From 79daf069fe7b60e21e3f08b4c93b30d7b89fb3ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 12:39:48 +0000 Subject: [PATCH 1/6] fix(metadata): annotate a typescript-format item with its own metadata type's spec type TypeScriptSerializer annotated every item ServiceObject, so a saved view (or any non-object type) was a .ts file that failed tsc with TS2353. FilesystemLoader.save() now passes the metadata type in the existing SerializeOptions bag; a type with no spec type gets no annotation. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- .../metadata/src/loaders/filesystem-loader.ts | 3 + .../src/serializers/serializer-interface.ts | 8 +++ .../src/serializers/typescript-serializer.ts | 72 ++++++++++++++++--- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/packages/metadata/src/loaders/filesystem-loader.ts b/packages/metadata/src/loaders/filesystem-loader.ts index 138de82cec2..118a9e39ca4 100644 --- a/packages/metadata/src/loaders/filesystem-loader.ts +++ b/packages/metadata/src/loaders/filesystem-loader.ts @@ -464,6 +464,9 @@ export class FilesystemLoader implements MetadataLoader { prettify, indent, sortKeys, + // The `typescript` format annotates the file with this metadata type's + // spec type, or with nothing when the type has none. + metadataType: type, }); // Write to disk (atomic or direct) diff --git a/packages/metadata/src/serializers/serializer-interface.ts b/packages/metadata/src/serializers/serializer-interface.ts index aedf4d35cc0..099f12c4261 100644 --- a/packages/metadata/src/serializers/serializer-interface.ts +++ b/packages/metadata/src/serializers/serializer-interface.ts @@ -21,6 +21,14 @@ export interface SerializeOptions { sortKeys?: boolean; /** Include default values in output */ includeDefaults?: boolean; + /** + * The metadata type of the item being serialized (`'object'`, `'view'`, + * `'flow'`, …). Only the `typescript` format reads it: it picks the spec + * authoring type the emitted constant is annotated with, and an item with no + * type, or with a type that has no such spec type, is written with no + * annotation. `FilesystemLoader.save()` passes the type it was given. + */ + metadataType?: string; } /** diff --git a/packages/metadata/src/serializers/typescript-serializer.ts b/packages/metadata/src/serializers/typescript-serializer.ts index 8d07ee2ae08..e8fa3bb3157 100644 --- a/packages/metadata/src/serializers/typescript-serializer.ts +++ b/packages/metadata/src/serializers/typescript-serializer.ts @@ -10,22 +10,78 @@ 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): then any body that metadata type's contract accepts + * also type-checks. A metadata type that is not listed (a plugin's own type, + * a misspelling, or one whose spec type is narrower than its schema) gets no + * annotation and no `import type`, never `any`, `unknown` or another type's + * shape. + * + * `book` is deliberately absent: `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. + * + * Every entry is compiled with `tsc` in `serializers.test.ts`, so a renamed or + * moved 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']], + ['view', ['ViewMetadata', 'ui']], + ['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']], +]); + export class TypeScriptSerializer implements MetadataSerializer { constructor(private format: 'typescript' | 'javascript' = 'typescript') {} serialize(item: T, options?: SerializeOptions): string { - const { prettify = true, indent = 2 } = options || {}; + const { prettify = true, indent = 2, metadataType } = 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` + + + const annotation = + this.format === 'typescript' && metadataType !== undefined + ? ANNOTATION_BY_METADATA_TYPE.get(metadataType) + : undefined; + 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`; } deserialize(content: string, schema?: z.ZodSchema): T { From b93328367d9a979e15a67dec62c38d06145cdd52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 12:46:29 +0000 Subject: [PATCH 2/6] test(metadata): compile every TypeScriptSerializer annotation with tsc Drops view from the annotation table: ViewMetadata is the input type of a z.preprocess schema, so it is unknown and would check nothing. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- .../src/serializers/serializers.test.ts | 77 ++++++++- .../typescript-serializer-annotation.test.ts | 148 ++++++++++++++++++ .../src/serializers/typescript-serializer.ts | 17 +- 3 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 packages/metadata/src/serializers/typescript-serializer-annotation.test.ts diff --git a/packages/metadata/src/serializers/serializers.test.ts b/packages/metadata/src/serializers/serializers.test.ts index 3943092f542..3420585911a 100644 --- a/packages/metadata/src/serializers/serializers.test.ts +++ b/packages/metadata/src/serializers/serializers.test.ts @@ -1,4 +1,8 @@ import { describe, it, expect } 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'; @@ -55,14 +59,85 @@ 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' } } }; + 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('writes a view with no annotation: no ServiceObject, no import type', () => { + const result = serializer.serialize(view, { metadataType: 'view' }); + expect(result).not.toContain('ServiceObject'); + expect(result).toBe( + `export const metadata = ${JSON.stringify(view, null, 2)};\n\nexport default metadata;\n`, + ); + }); + + it('still annotates an object ServiceObject, byte-identical to the earlier output', () => { + const result = serializer.serialize(object, { metadataType: 'object' }); + expect(result).toBe( + `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.each([ + ['no metadata type', undefined], + ['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) => { + const result = serializer.serialize({ name: 'x' }, { metadataType }); + expect(result.startsWith('export const metadata = {')).toBe(true); + expect(result).not.toContain('import type'); + }); + + it('the javascript format never annotates, whatever the metadata type', () => { + const js = new TypeScriptSerializer('javascript'); + expect(js.serialize(object, { metadataType: 'object' })).toBe( + `export const metadata = ${JSON.stringify(object, null, 2)};\n\nexport default metadata;\n`, + ); + }); + + 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(serializer.serialize(item, { metadataType }))).toEqual(item); + } + }); + + it('FilesystemLoader.save() passes the 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).toBe(serializer.serialize(view, { metadataType: 'view' })); + expect(viewFile).not.toContain('ServiceObject'); + expect(objectFile).toBe(serializer.serialize(object, { metadataType: 'object' })); + expect(objectFile).toContain('export const metadata: ServiceObject = {'); + } finally { + await fs.rm(rootDir, { recursive: true, force: true }); + } + }); + 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..be4244c5b98 --- /dev/null +++ b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `TypeScriptSerializer` annotates a `typescript`-format file with the spec + * type of the item's metadata type. 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`, twice: + * - 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 { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { MetadataTypeSchema, getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { TypeScriptSerializer } 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' } }, +}; + +/** 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 => + serializer.serialize({ name: 'x' }, { metadataType }).startsWith('import type {'); + +/** 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('round-trips every annotated body through serialize and deserialize', () => { + for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { + expect(serializer.deserialize(serializer.serialize(item, { metadataType })), metadataType).toEqual(item); + } + }); + + it('tsc: a valid body type-checks and an undeclared key is refused (TS2353), for every annotation', () => { + const dir = join(HERE, '__annotation_type_check__'); + const files = new Map(); + for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { + const valid = serializer.serialize(item, { metadataType }); + files.set(join(dir, `${metadataType}.ts`), valid); + files.set(join(dir, `${metadataType}.undeclared-key.ts`), valid.replace('= {', '= {\n "undeclared_key": 1,')); + } + 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]); + } + }, 60_000); +}); diff --git a/packages/metadata/src/serializers/typescript-serializer.ts b/packages/metadata/src/serializers/typescript-serializer.ts index e8fa3bb3157..a47044eb74f 100644 --- a/packages/metadata/src/serializers/typescript-serializer.ts +++ b/packages/metadata/src/serializers/typescript-serializer.ts @@ -23,12 +23,18 @@ import type { MetadataSerializer, SerializeOptions } from './serializer-interfac * annotation and no `import type`, never `any`, `unknown` or another type's * shape. * - * `book` is deliberately absent: `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. + * 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. * - * Every entry is compiled with `tsc` in `serializers.test.ts`, so a renamed or - * moved spec type fails there instead of in a saved file. + * `typescript-serializer-annotation.test.ts` compiles every entry with `tsc`: + * a valid body must type-check and an undeclared key must not, so a renamed, + * moved 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']], @@ -38,7 +44,6 @@ const ANNOTATION_BY_METADATA_TYPE: ReadonlyMap Date: Wed, 23 Sep 2026 12:49:27 +0000 Subject: [PATCH 3/6] docs(metadata): README and changeset say what TypeScriptSerializer annotates per metadata type Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- ...52-typescript-serializer-per-type-annotation.md | 14 ++++++++++++++ packages/metadata/README.md | 2 +- .../src/serializers/typescript-serializer.ts | 10 +++++----- 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 .changeset/19852-typescript-serializer-per-type-annotation.md 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..0afc808a5b5 --- /dev/null +++ b/.changeset/19852-typescript-serializer-per-type-annotation.md @@ -0,0 +1,14 @@ +--- +"@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 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 metadata types in all. Such a file now fails `tsc` only where its content is not a valid item of its own type. +- `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. +- If you call `TypeScriptSerializer.serialize()` yourself, pass the item's metadata type as the new optional `SerializeOptions.metadataType` to get its annotation. Without it the file carries no annotation (it used to carry `ServiceObject`). + +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..38beb774170 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;`. The `typescript` format also annotates that constant with the spec type of the item's metadata type, which `FilesystemLoader.save()` passes as `SerializeOptions.metadataType`, and imports it: `ServiceObject` from `@objectstack/spec/data` for an `object`, `Flow` from `@objectstack/spec/automation` for a `flow`, and so on, each 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`, a plugin's own type, and any `serialize()` call that passes no `metadataType`. 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/serializers/typescript-serializer.ts b/packages/metadata/src/serializers/typescript-serializer.ts index a47044eb74f..4a605dcbdc2 100644 --- a/packages/metadata/src/serializers/typescript-serializer.ts +++ b/packages/metadata/src/serializers/typescript-serializer.ts @@ -17,11 +17,11 @@ import type { MetadataSerializer, SerializeOptions } from './serializer-interfac * 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): then any body that metadata type's contract accepts - * also type-checks. A metadata type that is not listed (a plugin's own type, - * a misspelling, or one whose spec type is narrower than its schema) gets no - * annotation and no `import type`, never `any`, `unknown` or another type's - * shape. + * ADR-0122 authoring name): the annotation then states that type's own + * contract, nothing narrower and nothing else. 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 From 96e53e94d8d87d90f56c7a965f9414a9d0b48f50 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:22:18 +0000 Subject: [PATCH 4/6] fix(metadata): route the metadata type to the annotation through a package-internal function SerializeOptions is back to its published shape: metadataType on it would widen @objectstack/metadata's public surface for a caller that lives in the package. FilesystemLoader.save() now calls the module-internal serializeTypeScriptForMetadataType for the built-in typescript serializer; the public TypeScriptSerializer.serialize() writes no annotation. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- .../metadata/src/loaders/filesystem-loader.ts | 21 ++--- .../src/serializers/serializer-interface.ts | 8 -- .../src/serializers/serializers.test.ts | 76 +++++++++++++------ .../typescript-serializer-annotation.test.ts | 39 ++++++++-- .../src/serializers/typescript-serializer.ts | 64 ++++++++++++---- 5 files changed, 142 insertions(+), 66 deletions(-) diff --git a/packages/metadata/src/loaders/filesystem-loader.ts b/packages/metadata/src/loaders/filesystem-loader.ts index 118a9e39ca4..3b8f0a53f2a 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,15 +460,17 @@ export class FilesystemLoader implements MetadataLoader { } } - // Serialize data - const content = serializer.serialize(data, { - prettify, - indent, - sortKeys, - // The `typescript` format annotates the file with this metadata type's - // spec type, or with nothing when the type has none. - metadataType: type, - }); + // 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. Any other serializer, including + // a custom one registered for `typescript`, is called as it always was. + const serializeOptions = { prettify, indent, sortKeys }; + const content = + serializer instanceof TypeScriptSerializer && 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/serializer-interface.ts b/packages/metadata/src/serializers/serializer-interface.ts index 099f12c4261..aedf4d35cc0 100644 --- a/packages/metadata/src/serializers/serializer-interface.ts +++ b/packages/metadata/src/serializers/serializer-interface.ts @@ -21,14 +21,6 @@ export interface SerializeOptions { sortKeys?: boolean; /** Include default values in output */ includeDefaults?: boolean; - /** - * The metadata type of the item being serialized (`'object'`, `'view'`, - * `'flow'`, …). Only the `typescript` format reads it: it picks the spec - * authoring type the emitted constant is annotated with, and an item with no - * type, or with a type that has no such spec type, is written with no - * annotation. `FilesystemLoader.save()` passes the type it was given. - */ - metadataType?: string; } /** diff --git a/packages/metadata/src/serializers/serializers.test.ts b/packages/metadata/src/serializers/serializers.test.ts index 3420585911a..68195a2747f 100644 --- a/packages/metadata/src/serializers/serializers.test.ts +++ b/packages/metadata/src/serializers/serializers.test.ts @@ -5,7 +5,10 @@ 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 } from '../serializers/serializer-interface.js'; +import { FilesystemLoader } from '../loaders/filesystem-loader.js'; describe('Serializers', () => { describe('JSONSerializer', () => { @@ -63,6 +66,13 @@ describe('Serializers', () => { // 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 }; @@ -71,40 +81,32 @@ describe('Serializers', () => { 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 = serializer.serialize(view, { metadataType: 'view' }); + const result = serializeTypeScriptForMetadataType(view, 'view'); expect(result).not.toContain('ServiceObject'); - expect(result).toBe( - `export const metadata = ${JSON.stringify(view, null, 2)};\n\nexport default metadata;\n`, - ); + expect(result).toBe(plain(view)); }); it('still annotates an object ServiceObject, byte-identical to the earlier output', () => { - const result = serializer.serialize(object, { metadataType: 'object' }); - expect(result).toBe( - `import type { ServiceObject } from '@objectstack/spec/data';\n\n` + - `export const metadata: ServiceObject = ${JSON.stringify(object, null, 2)};\n\n` + - `export default metadata;\n`, - ); + expect(serializeTypeScriptForMetadataType(object, 'object')).toBe(annotatedObject); }); it.each([ - ['no metadata type', undefined], ['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) => { - const result = serializer.serialize({ name: 'x' }, { metadataType }); - expect(result.startsWith('export const metadata = {')).toBe(true); - expect(result).not.toContain('import type'); + expect(serializeTypeScriptForMetadataType({ name: 'x' }, metadataType)).toBe(plain({ name: 'x' })); }); - it('the javascript format never annotates, whatever the metadata type', () => { - const js = new TypeScriptSerializer('javascript'); - expect(js.serialize(object, { metadataType: 'object' })).toBe( - `export const metadata = ${JSON.stringify(object, null, 2)};\n\nexport default metadata;\n`, - ); + 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', () => { @@ -117,11 +119,12 @@ describe('Serializers', () => { 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(serializer.serialize(item, { metadataType }))).toEqual(item); + expect(serializer.deserialize(serializeTypeScriptForMetadataType(item, metadataType))).toEqual(item); + expect(serializer.deserialize(serializer.serialize(item))).toEqual(item); } }); - it('FilesystemLoader.save() passes the metadata type: the view file is unannotated, the object file is ServiceObject', async () => { + 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 }); @@ -129,10 +132,33 @@ describe('Serializers', () => { 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).toBe(serializer.serialize(view, { metadataType: 'view' })); expect(viewFile).not.toContain('ServiceObject'); - expect(objectFile).toBe(serializer.serialize(object, { metadataType: 'object' })); - expect(objectFile).toContain('export const metadata: 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 }); } diff --git a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts index be4244c5b98..c86b83dddfd 100644 --- a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts +++ b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts @@ -1,9 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `TypeScriptSerializer` annotates a `typescript`-format file with the spec - * type of the item's metadata type. This file holds that annotation to the one - * property that makes it worth writing: it is never false. + * `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`, twice: * - a spec-valid body must type-check with no diagnostic at all; @@ -18,11 +19,12 @@ */ import { describe, it, expect } from 'vitest'; -import { dirname, join } from 'node:path'; +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 { TypeScriptSerializer } from './typescript-serializer.js'; +import { TypeScriptSerializer, serializeTypeScriptForMetadataType } from './typescript-serializer.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -66,7 +68,13 @@ const NON_MEMBER_TYPES = ['webhook', 'connector', 'sharing_rule', 'analytics_cub const serializer = new TypeScriptSerializer('typescript'); const annotates = (metadataType: string): boolean => - serializer.serialize({ name: 'x' }, { metadataType }).startsWith('import type {'); + 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[] { @@ -109,9 +117,24 @@ describe('TypeScriptSerializer annotation, per metadata type', () => { } }); + 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 () => { + 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'); + } + }); + it('round-trips every annotated body through serialize and deserialize', () => { for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { - expect(serializer.deserialize(serializer.serialize(item, { metadataType })), metadataType).toEqual(item); + expect(serializer.deserialize(serializeTypeScriptForMetadataType(item, metadataType)), metadataType).toEqual(item); } }); @@ -119,7 +142,7 @@ describe('TypeScriptSerializer annotation, per metadata type', () => { const dir = join(HERE, '__annotation_type_check__'); const files = new Map(); for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { - const valid = serializer.serialize(item, { metadataType }); + 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,')); } diff --git a/packages/metadata/src/serializers/typescript-serializer.ts b/packages/metadata/src/serializers/typescript-serializer.ts index 4a605dcbdc2..98003b1c6f2 100644 --- a/packages/metadata/src/serializers/typescript-serializer.ts +++ b/packages/metadata/src/serializers/typescript-serializer.ts @@ -31,6 +31,9 @@ import type { MetadataSerializer, SerializeOptions } from './serializer-interfac * `_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`: * a valid body must type-check and an undeclared key must not, so a renamed, * moved or widened-to-`unknown` spec type fails there instead of in a saved @@ -67,26 +70,55 @@ const ANNOTATION_BY_METADATA_TYPE: ReadonlyMap(item: T, options?: SerializeOptions): string { - const { prettify = true, indent = 2, metadataType } = options || {}; - - const jsonStr = JSON.stringify(item, null, prettify ? indent : 0); - - const annotation = - this.format === 'typescript' && metadataType !== undefined - ? ANNOTATION_BY_METADATA_TYPE.get(metadataType) - : undefined; - 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`; + return renderModule(item, options, undefined); } deserialize(content: string, schema?: z.ZodSchema): T { From 3ac5f8100399c21cfc79a7fcdf688db3866b48a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:23:43 +0000 Subject: [PATCH 5/6] docs(metadata): README and changeset describe the package-internal annotation path Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- .changeset/19852-typescript-serializer-per-type-annotation.md | 4 ++-- packages/metadata/README.md | 2 +- .../src/serializers/typescript-serializer-annotation.test.ts | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/19852-typescript-serializer-per-type-annotation.md b/.changeset/19852-typescript-serializer-per-type-annotation.md index 0afc808a5b5..62ce02bfd3d 100644 --- a/.changeset/19852-typescript-serializer-per-type-annotation.md +++ b/.changeset/19852-typescript-serializer-per-type-annotation.md @@ -7,8 +7,8 @@ If you type-check the `.ts` files that `MetadataManager.save()` / `FilesystemLoader.save()` write: - An `object` file 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 metadata types in all. Such a file now fails `tsc` only where its content is not a valid item of its own type. +- 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. Such a file now fails `tsc` only where its content is not a valid item of its own type. - `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. -- If you call `TypeScriptSerializer.serialize()` yourself, pass the item's metadata type as the new optional `SerializeOptions.metadataType` to get its annotation. Without it the file carries no annotation (it used to carry `ServiceObject`). +- 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 38beb774170..5b195ce9409 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 annotates that constant with the spec type of the item's metadata type, which `FilesystemLoader.save()` passes as `SerializeOptions.metadataType`, and imports it: `ServiceObject` from `@objectstack/spec/data` for an `object`, `Flow` from `@objectstack/spec/automation` for a `flow`, and so on, each 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`, a plugin's own type, and any `serialize()` call that passes no `metadataType`. 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. +- **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 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. 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/serializers/typescript-serializer-annotation.test.ts b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts index c86b83dddfd..dec4d3bfc9d 100644 --- a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts +++ b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts @@ -124,13 +124,15 @@ describe('TypeScriptSerializer annotation, per metadata type', () => { }); 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('round-trips every annotated body through serialize and deserialize', () => { for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { From ee7faa27ca5395f2ce9ed5129c4b1514bfb8c7d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 14:14:51 +0000 Subject: [PATCH 6/6] fix(metadata): route to the annotated path only when the built-in serialize() would run The instanceof predicate also matched a subclass that overrides serialize(), so FilesystemLoader.save() bypassed the override. The test is now the method itself (TypeScriptSerializer.prototype.serialize), which a subclass override and a class copy from the other entry bundle both fail. Also pins each table row's spec type as identical to z.input of its bound schema inside the in-test tsc, and corrects the changeset and README sentences the contract review flagged. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- ...pescript-serializer-per-type-annotation.md | 5 +- packages/metadata/README.md | 2 +- .../metadata/src/loaders/filesystem-loader.ts | 14 +++- .../src/serializers/serializers.test.ts | 39 ++++++++- .../typescript-serializer-annotation.test.ts | 79 ++++++++++++++++++- .../src/serializers/typescript-serializer.ts | 16 ++-- 6 files changed, 139 insertions(+), 16 deletions(-) diff --git a/.changeset/19852-typescript-serializer-per-type-annotation.md b/.changeset/19852-typescript-serializer-per-type-annotation.md index 62ce02bfd3d..830b96e6fc9 100644 --- a/.changeset/19852-typescript-serializer-per-type-annotation.md +++ b/.changeset/19852-typescript-serializer-per-type-annotation.md @@ -6,9 +6,10 @@ If you type-check the `.ts` files that `MetadataManager.save()` / `FilesystemLoader.save()` write: -- An `object` file 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. Such a file now fails `tsc` only where its content is not a valid item of its own type. +- 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 5b195ce9409..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;`. A `typescript`-format file that `FilesystemLoader.save()` writes 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. 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. +- **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 3b8f0a53f2a..b2313f272f2 100644 --- a/packages/metadata/src/loaders/filesystem-loader.ts +++ b/packages/metadata/src/loaders/filesystem-loader.ts @@ -464,11 +464,19 @@ export class FilesystemLoader implements MetadataLoader { // 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. Any other serializer, including - // a custom one registered for `typescript`, is called as it always was. + // `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 instanceof TypeScriptSerializer && serializer.getFormat() === 'typescript' + serializer.serialize === TypeScriptSerializer.prototype.serialize && + serializer.getFormat() === 'typescript' ? serializeTypeScriptForMetadataType(data, type, serializeOptions) : serializer.serialize(data, serializeOptions); diff --git a/packages/metadata/src/serializers/serializers.test.ts b/packages/metadata/src/serializers/serializers.test.ts index 68195a2747f..e5106489b31 100644 --- a/packages/metadata/src/serializers/serializers.test.ts +++ b/packages/metadata/src/serializers/serializers.test.ts @@ -1,4 +1,4 @@ -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'; @@ -7,7 +7,7 @@ import { JSONSerializer } from '../serializers/json-serializer.js'; import { YAMLSerializer } from '../serializers/yaml-serializer.js'; import { TypeScriptSerializer, serializeTypeScriptForMetadataType } from '../serializers/typescript-serializer.js'; import type { MetadataFormat } from '@objectstack/spec/system'; -import type { MetadataSerializer } from '../serializers/serializer-interface.js'; +import type { MetadataSerializer, SerializeOptions } from '../serializers/serializer-interface.js'; import { FilesystemLoader } from '../loaders/filesystem-loader.js'; describe('Serializers', () => { @@ -164,6 +164,41 @@ describe('Serializers', () => { } }); + // 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 index dec4d3bfc9d..01a3067f0a1 100644 --- a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts +++ b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts @@ -6,7 +6,9 @@ * `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`, twice: + * 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 @@ -24,6 +26,15 @@ 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)); @@ -63,6 +74,36 @@ const REPRESENTATIVE: Record> = { 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']; @@ -134,13 +175,23 @@ describe('TypeScriptSerializer annotation, per metadata type', () => { } }, 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: a valid body type-checks and an undeclared key is refused (TS2353), for every annotation', () => { + 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)) { @@ -148,6 +199,26 @@ describe('TypeScriptSerializer annotation, per metadata type', () => { 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[] = []; @@ -169,5 +240,9 @@ describe('TypeScriptSerializer annotation, per metadata type', () => { 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 98003b1c6f2..53538151b01 100644 --- a/packages/metadata/src/serializers/typescript-serializer.ts +++ b/packages/metadata/src/serializers/typescript-serializer.ts @@ -17,8 +17,10 @@ import type { MetadataSerializer, SerializeOptions } from './serializer-interfac * 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 then states that type's own - * contract, nothing narrower and nothing else. A metadata type that is not + * 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. @@ -35,8 +37,9 @@ import type { MetadataSerializer, SerializeOptions } from './serializer-interfac * `TypeScriptSerializer.serialize()` never annotates. * * `typescript-serializer-annotation.test.ts` compiles every entry with `tsc`: - * a valid body must type-check and an undeclared key must not, so a renamed, - * moved or widened-to-`unknown` spec type fails there instead of in a saved + * 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([ @@ -93,8 +96,9 @@ function renderModule( /** * 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 for the built-in `typescript` serializer, - * because only the loader knows the item's 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