diff --git a/.changeset/19852-typescript-serializer-per-type-annotation.md b/.changeset/19852-typescript-serializer-per-type-annotation.md new file mode 100644 index 00000000000..830b96e6fc9 --- /dev/null +++ b/.changeset/19852-typescript-serializer-per-type-annotation.md @@ -0,0 +1,15 @@ +--- +"@objectstack/metadata": patch +--- + +`TypeScriptSerializer` no longer annotates every `typescript`-format file `ServiceObject` (#19852). A saved view, or any other item that is not an object, used to be written as `export const metadata: ServiceObject = { … }`: a false annotation, which `tsc` refused with TS2353 (for a view, `'"type"' does not exist in type …`). + +If you type-check the `.ts` files that `MetadataManager.save()` / `FilesystemLoader.save()` write: + +- An `object` file written by the built-in serializer the package wires in is byte-identical: `import type { ServiceObject } from '@objectstack/spec/data'` and `export const metadata: ServiceObject = …`. +- Every other metadata type whose spec type is exactly the `z.input` type of its schema is now annotated with that type instead: `Flow` (`@objectstack/spec/automation`) for a `flow`, `Page` (`@objectstack/spec/ui`) for a `page`, `PermissionSet` (`@objectstack/spec/security`) for a `permission`, and so on: 28 annotated metadata types, `object` included. `tsc` now checks such a file against its own type instead of `ServiceObject`. +- `view`, `book`, `external_catalog` and any other metadata type (a plugin's own, for example) are written with no annotation and no import: `export const metadata = { … };`. `ViewMetadata` is `unknown` and `Book` is narrower than `BookSchema`, so neither would be a true annotation. +- A serializer you wire into `FilesystemLoader` by hand is called as it always was: a custom one, or a subclass that overrides `serialize()`, writes what its `serialize()` writes, and a `TypeScriptSerializer` taken from the package's other entry point (`.` versus `./node`) writes no annotation. +- If you call `TypeScriptSerializer.serialize()` yourself, it now writes no annotation for any item, an object included. It used to write `ServiceObject` whatever the item was, and it cannot know the item's metadata type, so that annotation could be false. The loader picks the annotation through a package-internal function. The public API is unchanged: `SerializeOptions` and every declaration the package exports are as before. + +Reading is unchanged: `deserialize` still reads the first JSON block, so a file written before this fix, `ServiceObject` annotation and all, still reads back. The `javascript` format is unchanged. diff --git a/packages/metadata/README.md b/packages/metadata/README.md index ad4cdeac263..c8269152b09 100644 --- a/packages/metadata/README.md +++ b/packages/metadata/README.md @@ -74,7 +74,7 @@ Serializers convert metadata objects to/from different file formats: - **JSONSerializer** — `.json` files with optional key sorting - **YAMLSerializer** — `.yaml`/`.yml` files (JSON_SCHEMA for security) -- **TypeScriptSerializer** — the `typescript` / `javascript` formats (`.ts` / `.js`): a JSON document wrapped in a module, the file format `FilesystemLoader` writes and reads for those two formats (`typescript` is `FilesystemLoader.save()`'s default, so `MetadataManager.save()` routed to the filesystem loader writes `{rootDir}/{type}/{name}.ts`). It writes `export const metadata = { …JSON… };` then `export default metadata;` — the `typescript` format also imports the `ServiceObject` type and annotates the constant with it, whatever the item's metadata type — and reads back the first `{ … }` block after the first `export const` (or, failing that, `export default`), which must be JSON: double-quoted keys and strings, no comments, no trailing commas, no functions. It is **not** an authoring shape: authored metadata such as a `*.object.ts` is written `ObjectSchema.create({ … })` (or `defineView()`, …), which this serializer never emits, and an authored file with unquoted keys is refused rather than read. +- **TypeScriptSerializer** — the `typescript` / `javascript` formats (`.ts` / `.js`): a JSON document wrapped in a module, the file format `FilesystemLoader` writes and reads for those two formats (`typescript` is `FilesystemLoader.save()`'s default, so `MetadataManager.save()` routed to the filesystem loader writes `{rootDir}/{type}/{name}.ts`). It writes `export const metadata = { …JSON… };` then `export default metadata;`. A `typescript`-format file that `FilesystemLoader.save()` writes with the built-in serializer the package wires in also annotates that constant with the spec type of the item's metadata type, and imports it: `ServiceObject` from `@objectstack/spec/data` for an `object`, `Flow` from `@objectstack/spec/automation` for a `flow`, and so on. Each is exactly the `z.input` type of the schema `getMetadataTypeSchema()` resolves for that metadata type. A metadata type with no such spec type is written with no annotation and no import: `view` (its `ViewMetadata` type is `unknown`), `book`, `external_catalog`, or a plugin's own type. Only the loader knows the metadata type, so it picks the annotation through a package-internal function, and `TypeScriptSerializer.serialize()` called directly writes no annotation. A serializer wired in by hand (a custom one, a subclass that overrides `serialize()`, or a `TypeScriptSerializer` from the package's other entry point) is called through its own `serialize()`. It reads back the first `{ … }` block after the first `export const` (or, failing that, `export default`), which must be JSON: double-quoted keys and strings, no comments, no trailing commas, no functions. It is **not** an authoring shape: authored metadata such as a `*.object.ts` is written `ObjectSchema.create({ … })` (or `defineView()`, …), which this serializer never emits, and an authored file with unquoted keys is refused rather than read. ### 4. Overlay / Customization System diff --git a/packages/metadata/src/loaders/filesystem-loader.ts b/packages/metadata/src/loaders/filesystem-loader.ts index 138de82cec2..b2313f272f2 100644 --- a/packages/metadata/src/loaders/filesystem-loader.ts +++ b/packages/metadata/src/loaders/filesystem-loader.ts @@ -22,6 +22,7 @@ import type { import type { Logger } from '@objectstack/core'; import type { MetadataLoader, MetadataKeyedItem } from './loader-interface.js'; import type { MetadataSerializer } from '../serializers/serializer-interface.js'; +import { TypeScriptSerializer, serializeTypeScriptForMetadataType } from '../serializers/typescript-serializer.js'; import { AmbiguousMetadataStemError } from './ambiguous-metadata-stem.js'; /** @@ -459,12 +460,25 @@ export class FilesystemLoader implements MetadataLoader { } } - // Serialize data - const content = serializer.serialize(data, { - prettify, - indent, - sortKeys, - }); + // Serialize data. The built-in `typescript` serializer is the one format + // that annotates, and the annotation depends on the metadata type, which + // only this call knows: it goes through the package-internal + // `serializeTypeScriptForMetadataType`, so the published + // `SerializeOptions` does not grow a key. + // + // The test is the METHOD, not the class: only when this module's own, + // un-overridden `TypeScriptSerializer.prototype.serialize` is the one + // that would run. `instanceof` also matched a subclass whose overridden + // `serialize()` must still be called. Any other serializer (a custom + // one, a subclass that overrides `serialize()`, or a `TypeScriptSerializer` + // from the package's other entry bundle, whose prototype is a different + // object) is called as it always was. + const serializeOptions = { prettify, indent, sortKeys }; + const content = + serializer.serialize === TypeScriptSerializer.prototype.serialize && + serializer.getFormat() === 'typescript' + ? serializeTypeScriptForMetadataType(data, type, serializeOptions) + : serializer.serialize(data, serializeOptions); // Write to disk (atomic or direct) if (atomic) { diff --git a/packages/metadata/src/serializers/serializers.test.ts b/packages/metadata/src/serializers/serializers.test.ts index 3943092f542..e5106489b31 100644 --- a/packages/metadata/src/serializers/serializers.test.ts +++ b/packages/metadata/src/serializers/serializers.test.ts @@ -1,7 +1,14 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { NodeMetadataManager } from '../node-metadata-manager.js'; import { JSONSerializer } from '../serializers/json-serializer.js'; import { YAMLSerializer } from '../serializers/yaml-serializer.js'; -import { TypeScriptSerializer } from '../serializers/typescript-serializer.js'; +import { TypeScriptSerializer, serializeTypeScriptForMetadataType } from '../serializers/typescript-serializer.js'; +import type { MetadataFormat } from '@objectstack/spec/system'; +import type { MetadataSerializer, SerializeOptions } from '../serializers/serializer-interface.js'; +import { FilesystemLoader } from '../loaders/filesystem-loader.js'; describe('Serializers', () => { describe('JSONSerializer', () => { @@ -55,14 +62,143 @@ describe('Serializers', () => { describe('TypeScriptSerializer', () => { const serializer = new TypeScriptSerializer('typescript'); + // The saved-view repro: a view used to be annotated `ServiceObject`, which + // has no top-level `type` or `columns` key, so `tsc` refused the file (TS2353). + const view = { name: 'all_accounts', type: 'grid', object: 'account', columns: ['name'] }; + const object = { name: 'account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }; + const plain = (item: unknown) => + `export const metadata = ${JSON.stringify(item, null, 2)};\n\nexport default metadata;\n`; + // Exactly what `FilesystemLoader.save('object', …)` wrote before this fix, and still writes. + const annotatedObject = + `import type { ServiceObject } from '@objectstack/spec/data';\n\n` + + `export const metadata: ServiceObject = ${JSON.stringify(object, null, 2)};\n\n` + + `export default metadata;\n`; + it('should serialize to TypeScript module', () => { const data = { name: 'test', value: 42 }; const result = serializer.serialize(data); - expect(result).toContain('import type'); expect(result).toContain('export const metadata'); expect(result).toContain('export default metadata'); }); + it('the public serialize() writes no annotation, even for an object: it does not know the metadata type', () => { + expect(serializer.serialize(object)).toBe(plain(object)); + expect(serializer.serialize(view)).toBe(plain(view)); + }); + + it('writes a view with no annotation: no ServiceObject, no import type', () => { + const result = serializeTypeScriptForMetadataType(view, 'view'); + expect(result).not.toContain('ServiceObject'); + expect(result).toBe(plain(view)); + }); + + it('still annotates an object ServiceObject, byte-identical to the earlier output', () => { + expect(serializeTypeScriptForMetadataType(object, 'object')).toBe(annotatedObject); + }); + + it.each([ + ['a metadata type the spec has no type for', 'external_catalog'], + ['a metadata type whose spec type is narrower than its schema', 'book'], + ['a plugin-registered metadata type', 'acme_widget'], + ['a plural spelling', 'objects'], + ])('writes no annotation for %s', (_label, metadataType) => { + expect(serializeTypeScriptForMetadataType({ name: 'x' }, metadataType)).toBe(plain({ name: 'x' })); + }); + + it('the javascript format never annotates', () => { + expect(new TypeScriptSerializer('javascript').serialize(object)).toBe(plain(object)); + }); + + it('reads back a file written before this fix: a view annotated ServiceObject', () => { + const legacy = + `import type { ServiceObject } from '@objectstack/spec/data';\n\n` + + `export const metadata: ServiceObject = ${JSON.stringify(view, null, 2)};\n\n` + + `export default metadata;\n`; + expect(serializer.deserialize(legacy)).toEqual(view); + }); + + it('round-trips a view and an object through serialize and deserialize', () => { + for (const [metadataType, item] of [['view', view], ['object', object]] as const) { + expect(serializer.deserialize(serializeTypeScriptForMetadataType(item, metadataType))).toEqual(item); + expect(serializer.deserialize(serializer.serialize(item))).toEqual(item); + } + }); + + it('FilesystemLoader.save() annotates by metadata type: the view file is unannotated, the object file is ServiceObject', async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-')); + try { + const manager = new NodeMetadataManager({ rootDir, watch: false }); + await manager.save('view', 'all_accounts', view); + await manager.save('object', 'account', object); + const viewFile = await fs.readFile(path.join(rootDir, 'view', 'all_accounts.ts'), 'utf-8'); + const objectFile = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8'); + expect(viewFile).not.toContain('ServiceObject'); + expect(viewFile).toBe(plain(view)); + expect(objectFile).toBe(annotatedObject); + } finally { + await fs.rm(rootDir, { recursive: true, force: true }); + } + }); + + it('FilesystemLoader.save() calls a custom serializer registered for typescript as it always did', async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-custom-')); + try { + const seen: unknown[] = []; + const custom: MetadataSerializer = { + serialize: (item, options) => { + seen.push(options); + return `// custom\nexport const metadata = ${JSON.stringify(item)};\n`; + }, + deserialize: (content) => serializer.deserialize(content), + getExtension: () => '.ts', + canHandle: (format) => format === 'typescript', + getFormat: () => 'typescript', + }; + const loader = new FilesystemLoader(rootDir, new Map([['typescript', custom]])); + await loader.save('object', 'account', object); + const file = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8'); + expect(file).toBe(`// custom\nexport const metadata = ${JSON.stringify(object)};\n`); + expect(seen).toEqual([{ prettify: true, indent: 2, sortKeys: false }]); + } finally { + await fs.rm(rootDir, { recursive: true, force: true }); + } + }); + + // A FilesystemLoader wired by hand with one `typescript` serializer, saving `object`. + const saveObjectWith = async (tsSerializer: MetadataSerializer): Promise => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-wired-')); + try { + const loader = new FilesystemLoader(rootDir, new Map([['typescript', tsSerializer]])); + await loader.save('object', 'account', object); + return await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8'); + } finally { + await fs.rm(rootDir, { recursive: true, force: true }); + } + }; + + it('FilesystemLoader.save() calls a subclass that overrides serialize(), as it always did', async () => { + class HeaderSerializer extends TypeScriptSerializer { + override serialize(item: T, options?: SerializeOptions): string { + return '// header\n' + super.serialize(item, options); + } + } + expect(await saveObjectWith(new HeaderSerializer('typescript'))).toBe('// header\n' + plain(object)); + }); + + it('FilesystemLoader.save() annotates through the built-in serialize(), inherited by a subclass or not', async () => { + class KeepsSerialize extends TypeScriptSerializer {} + expect(await saveObjectWith(new TypeScriptSerializer('typescript'))).toBe(annotatedObject); + expect(await saveObjectWith(new KeepsSerialize('typescript'))).toBe(annotatedObject); + }); + + it('FilesystemLoader.save() calls a TypeScriptSerializer from another module copy through its own serialize(): no annotation', async () => { + // The published `.` and `./node` entries are separate bundles, each with its own class copy. + vi.resetModules(); + const other = await import('../serializers/typescript-serializer.js'); + expect(other.TypeScriptSerializer).not.toBe(TypeScriptSerializer); + expect(await saveObjectWith(new other.TypeScriptSerializer('typescript'))).toBe(plain(object)); + }); + it('should get correct extension', () => { const ts = new TypeScriptSerializer('typescript'); expect(ts.getExtension()).toBe('.ts'); diff --git a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts new file mode 100644 index 00000000000..01a3067f0a1 --- /dev/null +++ b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts @@ -0,0 +1,248 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `FilesystemLoader.save()` annotates a `typescript`-format file with the spec + * type of the item's metadata type, through the package-internal + * `serializeTypeScriptForMetadataType`. This file holds that annotation to the + * one property that makes it worth writing: it is never false. + * + * Every annotated metadata type is compiled here with `tsc`: + * - its spec type must be IDENTICAL to `z.input` of the schema + * `getMetadataTypeSchema()` binds (the table's admission rule); + * - a spec-valid body must type-check with no diagnostic at all; + * - the same body plus one undeclared key must fail with exactly TS2353. That + * is what makes the annotation a check: a spec type that is `unknown` (as + * `ViewMetadata` is) would pass the first file and the second. + * + * `@objectstack/spec` is resolved the way a consumer of a saved file resolves + * it, through this package's `node_modules` and the `exports` map's `types`, + * so this reads spec's BUILT `dist/*.d.ts`. `turbo test` builds it first + * (`^build`); a bare `vitest run` needs `pnpm --filter @objectstack/spec build`. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { MetadataTypeSchema, getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import * as SpecData from '@objectstack/spec/data'; +import * as SpecUi from '@objectstack/spec/ui'; +import * as SpecAutomation from '@objectstack/spec/automation'; +import * as SpecSystem from '@objectstack/spec/system'; +import * as SpecApi from '@objectstack/spec/api'; +import * as SpecSecurity from '@objectstack/spec/security'; +import * as SpecIdentity from '@objectstack/spec/identity'; +import * as SpecAi from '@objectstack/spec/ai'; +import * as SpecIntegration from '@objectstack/spec/integration'; +import { TypeScriptSerializer, serializeTypeScriptForMetadataType } from './typescript-serializer.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** One spec-valid body per metadata type the serializer annotates. */ +const REPRESENTATIVE: Record> = { + object: { name: 'account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }, + field: { name: 'title', type: 'text', label: 'Title' }, + hook: { name: 'account_audit', object: 'account', events: ['beforeInsert'], handler: 'audit_account' }, + seed: { object: 'account', records: [{ name: 'Acme' }] }, + mapping: { name: 'account_import', targetObject: 'account', fieldMapping: [] }, + datasource: { name: 'warehouse', driver: 'sqlite', config: {} }, + analytics_cube: { name: 'account_cube', sql: 'account', measures: {}, dimensions: {} }, + page: { name: 'account_home', label: 'Account Home', regions: [] }, + dashboard: { name: 'ops', label: 'Ops', widgets: [] }, + app: { name: 'sales', label: 'Sales' }, + action: { name: 'close_account', label: 'Close', type: 'script', target: 'close_account' }, + report: { name: 'hours_by_status', label: 'Hours by status', dataset: 'task_metrics', rows: ['status'], values: ['est_hours'] }, + dataset: { name: 'account_ds', label: 'Accounts', object: 'account', dimensions: [], measures: [] }, + flow: { name: 'notify_owner', label: 'Notify owner', type: 'autolaunched', nodes: [], edges: [] }, + webhook: { name: 'account_hook', object: 'account', triggers: ['create'], url: 'https://example.com/hook' }, + job: { name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 2 * * *' }, handler: 'sweep' }, + translation: { locale: 'en', objects: { account: { label: 'Account' } } }, + email_template: { name: 'welcome', label: 'Welcome', subject: 'Welcome', bodyHtml: 'Hello' }, + doc: { name: 'getting_started', label: 'Getting Started', content: '# Hello' }, + api: { name: 'ping', path: '/api/v1/apps/demo/ping', method: 'GET', type: 'object_operation', target: 'account' }, + permission: { name: 'sales_user', label: 'Sales user', objects: {} }, + sharing_rule: { + name: 'share_accounts', object: 'account', type: 'criteria', condition: 'true', + sharedWith: { type: 'team', value: 'sales' }, accessLevel: 'read', + }, + capability: { name: 'manage_accounts', label: 'Manage accounts' }, + position: { name: 'sales_rep', label: 'Sales rep' }, + agent: { name: 'support_agent', label: 'Support', role: 'Support', instructions: 'Help users.', skills: ['case_management'] }, + tool: { name: 'list_records', label: 'List records', description: 'Lists records', parameters: {} }, + skill: { name: 'case_management', label: 'Case management', tools: [] }, + connector: { name: 'status_api', label: 'Status API', type: 'api', authentication: { type: 'none' } }, +}; + +/** + * The schema `getMetadataTypeSchema()` binds for each annotated metadata type, + * by its export name on the annotation's own `@objectstack/spec` subpath. The + * binding is pinned at run time below, and the type identity by `tsc`. + */ +const BOUND_SCHEMA: Record = { + object: 'ObjectSchema', field: 'FieldSchema', hook: 'HookSchema', seed: 'SeedSchema', + mapping: 'MappingSchema', datasource: 'DatasourceSchema', analytics_cube: 'CubeSchema', + page: 'PageSchema', dashboard: 'DashboardSchema', app: 'AppSchema', action: 'ActionSchema', + report: 'ReportSchema', dataset: 'DatasetSchema', flow: 'FlowSchema', webhook: 'WebhookSchema', + job: 'JobSchema', translation: 'TranslationItemSchema', email_template: 'EmailTemplateDefinitionSchema', + doc: 'DocSchema', api: 'ApiEndpointSchema', permission: 'PermissionSetSchema', + sharing_rule: 'SharingRuleSchema', capability: 'CapabilityDeclarationSchema', position: 'PositionSchema', + agent: 'AgentSchema', tool: 'ToolSchema', skill: 'SkillSchema', connector: 'DeclarativeConnectorEntrySchema', +}; + +const SPEC_SUBPATHS: Record> = { + data: SpecData, ui: SpecUi, automation: SpecAutomation, system: SpecSystem, api: SpecApi, + security: SpecSecurity, identity: SpecIdentity, ai: SpecAi, integration: SpecIntegration, +}; + +/** `[type name, subpath]` of the annotation the loader path writes for a metadata type. */ +function annotationOf(metadataType: string): readonly [typeName: string, subpath: string] { + const m = /^import type \{ (\w+) \} from '@objectstack\/spec\/(\w+)';/.exec( + serializeTypeScriptForMetadataType({}, metadataType), + ); + if (!m) throw new Error(`no annotation for ${metadataType}`); + return [m[1], m[2]]; +} + +/** Stack collections `getMetadataTypeSchema()` binds that are not `MetadataTypeSchema` members. */ +const NON_MEMBER_TYPES = ['webhook', 'connector', 'sharing_rule', 'analytics_cube']; + +const serializer = new TypeScriptSerializer('typescript'); +const annotates = (metadataType: string): boolean => + serializeTypeScriptForMetadataType({ name: 'x' }, metadataType).startsWith('import type {'); + +/** This package's `exports` entries, as the source modules tsup builds them from. */ +const PACKAGE_ROOT = resolve(HERE, '../..'); +const EXPORT_ENTRY_SOURCES: string[] = Object.values( + JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).exports as Record, +).map((entry) => join(PACKAGE_ROOT, entry.import.default.replace(/^\.\/dist\//, 'src/').replace(/\.js$/, '.ts'))); + +/** Type-check in-memory files as if they sat in this directory. */ +function typeCheck(files: ReadonlyMap): readonly ts.Diagnostic[] { + const options: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }; + const host = ts.createCompilerHost(options, true); + const { getSourceFile, fileExists, readFile } = host; + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + const text = files.get(fileName); + return text === undefined + ? getSourceFile.call(host, fileName, languageVersion, onError, shouldCreate) + : ts.createSourceFile(fileName, text, languageVersion, true); + }; + host.fileExists = (fileName) => files.has(fileName) || fileExists.call(host, fileName); + host.readFile = (fileName) => files.get(fileName) ?? readFile.call(host, fileName); + return ts.getPreEmitDiagnostics(ts.createProgram([...files.keys()], options, host)); +} + +describe('TypeScriptSerializer annotation, per metadata type', () => { + it('every representative body is valid for its metadata type', () => { + for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { + const result = getMetadataTypeSchema(metadataType)?.safeParse(item); + expect(result?.success, metadataType).toBe(true); + } + }); + + it('annotates exactly the representative metadata types, and never view, book or external_catalog', () => { + const universe = new Set([...MetadataTypeSchema.options, ...NON_MEMBER_TYPES, ...Object.keys(REPRESENTATIVE)]); + const annotated = [...universe].filter(annotates).sort(); + expect(annotated).toEqual(Object.keys(REPRESENTATIVE).sort()); + for (const metadataType of ['view', 'book', 'external_catalog']) { + expect(annotates(metadataType), metadataType).toBe(false); + } + }); + + it('the public TypeScriptSerializer.serialize() annotates none of them', () => { + for (const item of Object.values(REPRESENTATIVE)) { + expect(serializer.serialize(item).startsWith('export const metadata = {')).toBe(true); + } + }); + + it('no exports entry of the package re-exports the internal channel', async () => { + // Control: the name is spelled right, so the absences below can fail. + expect(Object.keys(await import('./typescript-serializer.js'))).toContain('serializeTypeScriptForMetadataType'); + expect(EXPORT_ENTRY_SOURCES.length).toBe(5); + for (const source of EXPORT_ENTRY_SOURCES) { + const entry = (await import(source)) as Record; + expect(Object.keys(entry).length, source).toBeGreaterThan(0); + expect(Object.keys(entry), source).not.toContain('serializeTypeScriptForMetadataType'); + } + }, 60_000); + + it('each annotation names a spec type on the subpath that exports the schema getMetadataTypeSchema() binds', () => { + expect(Object.keys(BOUND_SCHEMA).sort()).toEqual(Object.keys(REPRESENTATIVE).sort()); + for (const [metadataType, schemaName] of Object.entries(BOUND_SCHEMA)) { + const [, subpath] = annotationOf(metadataType); + const bound = getMetadataTypeSchema(metadataType); + expect(bound, metadataType).toBeDefined(); + expect(SPEC_SUBPATHS[subpath]?.[schemaName] === bound, `${metadataType}: ${subpath}.${schemaName}`).toBe(true); + } + }); + + it('round-trips every annotated body through serialize and deserialize', () => { + for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { + expect(serializer.deserialize(serializeTypeScriptForMetadataType(item, metadataType)), metadataType).toEqual(item); + } + }); + + it('tsc: each spec type is identical to its schema\'s z.input, a valid body type-checks and an undeclared key is refused (TS2353)', () => { + const dir = join(HERE, '__annotation_type_check__'); + const files = new Map(); + for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { + const valid = serializeTypeScriptForMetadataType(item, metadataType); + files.set(join(dir, `${metadataType}.ts`), valid); + files.set(join(dir, `${metadataType}.undeclared-key.ts`), valid.replace('= {', '= {\n "undeclared_key": 1,')); + } + // Identity, not mutual assignability: `Book` is assignable both ways to + // `z.input` of `BookSchema` yet lacks two optional keys, so only identity + // refuses it. That pair is the control line, and it must be the one error. + const rows = [ + ...Object.entries(BOUND_SCHEMA).map(([metadataType, schemaName]) => { + const [typeName, subpath] = annotationOf(metadataType); + return { id: metadataType, typeName, schemaName, subpath }; + }), + { id: 'control_book', typeName: 'Book', schemaName: 'BookSchema', subpath: 'system' }, + ]; + const identity = [ + "import type { z } from 'zod';", + ...rows.map((r) => `import type { ${r.typeName} as T_${r.id}, ${r.schemaName} as S_${r.id} } from '@objectstack/spec/${r.subpath}';`), + 'type Identical = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false;', + ...rows.map((r) => `export const ${r.id}: Identical> = true;`), + '', + ].join('\n'); + const identityFile = join(dir, 'identity.ts'); + files.set(identityFile, identity); + const controlLine = identity.split('\n').findIndex((l) => l.startsWith('export const control_book:')); + const diagnostics = typeCheck(files); + const byFile = new Map(); + const unattributed: string[] = []; + for (const d of diagnostics) { + if (!d.file) { + unattributed.push(ts.flattenDiagnosticMessageText(d.messageText, '\n')); + continue; + } + byFile.set(d.file.fileName, [...(byFile.get(d.file.fileName) ?? []), d.code]); + } + const report = ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (f) => f, + getCurrentDirectory: () => dir, + getNewLine: () => '\n', + }); + expect(unattributed, report).toEqual([]); + expect([...byFile.keys()].filter((f) => !files.has(f)), report).toEqual([]); + for (const metadataType of Object.keys(REPRESENTATIVE)) { + expect(byFile.get(join(dir, `${metadataType}.ts`)) ?? [], report).toEqual([]); + expect(byFile.get(join(dir, `${metadataType}.undeclared-key.ts`)) ?? [], report).toEqual([2353]); + } + const identityErrors = diagnostics + .filter((d) => d.file?.fileName === identityFile) + .map((d) => [d.file!.getLineAndCharacterOfPosition(d.start ?? 0).line, d.code]); + expect(identityErrors, report).toEqual([[controlLine, 2322]]); + }, 60_000); +}); diff --git a/packages/metadata/src/serializers/typescript-serializer.ts b/packages/metadata/src/serializers/typescript-serializer.ts index 8d07ee2ae08..53538151b01 100644 --- a/packages/metadata/src/serializers/typescript-serializer.ts +++ b/packages/metadata/src/serializers/typescript-serializer.ts @@ -10,22 +10,119 @@ import type { z } from 'zod'; import type { MetadataFormat } from '@objectstack/spec/system'; import type { MetadataSerializer, SerializeOptions } from './serializer-interface.js'; +/** + * The spec type a `typescript`-format file's `metadata` constant is annotated + * with, per metadata type: `[type name, @objectstack/spec subpath]`. + * + * An emitted annotation must never be false. So a metadata type is listed only + * when the spec exports a type that IS the input type of the schema + * `getMetadataTypeSchema()` resolves for it (`z.input`, the + * ADR-0122 authoring name): the annotation is then that metadata type's own + * static input type, never another type's shape. (It is not a runtime verdict: + * a strip-mode schema drops an undeclared key that `tsc` refuses as TS2353.) + * A metadata type that is not + * listed (a plugin's own type, a misspelling, or one whose spec type does not + * state its schema) gets no annotation and no `import type`: never `any`, + * `unknown` or another type's shape. + * + * Two metadata types are deliberately absent: + * - `view`: `ViewMetadataSchema` is a `z.preprocess`, so its input type, and + * with it `ViewMetadata`, is `unknown`. Annotating with it would check + * nothing. + * - `book`: `Book` is written by hand and lacks the `_packageId` / + * `_provenance` protection keys `BookSchema` accepts, so a book the loader + * has stamped would fail against it. + * + * Only {@link serializeTypeScriptForMetadataType} reads this table; the public + * `TypeScriptSerializer.serialize()` never annotates. + * + * `typescript-serializer-annotation.test.ts` compiles every entry with `tsc`: + * the spec type must be identical to its bound schema's `z.input`, a valid + * body must type-check and an undeclared key must not, so a renamed, moved, + * drifted or widened-to-`unknown` spec type fails there instead of in a saved + * file. + */ +const ANNOTATION_BY_METADATA_TYPE: ReadonlyMap = new Map([ + ['object', ['ServiceObject', 'data']], + ['field', ['Field', 'data']], + ['hook', ['Hook', 'data']], + ['seed', ['Seed', 'data']], + ['mapping', ['Mapping', 'data']], + ['datasource', ['Datasource', 'data']], + ['analytics_cube', ['Cube', 'data']], + ['page', ['Page', 'ui']], + ['dashboard', ['Dashboard', 'ui']], + ['app', ['App', 'ui']], + ['action', ['Action', 'ui']], + ['report', ['Report', 'ui']], + ['dataset', ['Dataset', 'ui']], + ['flow', ['Flow', 'automation']], + ['webhook', ['Webhook', 'automation']], + ['job', ['Job', 'system']], + ['translation', ['TranslationItem', 'system']], + ['email_template', ['EmailTemplateDefinition', 'system']], + ['doc', ['Doc', 'system']], + ['api', ['ApiEndpoint', 'api']], + ['permission', ['PermissionSet', 'security']], + ['sharing_rule', ['SharingRule', 'security']], + ['capability', ['CapabilityDeclarationInput', 'security']], + ['position', ['Position', 'identity']], + ['agent', ['Agent', 'ai']], + ['tool', ['Tool', 'ai']], + ['skill', ['Skill', 'ai']], + ['connector', ['DeclarativeConnectorEntry', 'integration']], +]); + +/** The module text: `export const metadata = …; export default metadata;`, annotated when given one. */ +function renderModule( + item: unknown, + options: SerializeOptions | undefined, + annotation: readonly [typeName: string, subpath: string] | undefined, +): string { + const { prettify = true, indent = 2 } = options || {}; + + const jsonStr = JSON.stringify(item, null, prettify ? indent : 0); + + if (annotation) { + const [typeName, subpath] = annotation; + return `import type { ${typeName} } from '@objectstack/spec/${subpath}';\n\n` + + `export const metadata: ${typeName} = ${jsonStr};\n\n` + + `export default metadata;\n`; + } + return `export const metadata = ${jsonStr};\n\n` + + `export default metadata;\n`; +} + +/** + * PACKAGE-INTERNAL: the `typescript`-format file for an item of a known + * metadata type, annotated per {@link ANNOTATION_BY_METADATA_TYPE}. + * `FilesystemLoader.save()` calls it in place of the built-in `typescript` + * serializer's own, un-overridden `serialize()`, because only the loader knows + * the item's metadata type. + * + * ⛔ Not re-exported from any `exports` entry of `@objectstack/metadata`, and + * the public `SerializeOptions` does not carry the metadata type. Publishing + * either would widen the package's public surface for a caller that lives + * inside the package; `typescript-serializer-annotation.test.ts` pins that no + * entry exports it. + */ +export function serializeTypeScriptForMetadataType( + item: unknown, + metadataType: string, + options?: SerializeOptions, +): string { + return renderModule(item, options, ANNOTATION_BY_METADATA_TYPE.get(metadataType)); +} + export class TypeScriptSerializer implements MetadataSerializer { constructor(private format: 'typescript' | 'javascript' = 'typescript') {} + /** + * Writes no type annotation: this call does not know the item's metadata + * type, and a guessed annotation can be false. + */ serialize(item: T, options?: SerializeOptions): string { - const { prettify = true, indent = 2 } = options || {}; - - const jsonStr = JSON.stringify(item, null, prettify ? indent : 0); - - if (this.format === 'typescript') { - return `import type { ServiceObject } from '@objectstack/spec/data';\n\n` + - `export const metadata: ServiceObject = ${jsonStr};\n\n` + - `export default metadata;\n`; - } else { - return `export const metadata = ${jsonStr};\n\n` + - `export default metadata;\n`; - } + return renderModule(item, options, undefined); } deserialize(content: string, schema?: z.ZodSchema): T {