mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
9dec36995e
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.
243 lines
9.6 KiB
TypeScript
243 lines
9.6 KiB
TypeScript
/**
|
|
* Regenerates every derived copy of the plugin permission facts from the single source,
|
|
* server/src/nest/plugins/protocol/envelope.ts, plus the output contract
|
|
* (protocol/output-contract.ts) and the addon ids (src/addons.ts) the SDK publishes.
|
|
*
|
|
* node --import tsx server/scripts/gen-plugin-facts.ts # write
|
|
* node --import tsx server/scripts/gen-plugin-facts.ts --check # exit 1 on drift
|
|
*
|
|
* It *imports* envelope.ts rather than scraping it. The guard it replaces
|
|
* (plugin-sdk/test/permissions-parity.test.ts) pulled the block out with a regex, which
|
|
* goes quietly null the moment someone reformats a closing brace — and that test only ran
|
|
* from prepublishOnly, so the failure would have surfaced as a broken npm release.
|
|
*
|
|
* The generated files are CHECKED IN: publish-plugin-sdk runs inside a standalone
|
|
* plugin-sdk/ checkout with no server present, so a build-time artefact would ship an SDK
|
|
* with an empty permission list.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
HOOK_PERMISSION, KNOWN_METHODS, KNOWN_PERMISSIONS, METHOD_PERMISSION,
|
|
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX,
|
|
} from '../src/nest/plugins/protocol/envelope';
|
|
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
|
|
import {
|
|
PLUGIN_ENTITY_CONTRACT, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_OUTPUT, pluginEntityFields, type PluginEntityName,
|
|
type PluginMethodOutput,
|
|
} from '../src/nest/plugins/protocol/output-contract';
|
|
import { ADDON_IDS } from '../src/addons';
|
|
// A relative import of the shared SOURCE, not @trek/shared: the plugin-facts CI job
|
|
// installs only the server, so shared's dist does not exist there. The file has no
|
|
// imports of its own for exactly this reason.
|
|
import {
|
|
PLUGIN_POI_ICONS, PLUGIN_POI_LABEL_MAX, PLUGIN_POI_MAX_CATEGORIES,
|
|
} from '../../shared/src/plugins/plugin-poi-facts';
|
|
|
|
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
|
|
const HEADER = [
|
|
'// GENERATED — do not edit by hand.',
|
|
'// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts',
|
|
'// + server/src/nest/plugins/protocol/output-contract.ts + server/src/addons.ts',
|
|
'// + shared/src/plugins/plugin-poi-facts.ts',
|
|
'// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts',
|
|
'',
|
|
].join('\n');
|
|
|
|
const list = (xs: readonly string[]) => xs.map((x) => ` '${x}',`).join('\n');
|
|
const pairs = (o: Readonly<Record<string, string>>) =>
|
|
Object.entries(o).map(([k, v]) => ` ${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: '${v}',`).join('\n');
|
|
|
|
/**
|
|
* Types here are deliberately WIDE — Readonly<Record<string, string>> and string[] —
|
|
* matching exactly what plugin-sdk published before this file existed. An `as const`
|
|
* literal would be TS7053 under plugin-sdk/tsconfig.json's "strict": true, where
|
|
* grantGaps indexes HOOK_PERMISSION[key] with a plain string, and it would be a
|
|
* source-breaking type change for any plugin author doing a dynamic lookup. Strictness
|
|
* belongs on the host side, where the data source lives.
|
|
*/
|
|
const EVENT_FAMILIES = [...new Set([...Object.keys(ENTITY_ID_KEYS), ...Object.keys(SNAPSHOT_GRANT)])].sort();
|
|
|
|
/** `'trip'`, `'trip[]'`, `'host'` or `'readModel'`: what a method's result is. */
|
|
const describeOutput = (output: PluginMethodOutput): string =>
|
|
output.kind === 'entity' ? `${output.entity}${output.many ? '[]' : ''}` : output.kind;
|
|
|
|
const fieldList = (fields: readonly string[]) => fields.map((f) => ` '${f}',`).join('\n');
|
|
const ENTITY_FIELDS = (Object.keys(PLUGIN_ENTITY_CONTRACT) as PluginEntityName[])
|
|
.map((entity) => ` ${entity}: [\n${fieldList(pluginEntityFields(entity))}\n ],`)
|
|
.join('\n');
|
|
|
|
const ENTITY_NESTED = (Object.keys(PLUGIN_ENTITY_NESTED) as PluginEntityName[])
|
|
.map((entity) => {
|
|
const children = Object.entries(PLUGIN_ENTITY_NESTED[entity] ?? {}).map(([key, child]) => `${key}: '${child}'`);
|
|
return ` ${entity}: { ${children.join(', ')} },`;
|
|
})
|
|
.join('\n');
|
|
|
|
const METHOD_RESULT = Object.fromEntries(
|
|
Object.entries(PLUGIN_METHOD_OUTPUT).map(([method, output]) => [method, describeOutput(output)]),
|
|
);
|
|
|
|
const SDK_FACTS = `${HEADER}
|
|
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
|
${pairs(HOOK_PERMISSION)}
|
|
};
|
|
|
|
export const KNOWN_PERMISSIONS: string[] = [
|
|
${list(KNOWN_PERMISSIONS)}
|
|
];
|
|
|
|
export const METHOD_PERMISSION: Readonly<Record<string, string>> = {
|
|
${pairs(METHOD_PERMISSION)}
|
|
};
|
|
|
|
export const KNOWN_METHODS: string[] = [
|
|
${list(KNOWN_METHODS)}
|
|
];
|
|
|
|
export const USER_DATA_PERMISSION = '${USER_DATA_PERMISSION}';
|
|
export const EVENTS_PERMISSION = '${EVENTS_PERMISSION}';
|
|
export const JOBS_PERMISSION = '${JOBS_PERMISSION}';
|
|
export const HTTP_OUTBOUND_PREFIX = '${HTTP_OUTBOUND_PREFIX}';
|
|
|
|
/**
|
|
* Core-event catalog. Delivery names are the WebSocket broadcast names,
|
|
* \`<family>:<verb>\` (e.g. \`place:created\`). A subscribed plugin receives
|
|
* \`{event, tripId, entity?, entityId?, snapshot?}\`; \`snapshot\` is delivered only
|
|
* when the plugin also holds EVENT_SNAPSHOT_GRANT[family]. Delete/reorder/bulk
|
|
* events carry no snapshot.
|
|
*/
|
|
export const EVENT_FAMILIES: readonly string[] = [
|
|
${list(EVENT_FAMILIES)}
|
|
];
|
|
|
|
export const EVENT_SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
|
|
${pairs(SNAPSHOT_GRANT)}
|
|
};
|
|
|
|
/**
|
|
* \`capabilities.poiCategories\` (#1781): the lucide icons a category may use, how many
|
|
* categories one plugin may declare, and the longest label.
|
|
*/
|
|
export const POI_CATEGORY_ICONS: string[] = [
|
|
${list(PLUGIN_POI_ICONS)}
|
|
];
|
|
|
|
export const POI_CATEGORY_MAX = ${PLUGIN_POI_MAX_CATEGORIES};
|
|
export const POI_CATEGORY_LABEL_MAX = ${PLUGIN_POI_LABEL_MAX};
|
|
|
|
/**
|
|
* The addon ids a manifest's \`requiredAddons\` may name: TREK's ADDON_IDS.
|
|
*/
|
|
export const KNOWN_ADDONS: string[] = [
|
|
${list(Object.values(ADDON_IDS))}
|
|
];
|
|
|
|
/**
|
|
* The fields each entity result carries, and nothing else: the row's published
|
|
* columns, then the keys the host adds (joined names, counts, hydrated children).
|
|
* A column TREK adds later is not delivered until it is listed here. A literal,
|
|
* unlike the wide tables above, because it is new (nothing published depends on a
|
|
* wider type) and the SDK's entity interfaces are type-checked against it.
|
|
*/
|
|
export const PLUGIN_ENTITY_FIELDS = {
|
|
${ENTITY_FIELDS}
|
|
} as const;
|
|
|
|
/**
|
|
* The fields of an entity that hold rows of another entity (one row or a list),
|
|
* and which one. Those rows carry that entity's PLUGIN_ENTITY_FIELDS and no others,
|
|
* however deep they sit, so a column TREK adds to a child table is held back the
|
|
* same way. A field not listed here holds a value TREK builds field by field.
|
|
*/
|
|
export const PLUGIN_ENTITY_NESTED: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
${ENTITY_NESTED}
|
|
};
|
|
|
|
/**
|
|
* What each ctx method returns: an entity from PLUGIN_ENTITY_FIELDS (\`trip\`, or
|
|
* \`trip[]\` for a list), \`host\` for a value the host builds itself or data the
|
|
* plugin owns, or \`readModel\` for a domain read model passed on as the app's own
|
|
* REST route returns it.
|
|
*/
|
|
export const PLUGIN_METHOD_RESULT: Readonly<Record<string, string>> = {
|
|
${pairs(METHOD_RESULT)}
|
|
};
|
|
`;
|
|
|
|
const SHARED_FACTS = `${HEADER}
|
|
/** Widened on purpose so client callers can .includes() with a plain string. */
|
|
export const PLUGIN_PERMISSIONS: readonly string[] = [
|
|
${list(KNOWN_PERMISSIONS)}
|
|
];
|
|
|
|
export const PLUGIN_HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
|
${pairs(HOOK_PERMISSION)}
|
|
};
|
|
`;
|
|
|
|
const OUTPUTS: Array<[string, string]> = [
|
|
['plugin-sdk/src/generated/host-facts.ts', SDK_FACTS],
|
|
['shared/src/plugin-permissions.ts', SHARED_FACTS],
|
|
];
|
|
|
|
/**
|
|
* Coverage assertions the type system cannot make, because the targets are not TypeScript
|
|
* unions. Both failure modes are silent today: a new permission reaches the consent screen
|
|
* as a raw code with no translation, and `trek-plugin permissions` prints it with no hint.
|
|
*/
|
|
function coverageProblems(): string[] {
|
|
const problems: string[] = [];
|
|
|
|
// Match the whole quoted key, not a prefix of it. `includes('plugins.perm.db:own')`
|
|
// also matches 'plugins.perm.db:ownX', and several permissions are prefixes of others
|
|
// ('db:read:files' vs 'db:read:files:content'), so a substring test passes on exactly
|
|
// the drift it is supposed to catch.
|
|
const enAdmin = fs.readFileSync(path.join(REPO, 'shared/src/i18n/en/admin.ts'), 'utf8');
|
|
const missingI18n = KNOWN_PERMISSIONS.filter((p) => !enAdmin.includes(`'admin.plugins.perm.${p}':`));
|
|
if (missingI18n.length) {
|
|
problems.push(
|
|
`shared/src/i18n/en/admin.ts is missing 'admin.plugins.perm.<permission>' for: ${missingI18n.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
const ui = fs.readFileSync(path.join(REPO, 'plugin-sdk/src/cli/ui.ts'), 'utf8');
|
|
const missingHint = KNOWN_PERMISSIONS.filter((p) => !ui.includes(`'${p}'`));
|
|
if (missingHint.length) {
|
|
problems.push(
|
|
`plugin-sdk/src/cli/ui.ts PERMISSION_FAMILIES does not cover: ${missingHint.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
return problems;
|
|
}
|
|
|
|
const check = process.argv.includes('--check');
|
|
let failed = false;
|
|
|
|
for (const [rel, content] of OUTPUTS) {
|
|
const abs = path.join(REPO, rel);
|
|
const current = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
|
|
if (current === content) continue;
|
|
|
|
if (check) {
|
|
console.error(`DRIFT: ${rel} does not match envelope.ts.`);
|
|
console.error(' Run: node --import tsx server/scripts/gen-plugin-facts.ts');
|
|
failed = true;
|
|
} else {
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, content);
|
|
console.log(`wrote ${rel}`);
|
|
}
|
|
}
|
|
|
|
for (const problem of coverageProblems()) {
|
|
console.error(`COVERAGE: ${problem}`);
|
|
failed = true;
|
|
}
|
|
|
|
if (failed) process.exit(1);
|
|
if (check) console.log('plugin facts are in sync with envelope.ts');
|