mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(plugins): extend the output allowlist to child rows and whole-row read models
The output contract cut only the top level of an entity result. A derived key's value went out untouched, so the children that are whole rows still carried every column: a day's notes_items are full day_notes entities and a reservation's endpoints are SELECT e.* on reservation_endpoints. About fifty methods were also left as readModel and passed through whole, many of them backed by SELECT * reads (day_accommodations, day_notes, todos, tags, packing bags, journal entries, collab, collections, vacay plans). A new column on any of those tables reached every plugin by default, which is what the contract was meant to stop and what the SDK README and the wiki already claimed it did. PLUGIN_ENTITY_NESTED now names, per entity, the derived keys that hold rows of another entity, and the shaper cuts those rows down to their own field list, recursively. dayNote and reservationEndpoint are the child tables that were whole rows; day.assignments and place.tags are held to the assignment and tag lists too, although their reads already name their columns. The remaining derived children (travelers, participants, ratings, budget members, payers and receipts, packing recipients, attachments, voters, bag members) are built field by field, which the contract comment now states as the rule for a derived key without a nested entity. The whole-row read models get entity contracts: accommodation, dayNote, todo, tag, category, packingBag, fileLink, collabNote, collabPoll, collabMessage, journey, journalEntry, bucketItem and collection, and vacayPlan with its holiday calendars. collections.listMine, collections.get and vacay.mine return envelopes around rows; an entity with a null table describes those, so their rows are reached through the same nesting. Every list holds exactly the keys each read returned before, so no plugin loses a field. What stays readModel is built field by field from named columns, and each such entry says what it returns: setBagMembers, atlas.visited, collections.savePlace and copyToTrip, the two vacay toggles and journal.addEntryPhoto. The schema test now covers every child and new table, and calls each newly shaped method on the real services, comparing nested rows key for key so the only field allowed to disappear is a withheld one. A further case proves the nested rows it compares are not empty. gen:plugin-facts publishes PLUGIN_ENTITY_NESTED to the SDK, and the SDK README, the plugin wiki pages and server/CLAUDE.md describe the nested rows and list the new entities.
This commit is contained in:
@@ -9,19 +9,31 @@
|
||||
* received when the contract was drawn, minus the credentials, and a new column stays
|
||||
* on the host until someone adds it here on purpose.
|
||||
*
|
||||
* The plugin SDK publishes the same lists (`PLUGIN_ENTITY_FIELDS`), generated from
|
||||
* this file by `server/scripts/gen-plugin-facts.ts`, and type-checks its own entity
|
||||
* interfaces against them. `tests/unit/plugins/output-contract.schema.test.ts` holds
|
||||
* the column lists to the migrated schema, so a new column fails CI until it is
|
||||
* classified as published or withheld.
|
||||
* The allowlist reaches into the rows a result carries as well. A derived key that
|
||||
* holds rows of another table (a day's `notes_items`, a reservation's `endpoints`)
|
||||
* names that child's entity in PLUGIN_ENTITY_NESTED, and the child is cut down to its
|
||||
* own field list the same way, however deep it sits. A derived key without an entry
|
||||
* there holds a value the host builds field by field (a count, `{ user_id, username }`
|
||||
* pairs, an id list), which cannot pick up a new column on its own.
|
||||
*
|
||||
* The plugin SDK publishes the same lists (`PLUGIN_ENTITY_FIELDS`,
|
||||
* `PLUGIN_ENTITY_NESTED`), generated from this file by
|
||||
* `server/scripts/gen-plugin-facts.ts`, and type-checks its own entity interfaces
|
||||
* against them. `tests/unit/plugins/output-contract.schema.test.ts` holds the column
|
||||
* lists to the migrated schema, so a new column fails CI until it is classified as
|
||||
* published or withheld.
|
||||
*
|
||||
* Pure data and one pure function, like envelope.ts: no imports with side effects.
|
||||
*/
|
||||
import type { KnownMethod, UnconditionalMethod } from './envelope';
|
||||
|
||||
export interface PluginEntityContract {
|
||||
/** The table the rows are read from. */
|
||||
readonly table: string;
|
||||
/**
|
||||
* The table the rows are read from, or null for an envelope: an object the host
|
||||
* assembles around rows (`{ collections, incomingInvites }`), whose keys are all
|
||||
* `derived`.
|
||||
*/
|
||||
readonly table: string | null;
|
||||
/** The table's columns a plugin receives, in the order the row carries them. */
|
||||
readonly columns: readonly string[];
|
||||
/** Columns a plugin never receives: credentials, whichever read path carries them. */
|
||||
@@ -126,7 +138,7 @@ export const PLUGIN_ENTITY_CONTRACT = {
|
||||
'leg_transport_mode', 'incoming_leg_transport_mode', 'route_excluded', 'accommodation_id', 'created_at',
|
||||
],
|
||||
withheld: [],
|
||||
// The assignment read model names its fields one by one.
|
||||
// The assignment read model names its fields one by one, its `place` included.
|
||||
wholeRow: false,
|
||||
derived: ['participants', 'tour_place_id', 'tour_route_geometry', 'place'],
|
||||
},
|
||||
@@ -139,10 +151,208 @@ export const PLUGIN_ENTITY_CONTRACT = {
|
||||
wholeRow: false,
|
||||
derived: [],
|
||||
},
|
||||
// A day's `notes_items` and the daynotes.* results.
|
||||
dayNote: {
|
||||
table: 'day_notes',
|
||||
columns: ['id', 'day_id', 'trip_id', 'text', 'time', 'icon', 'sort_order', 'created_at', 'color'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [],
|
||||
},
|
||||
// A reservation's `endpoints`: the stops of a flight, train or car booking.
|
||||
reservationEndpoint: {
|
||||
table: 'reservation_endpoints',
|
||||
columns: [
|
||||
'id', 'reservation_id', 'role', 'sequence', 'name', 'code', 'lat', 'lng', 'timezone', 'local_time',
|
||||
'local_date', 'created_at',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [],
|
||||
},
|
||||
// The tags.* results and a place's `tags` (whose read names its columns, without
|
||||
// `user_id` on the compact projection).
|
||||
tag: {
|
||||
table: 'tags',
|
||||
columns: ['id', 'user_id', 'name', 'color', 'created_at'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [],
|
||||
},
|
||||
todo: {
|
||||
table: 'todo_items',
|
||||
columns: [
|
||||
'id', 'trip_id', 'name', 'checked', 'category', 'sort_order', 'due_date', 'description', 'assigned_user_id',
|
||||
'priority', 'created_at', 'reminded_at',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [],
|
||||
},
|
||||
// trips.getAccommodations and accommodations.create/update.
|
||||
accommodation: {
|
||||
table: 'day_accommodations',
|
||||
columns: [
|
||||
'id', 'trip_id', 'place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out',
|
||||
'confirmation', 'notes', 'created_at',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['place_name', 'place_address', 'place_image', 'place_lat', 'place_lng', 'reservation_title'],
|
||||
},
|
||||
packingBag: {
|
||||
table: 'packing_bags',
|
||||
columns: ['id', 'trip_id', 'name', 'color', 'weight_limit_grams', 'sort_order', 'created_at', 'user_id'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['assigned_username', 'members', 'total_weight_grams'],
|
||||
},
|
||||
// files.createLink: the file's links after the new one was added.
|
||||
fileLink: {
|
||||
table: 'file_links',
|
||||
columns: ['id', 'file_id', 'reservation_id', 'assignment_id', 'place_id', 'created_at', 'budget_item_id'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [],
|
||||
},
|
||||
collabNote: {
|
||||
table: 'collab_notes',
|
||||
columns: [
|
||||
'id', 'trip_id', 'user_id', 'category', 'title', 'content', 'color', 'pinned', 'created_at', 'updated_at',
|
||||
'website',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['username', 'avatar', 'avatar_url', 'attachments'],
|
||||
},
|
||||
// `options` is the column, delivered decoded with each option's voters.
|
||||
collabPoll: {
|
||||
table: 'collab_polls',
|
||||
columns: ['id', 'trip_id', 'user_id', 'question', 'options', 'multiple', 'closed', 'deadline', 'created_at'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['username', 'avatar', 'avatar_url', 'is_closed', 'multiple_choice'],
|
||||
},
|
||||
collabMessage: {
|
||||
table: 'collab_messages',
|
||||
columns: ['id', 'trip_id', 'user_id', 'text', 'reply_to', 'created_at', 'deleted'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [
|
||||
'username', 'avatar', 'reply_text', 'reply_username', 'user_avatar', 'avatar_url', 'reactions', 'attachments',
|
||||
],
|
||||
},
|
||||
journey: {
|
||||
table: 'journeys',
|
||||
columns: [
|
||||
'id', 'user_id', 'title', 'subtitle', 'cover_gradient', 'status', 'created_at', 'updated_at', 'cover_image',
|
||||
'show_trip_tracks', 'show_verdict', 'show_mood', 'show_weather', 'status_override', 'photo_location',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['entry_count', 'photo_count', 'place_count', 'trip_date_min', 'trip_date_max'],
|
||||
},
|
||||
// `tags` and `pros_cons` are the columns, delivered decoded from their JSON.
|
||||
journalEntry: {
|
||||
table: 'journey_entries',
|
||||
columns: [
|
||||
'id', 'journey_id', 'source_trip_id', 'source_place_id', 'author_id', 'type', 'title', 'story', 'entry_date',
|
||||
'entry_time', 'location_name', 'location_lat', 'location_lng', 'mood', 'weather', 'tags', 'visibility',
|
||||
'sort_order', 'created_at', 'updated_at', 'pros_cons', 'stats_excluded', 'dismissed', 'country_code',
|
||||
'source_assignment_id', 'is_draft',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['photos', 'source_trip_name'],
|
||||
},
|
||||
category: {
|
||||
table: 'categories',
|
||||
columns: ['id', 'name', 'color', 'icon', 'user_id', 'created_at'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [],
|
||||
},
|
||||
bucketItem: {
|
||||
table: 'bucket_list',
|
||||
columns: [
|
||||
'id', 'user_id', 'name', 'lat', 'lng', 'country_code', 'notes', 'created_at', 'target_date', 'visited_at',
|
||||
'visited_source', 'region_code',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [],
|
||||
},
|
||||
// `links` is the column, delivered decoded.
|
||||
collection: {
|
||||
table: 'collections',
|
||||
columns: [
|
||||
'id', 'owner_id', 'name', 'description', 'color', 'icon', 'cover_image', 'links', 'sort_order', 'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['place_count', 'members', 'is_owner', 'labels'],
|
||||
},
|
||||
// collections.listMine. The invites are built field by field.
|
||||
collectionListing: {
|
||||
table: null,
|
||||
columns: [],
|
||||
withheld: [],
|
||||
wholeRow: false,
|
||||
derived: ['collections', 'incomingInvites'],
|
||||
},
|
||||
// collections.get. The saved places name their columns one by one.
|
||||
collectionDetail: {
|
||||
table: null,
|
||||
columns: [],
|
||||
withheld: [],
|
||||
wholeRow: false,
|
||||
derived: ['collection', 'places'],
|
||||
},
|
||||
vacayPlan: {
|
||||
table: 'vacay_plans',
|
||||
columns: [
|
||||
'id', 'owner_id', 'block_weekends', 'holidays_enabled', 'holidays_region', 'school_holidays_enabled',
|
||||
'company_holidays_enabled', 'carry_over_enabled', 'created_at', 'weekend_days', 'week_start',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['holiday_calendars'],
|
||||
},
|
||||
vacayHolidayCalendar: {
|
||||
table: 'vacay_holiday_calendars',
|
||||
columns: ['id', 'plan_id', 'type', 'region', 'label', 'color', 'sort_order'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [],
|
||||
},
|
||||
// vacay.mine. The users and the invites are built from named columns.
|
||||
vacayPlanData: {
|
||||
table: null,
|
||||
columns: [],
|
||||
withheld: [],
|
||||
wholeRow: false,
|
||||
derived: ['plan', 'users', 'pendingInvites', 'incomingInvites', 'isOwner', 'isFused'],
|
||||
},
|
||||
} as const satisfies Record<string, PluginEntityContract>;
|
||||
|
||||
export type PluginEntityName = keyof typeof PLUGIN_ENTITY_CONTRACT;
|
||||
|
||||
/**
|
||||
* The derived keys that hold rows of another entity, per entity: the value (one row or
|
||||
* a list of rows) is cut down to that entity's own fields, recursively. Every key here
|
||||
* is one of the entity's `derived` keys (a unit test holds that).
|
||||
*/
|
||||
export const PLUGIN_ENTITY_NESTED: { readonly [E in PluginEntityName]?: Readonly<Record<string, PluginEntityName>> } = {
|
||||
place: { tags: 'tag' },
|
||||
day: { assignments: 'assignment', notes_items: 'dayNote' },
|
||||
reservation: { endpoints: 'reservationEndpoint' },
|
||||
collectionListing: { collections: 'collection' },
|
||||
collectionDetail: { collection: 'collection' },
|
||||
vacayPlan: { holiday_calendars: 'vacayHolidayCalendar' },
|
||||
vacayPlanData: { plan: 'vacayPlan' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The output of one RPC method.
|
||||
*
|
||||
@@ -151,8 +361,9 @@ export type PluginEntityName = keyof typeof PLUGIN_ENTITY_CONTRACT;
|
||||
* a token) or data the plugin owns (its own database, its metadata, a peer's
|
||||
* answer). There is no stored row behind it to leak.
|
||||
* - `readModel`: a domain read model shared with the REST route, passed through as
|
||||
* the app returns it. The SDK types these results as `unknown`; they are the next
|
||||
* candidates for an entity contract.
|
||||
* the app returns it. Each one is built field by field from named columns (see the
|
||||
* comments on the entries below), so no stored row passes through whole and a new
|
||||
* column cannot reach it by itself. The SDK types these results as `unknown`.
|
||||
*/
|
||||
export type PluginMethodOutput =
|
||||
| { readonly kind: 'entity'; readonly entity: PluginEntityName; readonly many: boolean }
|
||||
@@ -174,81 +385,88 @@ export const PLUGIN_METHOD_OUTPUT = {
|
||||
'trips.getPlaces': rows('place'),
|
||||
'trips.getReservations': rows('reservation'),
|
||||
'trips.getDays': rows('day'),
|
||||
'trips.getAccommodations': READ_MODEL,
|
||||
'trips.getAccommodations': rows('accommodation'),
|
||||
'trips.listMine': rows('trip'),
|
||||
'reservations.listMine': rows('reservation'),
|
||||
'reservations.create': row('reservation'),
|
||||
'reservations.update': row('reservation'),
|
||||
'reservations.delete': HOST,
|
||||
'accommodations.create': READ_MODEL,
|
||||
'accommodations.update': READ_MODEL,
|
||||
'accommodations.create': row('accommodation'),
|
||||
'accommodations.update': row('accommodation'),
|
||||
'accommodations.delete': HOST,
|
||||
'packing.list': rows('packingItem'),
|
||||
'packing.create': row('packingItem'),
|
||||
'packing.update': row('packingItem'),
|
||||
'packing.delete': HOST,
|
||||
'packing.listBags': READ_MODEL,
|
||||
'packing.createBag': READ_MODEL,
|
||||
'packing.updateBag': READ_MODEL,
|
||||
'packing.listBags': rows('packingBag'),
|
||||
'packing.createBag': row('packingBag'),
|
||||
'packing.updateBag': row('packingBag'),
|
||||
'packing.deleteBag': HOST,
|
||||
// The bag's members: user_id, username and avatar, selected by name.
|
||||
'packing.setBagMembers': READ_MODEL,
|
||||
'files.list': rows('tripFile'),
|
||||
'files.getContent': HOST,
|
||||
'files.create': row('tripFile'),
|
||||
'files.createLink': READ_MODEL,
|
||||
'files.createLink': rows('fileLink'),
|
||||
'files.update': row('tripFile'),
|
||||
'files.softDelete': HOST,
|
||||
'collab.listNotes': READ_MODEL,
|
||||
'collab.listPolls': READ_MODEL,
|
||||
'collab.listMessages': READ_MODEL,
|
||||
'collab.createNote': READ_MODEL,
|
||||
'collab.createPoll': READ_MODEL,
|
||||
'collab.votePoll': READ_MODEL,
|
||||
'collab.createMessage': READ_MODEL,
|
||||
'collab.listNotes': rows('collabNote'),
|
||||
'collab.listPolls': rows('collabPoll'),
|
||||
'collab.listMessages': rows('collabMessage'),
|
||||
'collab.createNote': row('collabNote'),
|
||||
'collab.createPoll': row('collabPoll'),
|
||||
'collab.votePoll': row('collabPoll'),
|
||||
'collab.createMessage': row('collabMessage'),
|
||||
'trips.addMember': HOST,
|
||||
'trips.removeMember': HOST,
|
||||
'trips.create': row('trip'),
|
||||
'journal.listMine': READ_MODEL,
|
||||
'journal.getEntries': READ_MODEL,
|
||||
'journal.listMine': rows('journey'),
|
||||
'journal.getEntries': rows('journalEntry'),
|
||||
// { countries, regions }: country and region codes selected by name.
|
||||
'atlas.visited': READ_MODEL,
|
||||
'atlas.bucketList': READ_MODEL,
|
||||
'atlas.bucketList': rows('bucketItem'),
|
||||
'rates.get': HOST,
|
||||
'vacay.mine': READ_MODEL,
|
||||
'daynotes.list': READ_MODEL,
|
||||
'daynotes.create': READ_MODEL,
|
||||
'daynotes.update': READ_MODEL,
|
||||
'vacay.mine': row('vacayPlanData'),
|
||||
'daynotes.list': rows('dayNote'),
|
||||
'daynotes.create': row('dayNote'),
|
||||
'daynotes.update': row('dayNote'),
|
||||
'daynotes.delete': HOST,
|
||||
'collections.listMine': READ_MODEL,
|
||||
'collections.get': READ_MODEL,
|
||||
'collections.create': READ_MODEL,
|
||||
'collections.update': READ_MODEL,
|
||||
'collections.listMine': row('collectionListing'),
|
||||
'collections.get': row('collectionDetail'),
|
||||
'collections.create': row('collection'),
|
||||
'collections.update': row('collection'),
|
||||
// { place } with the saved place's named columns, or { duplicate, duplicateOf: { id, name } }.
|
||||
'collections.savePlace': READ_MODEL,
|
||||
// { copied, skipped: [{ id, name }] }.
|
||||
'collections.copyToTrip': READ_MODEL,
|
||||
'collections.deletePlace': HOST,
|
||||
'atlas.markCountry': HOST,
|
||||
'atlas.unmarkCountry': HOST,
|
||||
'atlas.markRegion': HOST,
|
||||
'atlas.unmarkRegion': HOST,
|
||||
'atlas.createBucketItem': READ_MODEL,
|
||||
'atlas.createBucketItem': row('bucketItem'),
|
||||
'atlas.deleteBucketItem': HOST,
|
||||
// { action, fraction, kind }: what the toggle did.
|
||||
'vacay.toggleEntry': READ_MODEL,
|
||||
// { action, fraction }: what the toggle did.
|
||||
'vacay.toggleCompanyHoliday': READ_MODEL,
|
||||
'journal.createEntry': READ_MODEL,
|
||||
'journal.createEntry': row('journalEntry'),
|
||||
// The gallery photo as linked to the entry, its columns selected by name.
|
||||
'journal.addEntryPhoto': READ_MODEL,
|
||||
'journal.updateEntry': READ_MODEL,
|
||||
'journal.updateEntry': row('journalEntry'),
|
||||
'journal.deleteEntry': HOST,
|
||||
'journal.createJourney': READ_MODEL,
|
||||
'journal.createJourney': row('journey'),
|
||||
'journal.deleteJourney': HOST,
|
||||
'weather.get': HOST,
|
||||
'categories.list': READ_MODEL,
|
||||
'tags.list': READ_MODEL,
|
||||
'tags.create': READ_MODEL,
|
||||
'tags.update': READ_MODEL,
|
||||
'categories.list': rows('category'),
|
||||
'tags.list': rows('tag'),
|
||||
'tags.create': row('tag'),
|
||||
'tags.update': row('tag'),
|
||||
'tags.delete': HOST,
|
||||
'trips.members': rows('user'),
|
||||
'todos.list': READ_MODEL,
|
||||
'todos.create': READ_MODEL,
|
||||
'todos.update': READ_MODEL,
|
||||
'todos.list': rows('todo'),
|
||||
'todos.create': row('todo'),
|
||||
'todos.update': row('todo'),
|
||||
'todos.delete': HOST,
|
||||
'costs.getByTrip': rows('budgetItem'),
|
||||
'costs.listMine': rows('budgetItem'),
|
||||
@@ -288,20 +506,42 @@ export function pluginEntityFields(entity: PluginEntityName): readonly string[]
|
||||
return [...contract.columns, ...contract.derived];
|
||||
}
|
||||
|
||||
const fieldSets = new Map<PluginEntityName, ReadonlySet<string>>();
|
||||
|
||||
function fieldSetOf(entity: PluginEntityName): ReadonlySet<string> {
|
||||
let fields = fieldSets.get(entity);
|
||||
if (!fields) {
|
||||
fields = new Set(pluginEntityFields(entity));
|
||||
fieldSets.set(entity, fields);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of `row` holding only the published fields, in the row's own key order.
|
||||
* Values are passed on untouched. Anything that is not a plain row (null, undefined,
|
||||
* a primitive) passes through as it is, so "not found" keeps its wire form.
|
||||
* A copy of `value` holding only the published fields of `entity`, in the row's own
|
||||
* key order. A field that holds rows of another entity is cut down the same way;
|
||||
* every other value is passed on untouched. Anything that is not a plain row (null,
|
||||
* undefined, a primitive, an array) passes through as it is, so "not found" keeps its
|
||||
* wire form.
|
||||
*/
|
||||
function pickFields(value: unknown, fields: ReadonlySet<string>): unknown {
|
||||
function pickFields(value: unknown, entity: PluginEntityName): unknown {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
|
||||
const fields = fieldSetOf(entity);
|
||||
const nested = PLUGIN_ENTITY_NESTED[entity];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, field] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (fields.has(key)) out[key] = field;
|
||||
if (!fields.has(key)) continue;
|
||||
const child = nested?.[key];
|
||||
out[key] = child ? shapeRows(field, child) : field;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A child value: one row or a list of rows of `entity`, each cut down to its fields. */
|
||||
function shapeRows(value: unknown, entity: PluginEntityName): unknown {
|
||||
return Array.isArray(value) ? value.map((item) => pickFields(item, entity)) : pickFields(value, entity);
|
||||
}
|
||||
|
||||
function outputOf(method: string): PluginMethodOutput | undefined {
|
||||
return (PLUGIN_METHOD_OUTPUT as Readonly<Record<string, PluginMethodOutput | undefined>>)[method];
|
||||
}
|
||||
@@ -315,7 +555,6 @@ export function returnsEntity(method: string): boolean {
|
||||
export function shapePluginOutput(method: string, result: unknown): unknown {
|
||||
const output = outputOf(method);
|
||||
if (output?.kind !== 'entity') return result;
|
||||
const fields: ReadonlySet<string> = new Set(pluginEntityFields(output.entity));
|
||||
if (output.many) return Array.isArray(result) ? result.map((item) => pickFields(item, fields)) : result;
|
||||
return pickFields(result, fields);
|
||||
if (output.many) return Array.isArray(result) ? result.map((item) => pickFields(item, output.entity)) : result;
|
||||
return pickFields(result, output.entity);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user