Skip to content

Commit b933283

Browse files
committed
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 <noreply@anthropic.com>
1 parent 79daf06 commit b933283

3 files changed

Lines changed: 235 additions & 7 deletions

File tree

‎packages/metadata/src/serializers/serializers.test.ts‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { describe, it, expect } from 'vitest';
2+
import fs from 'node:fs/promises';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { NodeMetadataManager } from '../node-metadata-manager.js';
26
import { JSONSerializer } from '../serializers/json-serializer.js';
37
import { YAMLSerializer } from '../serializers/yaml-serializer.js';
48
import { TypeScriptSerializer } from '../serializers/typescript-serializer.js';
@@ -55,14 +59,85 @@ describe('Serializers', () => {
5559
describe('TypeScriptSerializer', () => {
5660
const serializer = new TypeScriptSerializer('typescript');
5761

62+
// The saved-view repro: a view used to be annotated `ServiceObject`, which
63+
// has no top-level `type` or `columns` key, so `tsc` refused the file (TS2353).
64+
const view = { name: 'all_accounts', type: 'grid', object: 'account', columns: ['name'] };
65+
const object = { name: 'account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } };
66+
5867
it('should serialize to TypeScript module', () => {
5968
const data = { name: 'test', value: 42 };
6069
const result = serializer.serialize(data);
61-
expect(result).toContain('import type');
6270
expect(result).toContain('export const metadata');
6371
expect(result).toContain('export default metadata');
6472
});
6573

74+
it('writes a view with no annotation: no ServiceObject, no import type', () => {
75+
const result = serializer.serialize(view, { metadataType: 'view' });
76+
expect(result).not.toContain('ServiceObject');
77+
expect(result).toBe(
78+
`export const metadata = ${JSON.stringify(view, null, 2)};\n\nexport default metadata;\n`,
79+
);
80+
});
81+
82+
it('still annotates an object ServiceObject, byte-identical to the earlier output', () => {
83+
const result = serializer.serialize(object, { metadataType: 'object' });
84+
expect(result).toBe(
85+
`import type { ServiceObject } from '@objectstack/spec/data';\n\n` +
86+
`export const metadata: ServiceObject = ${JSON.stringify(object, null, 2)};\n\n` +
87+
`export default metadata;\n`,
88+
);
89+
});
90+
91+
it.each([
92+
['no metadata type', undefined],
93+
['a metadata type the spec has no type for', 'external_catalog'],
94+
['a metadata type whose spec type is narrower than its schema', 'book'],
95+
['a plugin-registered metadata type', 'acme_widget'],
96+
['a plural spelling', 'objects'],
97+
])('writes no annotation for %s', (_label, metadataType) => {
98+
const result = serializer.serialize({ name: 'x' }, { metadataType });
99+
expect(result.startsWith('export const metadata = {')).toBe(true);
100+
expect(result).not.toContain('import type');
101+
});
102+
103+
it('the javascript format never annotates, whatever the metadata type', () => {
104+
const js = new TypeScriptSerializer('javascript');
105+
expect(js.serialize(object, { metadataType: 'object' })).toBe(
106+
`export const metadata = ${JSON.stringify(object, null, 2)};\n\nexport default metadata;\n`,
107+
);
108+
});
109+
110+
it('reads back a file written before this fix: a view annotated ServiceObject', () => {
111+
const legacy =
112+
`import type { ServiceObject } from '@objectstack/spec/data';\n\n` +
113+
`export const metadata: ServiceObject = ${JSON.stringify(view, null, 2)};\n\n` +
114+
`export default metadata;\n`;
115+
expect(serializer.deserialize(legacy)).toEqual(view);
116+
});
117+
118+
it('round-trips a view and an object through serialize and deserialize', () => {
119+
for (const [metadataType, item] of [['view', view], ['object', object]] as const) {
120+
expect(serializer.deserialize(serializer.serialize(item, { metadataType }))).toEqual(item);
121+
}
122+
});
123+
124+
it('FilesystemLoader.save() passes the metadata type: the view file is unannotated, the object file is ServiceObject', async () => {
125+
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-'));
126+
try {
127+
const manager = new NodeMetadataManager({ rootDir, watch: false });
128+
await manager.save('view', 'all_accounts', view);
129+
await manager.save('object', 'account', object);
130+
const viewFile = await fs.readFile(path.join(rootDir, 'view', 'all_accounts.ts'), 'utf-8');
131+
const objectFile = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8');
132+
expect(viewFile).toBe(serializer.serialize(view, { metadataType: 'view' }));
133+
expect(viewFile).not.toContain('ServiceObject');
134+
expect(objectFile).toBe(serializer.serialize(object, { metadataType: 'object' }));
135+
expect(objectFile).toContain('export const metadata: ServiceObject = {');
136+
} finally {
137+
await fs.rm(rootDir, { recursive: true, force: true });
138+
}
139+
});
140+
66141
it('should get correct extension', () => {
67142
const ts = new TypeScriptSerializer('typescript');
68143
expect(ts.getExtension()).toBe('.ts');
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `TypeScriptSerializer` annotates a `typescript`-format file with the spec
5+
* type of the item's metadata type. This file holds that annotation to the one
6+
* property that makes it worth writing: it is never false.
7+
*
8+
* Every annotated metadata type is compiled here with `tsc`, twice:
9+
* - a spec-valid body must type-check with no diagnostic at all;
10+
* - the same body plus one undeclared key must fail with exactly TS2353. That
11+
* is what makes the annotation a check: a spec type that is `unknown` (as
12+
* `ViewMetadata` is) would pass the first file and the second.
13+
*
14+
* `@objectstack/spec` is resolved the way a consumer of a saved file resolves
15+
* it, through this package's `node_modules` and the `exports` map's `types`,
16+
* so this reads spec's BUILT `dist/*.d.ts`. `turbo test` builds it first
17+
* (`^build`); a bare `vitest run` needs `pnpm --filter @objectstack/spec build`.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { dirname, join } from 'node:path';
22+
import { fileURLToPath } from 'node:url';
23+
import ts from 'typescript';
24+
import { MetadataTypeSchema, getMetadataTypeSchema } from '@objectstack/spec/kernel';
25+
import { TypeScriptSerializer } from './typescript-serializer.js';
26+
27+
const HERE = dirname(fileURLToPath(import.meta.url));
28+
29+
/** One spec-valid body per metadata type the serializer annotates. */
30+
const REPRESENTATIVE: Record<string, Record<string, unknown>> = {
31+
object: { name: 'account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } },
32+
field: { name: 'title', type: 'text', label: 'Title' },
33+
hook: { name: 'account_audit', object: 'account', events: ['beforeInsert'], handler: 'audit_account' },
34+
seed: { object: 'account', records: [{ name: 'Acme' }] },
35+
mapping: { name: 'account_import', targetObject: 'account', fieldMapping: [] },
36+
datasource: { name: 'warehouse', driver: 'sqlite', config: {} },
37+
analytics_cube: { name: 'account_cube', sql: 'account', measures: {}, dimensions: {} },
38+
page: { name: 'account_home', label: 'Account Home', regions: [] },
39+
dashboard: { name: 'ops', label: 'Ops', widgets: [] },
40+
app: { name: 'sales', label: 'Sales' },
41+
action: { name: 'close_account', label: 'Close', type: 'script', target: 'close_account' },
42+
report: { name: 'hours_by_status', label: 'Hours by status', dataset: 'task_metrics', rows: ['status'], values: ['est_hours'] },
43+
dataset: { name: 'account_ds', label: 'Accounts', object: 'account', dimensions: [], measures: [] },
44+
flow: { name: 'notify_owner', label: 'Notify owner', type: 'autolaunched', nodes: [], edges: [] },
45+
webhook: { name: 'account_hook', object: 'account', triggers: ['create'], url: 'https://example.com/hook' },
46+
job: { name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 2 * * *' }, handler: 'sweep' },
47+
translation: { locale: 'en', objects: { account: { label: 'Account' } } },
48+
email_template: { name: 'welcome', label: 'Welcome', subject: 'Welcome', bodyHtml: 'Hello' },
49+
doc: { name: 'getting_started', label: 'Getting Started', content: '# Hello' },
50+
api: { name: 'ping', path: '/api/v1/apps/demo/ping', method: 'GET', type: 'object_operation', target: 'account' },
51+
permission: { name: 'sales_user', label: 'Sales user', objects: {} },
52+
sharing_rule: {
53+
name: 'share_accounts', object: 'account', type: 'criteria', condition: 'true',
54+
sharedWith: { type: 'team', value: 'sales' }, accessLevel: 'read',
55+
},
56+
capability: { name: 'manage_accounts', label: 'Manage accounts' },
57+
position: { name: 'sales_rep', label: 'Sales rep' },
58+
agent: { name: 'support_agent', label: 'Support', role: 'Support', instructions: 'Help users.', skills: ['case_management'] },
59+
tool: { name: 'list_records', label: 'List records', description: 'Lists records', parameters: {} },
60+
skill: { name: 'case_management', label: 'Case management', tools: [] },
61+
connector: { name: 'status_api', label: 'Status API', type: 'api', authentication: { type: 'none' } },
62+
};
63+
64+
/** Stack collections `getMetadataTypeSchema()` binds that are not `MetadataTypeSchema` members. */
65+
const NON_MEMBER_TYPES = ['webhook', 'connector', 'sharing_rule', 'analytics_cube'];
66+
67+
const serializer = new TypeScriptSerializer('typescript');
68+
const annotates = (metadataType: string): boolean =>
69+
serializer.serialize({ name: 'x' }, { metadataType }).startsWith('import type {');
70+
71+
/** Type-check in-memory files as if they sat in this directory. */
72+
function typeCheck(files: ReadonlyMap<string, string>): readonly ts.Diagnostic[] {
73+
const options: ts.CompilerOptions = {
74+
strict: true,
75+
noEmit: true,
76+
skipLibCheck: true,
77+
types: [],
78+
target: ts.ScriptTarget.ES2022,
79+
module: ts.ModuleKind.ESNext,
80+
moduleResolution: ts.ModuleResolutionKind.Bundler,
81+
};
82+
const host = ts.createCompilerHost(options, true);
83+
const { getSourceFile, fileExists, readFile } = host;
84+
host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => {
85+
const text = files.get(fileName);
86+
return text === undefined
87+
? getSourceFile.call(host, fileName, languageVersion, onError, shouldCreate)
88+
: ts.createSourceFile(fileName, text, languageVersion, true);
89+
};
90+
host.fileExists = (fileName) => files.has(fileName) || fileExists.call(host, fileName);
91+
host.readFile = (fileName) => files.get(fileName) ?? readFile.call(host, fileName);
92+
return ts.getPreEmitDiagnostics(ts.createProgram([...files.keys()], options, host));
93+
}
94+
95+
describe('TypeScriptSerializer annotation, per metadata type', () => {
96+
it('every representative body is valid for its metadata type', () => {
97+
for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) {
98+
const result = getMetadataTypeSchema(metadataType)?.safeParse(item);
99+
expect(result?.success, metadataType).toBe(true);
100+
}
101+
});
102+
103+
it('annotates exactly the representative metadata types, and never view, book or external_catalog', () => {
104+
const universe = new Set([...MetadataTypeSchema.options, ...NON_MEMBER_TYPES, ...Object.keys(REPRESENTATIVE)]);
105+
const annotated = [...universe].filter(annotates).sort();
106+
expect(annotated).toEqual(Object.keys(REPRESENTATIVE).sort());
107+
for (const metadataType of ['view', 'book', 'external_catalog']) {
108+
expect(annotates(metadataType), metadataType).toBe(false);
109+
}
110+
});
111+
112+
it('round-trips every annotated body through serialize and deserialize', () => {
113+
for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) {
114+
expect(serializer.deserialize(serializer.serialize(item, { metadataType })), metadataType).toEqual(item);
115+
}
116+
});
117+
118+
it('tsc: a valid body type-checks and an undeclared key is refused (TS2353), for every annotation', () => {
119+
const dir = join(HERE, '__annotation_type_check__');
120+
const files = new Map<string, string>();
121+
for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) {
122+
const valid = serializer.serialize(item, { metadataType });
123+
files.set(join(dir, `${metadataType}.ts`), valid);
124+
files.set(join(dir, `${metadataType}.undeclared-key.ts`), valid.replace('= {', '= {\n "undeclared_key": 1,'));
125+
}
126+
const diagnostics = typeCheck(files);
127+
const byFile = new Map<string, number[]>();
128+
const unattributed: string[] = [];
129+
for (const d of diagnostics) {
130+
if (!d.file) {
131+
unattributed.push(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
132+
continue;
133+
}
134+
byFile.set(d.file.fileName, [...(byFile.get(d.file.fileName) ?? []), d.code]);
135+
}
136+
const report = ts.formatDiagnostics(diagnostics, {
137+
getCanonicalFileName: (f) => f,
138+
getCurrentDirectory: () => dir,
139+
getNewLine: () => '\n',
140+
});
141+
expect(unattributed, report).toEqual([]);
142+
expect([...byFile.keys()].filter((f) => !files.has(f)), report).toEqual([]);
143+
for (const metadataType of Object.keys(REPRESENTATIVE)) {
144+
expect(byFile.get(join(dir, `${metadataType}.ts`)) ?? [], report).toEqual([]);
145+
expect(byFile.get(join(dir, `${metadataType}.undeclared-key.ts`)) ?? [], report).toEqual([2353]);
146+
}
147+
}, 60_000);
148+
});

‎packages/metadata/src/serializers/typescript-serializer.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,18 @@ import type { MetadataSerializer, SerializeOptions } from './serializer-interfac
2323
* annotation and no `import type`, never `any`, `unknown` or another type's
2424
* shape.
2525
*
26-
* `book` is deliberately absent: `Book` is written by hand and lacks the
27-
* `_packageId` / `_provenance` protection keys `BookSchema` accepts, so a book
28-
* the loader has stamped would fail against it.
26+
* Two metadata types are deliberately absent:
27+
* - `view`: `ViewMetadataSchema` is a `z.preprocess`, so its input type, and
28+
* with it `ViewMetadata`, is `unknown`. Annotating with it would check
29+
* nothing.
30+
* - `book`: `Book` is written by hand and lacks the `_packageId` /
31+
* `_provenance` protection keys `BookSchema` accepts, so a book the loader
32+
* has stamped would fail against it.
2933
*
30-
* Every entry is compiled with `tsc` in `serializers.test.ts`, so a renamed or
31-
* moved spec type fails there instead of in a saved file.
34+
* `typescript-serializer-annotation.test.ts` compiles every entry with `tsc`:
35+
* a valid body must type-check and an undeclared key must not, so a renamed,
36+
* moved or widened-to-`unknown` spec type fails there instead of in a saved
37+
* file.
3238
*/
3339
const ANNOTATION_BY_METADATA_TYPE: ReadonlyMap<string, readonly [typeName: string, subpath: string]> = new Map([
3440
['object', ['ServiceObject', 'data']],
@@ -38,7 +44,6 @@ const ANNOTATION_BY_METADATA_TYPE: ReadonlyMap<string, readonly [typeName: strin
3844
['mapping', ['Mapping', 'data']],
3945
['datasource', ['Datasource', 'data']],
4046
['analytics_cube', ['Cube', 'data']],
41-
['view', ['ViewMetadata', 'ui']],
4247
['page', ['Page', 'ui']],
4348
['dashboard', ['Dashboard', 'ui']],
4449
['app', ['App', 'ui']],

0 commit comments

Comments
 (0)