Skip to content

Commit e9eb224

Browse files
fix(metadata): TypeScriptSerializer annotates each item with its own metadata type's spec type, never ServiceObject for a non-object (#19865)
Fixes #19852 Clause-②: no ## What changed `TypeScriptSerializer.serialize()` annotated every `typescript`-format item `ServiceObject`, so a saved view (or any other non-object item) was a `.ts` file that `tsc` refused with TS2353. Files that `FilesystemLoader.save()` writes with the built-in serializer the package wires in are now annotated with the spec type of the item's own metadata type, or not annotated at all. They never carry `any`, `unknown` or another type's shape. - **How the metadata type reaches the annotation (A1, per the seat's ruling on this PR).** It goes through a package-internal channel, and the public surface does not change. - `FilesystemLoader.save()` calls `serializeTypeScriptForMetadataType(item, type, options)` only when the serializer's `serialize` is this package's own, un-overridden `TypeScriptSerializer.prototype.serialize` and its format is `typescript`. - Every other serializer is called through its own `serialize()` exactly as before: a custom one, a subclass that overrides `serialize()`, or a `TypeScriptSerializer` class copy from the package's other entry bundle. - The predicate tests the method, not the class. The earlier `instanceof` also matched an overriding subclass and bypassed its override, which the contract review measured. - The function is module-level in `serializers/typescript-serializer.ts`, and no `exports` entry re-exports it (`.`, `./node`, `./migrations`, `./errors`, `./view-container`). A test pins that. - `SerializeOptions` and `serializer-interface.ts` are byte-identical to the base. - The public `TypeScriptSerializer.serialize(item, options)` keeps its exact signature and writes **no** annotation. It cannot know the item's metadata type, and the `ServiceObject` it used to write was false for every non-object. This output change is the correction, not a new surface. - **Which type (A2).** No existing table maps a metadata type to a type name: - `getMetadataTypeSchema()` maps a metadata type to a Zod schema **value**. - The `define*` helpers are value factories, so emitting one would turn the annotation into a runtime parse. - The CLI's `generate` templates are per-type code templates, not a lookup. So the table is private: `ANNOTATION_BY_METADATA_TYPE`, 28 rows. A row exists only when the spec exports a type **identical** to the `z.input` type of the schema `getMetadataTypeSchema()` binds for that metadata type. That rule is now pinned in the in-test `tsc`: - One strict type-identity assertion per row, with a `Book` / `BookSchema` control line that must be the one error. `Book` is assignable both ways to `z.input` of `BookSchema`, so mutual assignability alone would not refuse it; identity is stronger. - A run-time pin that each row's schema export on the annotation's subpath **is** the object `getMetadataTypeSchema()` returns. - **Deliberately unannotated.** - `view`: `ViewMetadataSchema` is a `z.preprocess`, so `ViewMetadata` is `unknown`. It type-checks anything, including the card's own repro body, which `ViewMetadataSchema.safeParse` rejects. - `book`: the hand-written `Book` lacks the `_packageId` / `_provenance` keys `BookSchema` accepts. A stamped book fails `tsc` against it (TS2353) even though it parses. - `external_catalog`, a plugin's own type, and a plural spelling also get no annotation. - **The `object` file written by the built-in serializer the package wires in is byte-identical to the base (A4).** An exact-string test pins it. - **`deserialize` is unchanged.** A legacy file (a view annotated `ServiceObject`) still reads back, pinned. - **The annotation is a static claim, not a runtime verdict.** `tsc` now checks each annotated file against its own type instead of `ServiceObject`. It is stricter than a strip-mode schema at run time: `DeclarativeConnectorEntrySchema` drops an undeclared key that `tsc` refuses as TS2353. Among the 28 representative bodies with one undeclared key added, only `connector` parses at run time (the key is dropped). ## Premise: no declaration reachable from an `exports` entry changes I built `@objectstack/metadata` on the base and on the head, with the deps from `turbo run build --filter='@objectstack/metadata^...'` and then `pnpm --filter @objectstack/metadata build`. I copied every emitted `*.d.ts` / `*.d.cts` under `dist/` (the same 10-file set on both sides: `index`, `node`, `errors`, `view-container` and `migrations/index`, ESM and CJS) and ran `diff -r`: - merge base `2bbb462335` vs head `ee7faa27ca`: 8/10 files byte-identical, and 10/10 identical once comments are stripped. The only difference is one added JSDoc block, 4 lines, on `TypeScriptSerializer.serialize` in `index.d.ts` and `index.d.cts`. - These are the same hunks as at `8b51f96280`. The predicate change is implementation only. - A control holds: an injected `metadataType?: string;` in `SerializeOptions` is detected as a difference. ## Routing, measured on the built bundles At `ee7faa27ca`, with `FilesystemLoader` from `dist/node.js` saving an `object`, the first line of each file is: - built-in `TypeScriptSerializer` from `./node`: `import type { ServiceObject } …` - a `TypeScriptSerializer` from `.` (a distinct class copy; `splitting: false`): `export const metadata = {` (no annotation, never a false one) - a subclass overriding `serialize()` to prepend `// header`: `// header` - a plain-object serializer: its own output - `NodeMetadataManager`: `object` is annotated `ServiceObject`, and `view` is unannotated ## A3: annotation and `tsc` exit per metadata type - **Setup:** `tsc --noEmit --strict` (typescript 6.0.3), with `@objectstack/spec/*` mapped to the built declarations. Head `8b51f96280` includes `main`'s zod 4.6.1. - **Bodies:** each body went through the loader's path, `serializeTypeScriptForMetadataType(item, type)`, which `ee7faa27ca` leaves unchanged. - "showcase" means every `examples/app-showcase` item of that type, both raw and after `getMetadataTypeSchema(type).safeParse`. - "minimal" means one spec-valid body plus its parsed form. - **Card repro:** the `view` row includes it (`all_accounts`). | metadata type | annotation | files | tsc exit | |:--|:--|:--|:--| | `action` | Action (@objectstack/spec/ui) | 140 (showcase) | 0 | | `agent` | Agent (@objectstack/spec/ai) | 2 (minimal) | 0 | | `analytics_cube` | Cube (@objectstack/spec/data) | 2 (showcase) | 0 | | `api` | ApiEndpoint (@objectstack/spec/api) | 4 (showcase) | 0 | | `app` | App (@objectstack/spec/ui) | 2 (showcase) | 0 | | `book` | none | 2 (showcase) | 0 | | `capability` | CapabilityDeclarationInput (@objectstack/spec/security) | 4 (showcase) | 0 | | `connector` | DeclarativeConnectorEntry (@objectstack/spec/integration) | 8 (showcase) | 0 | | `dashboard` | Dashboard (@objectstack/spec/ui) | 6 (showcase) | 0 | | `dataset` | Dataset (@objectstack/spec/ui) | 8 (showcase) | 0 | | `datasource` | Datasource (@objectstack/spec/data) | 2 (showcase) | 0 | | `doc` | Doc (@objectstack/spec/system) | 2 (minimal) | 0 | | `email_template` | EmailTemplateDefinition (@objectstack/spec/system) | 2 (showcase) | 0 | | `field` | Field (@objectstack/spec/data) | 2 (minimal) | 0 | | `flow` | Flow (@objectstack/spec/automation) | 60 (showcase) | 0 | | `hook` | Hook (@objectstack/spec/data) | 8 (showcase) | 0 | | `job` | Job (@objectstack/spec/system) | 2 (showcase) | 0 | | `mapping` | Mapping (@objectstack/spec/data) | 2 (showcase) | 0 | | `object` | ServiceObject (@objectstack/spec/data) | 48 (showcase) | 0 | | `page` | Page (@objectstack/spec/ui) | 56 (showcase) | 0 | | `permission` | PermissionSet (@objectstack/spec/security) | 18 (showcase) | 0 | | `position` | Position (@objectstack/spec/identity) | 20 (showcase) | 0 | | `report` | Report (@objectstack/spec/ui) | 8 (showcase) | 0 | | `seed` | Seed (@objectstack/spec/data) | 38 (showcase) | 0 | | `sharing_rule` | SharingRule (@objectstack/spec/security) | 4 (showcase) | 0 | | `skill` | Skill (@objectstack/spec/ai) | 2 (minimal) | 0 | | `tool` | Tool (@objectstack/spec/ai) | 2 (minimal) | 0 | | `translation` | TranslationItem (@objectstack/spec/system) | 2 (minimal) | 0 | | `view` | none | 13 (showcase) | 0 | | `webhook` | Webhook (@objectstack/spec/automation) | 2 (showcase) | 0 | - **Before the fix, at base `2548ba57de`:** the card's repro through `NodeMetadataManager.save('view', …)` makes `tsc` exit 2 with TS2353. `object/account.ts` is clean. - **Negative control, every annotation (in-test):** the same valid body plus one undeclared key fails with exactly TS2353 for all 28 annotated types. ## Tests - `serializers.test.ts` covers these cases: - The public `serialize()` writes no annotation, an object included. - The loader path writes a `view` with no `ServiceObject` and no `import type`. - The `object` output is byte-identical to the base. - No annotation for `external_catalog` / `book` / plugin / plural types. - The `javascript` format never annotates. - The legacy file still reads back. - Round trips. - `NodeMetadataManager.save()` end to end. - A `FilesystemLoader` wired by hand with each of these: - a plain-object custom serializer, called with `{ prettify, indent, sortKeys }` - **a subclass overriding `serialize()`**, called: its `// header` survives - the built-in serializer, and a subclass that keeps the inherited `serialize()`: both annotated - **a `TypeScriptSerializer` from a second module copy**, obtained with `vi.resetModules()`, with the control that it is a distinct class: called through its own `serialize()`, so no annotation - `typescript-serializer-annotation.test.ts` covers these cases: - Each of the 28 representative bodies is spec-valid. - The annotated set is exactly those 28. - The public `serialize()` annotates none of them. - No `exports` entry re-exports the internal function, with a spelling control. - **Run-time binding pin:** each row's schema export on the annotation's subpath `===` `getMetadataTypeSchema(type)`. - Round trips. - **In-test `tsc`:** per-row strict type identity (spec type vs `z.input` of the bound schema), with exactly one error allowed, TS2322 on the `Book` control line. Every valid body type-checks clean, and every undeclared-key body fails with exactly `[2353]`. - **Package suite, at `ee7faa27ca`:** `pnpm --filter @objectstack/metadata exec vitest run --maxWorkers=2` gives **54 files / 814 tests passed**, and `pnpm --filter @objectstack/metadata typecheck` exits 0. - **Ablation, routing predicate, at `ee7faa27ca`.** Through `scripts/ablation-replace.mjs`, `serializer.serialize === TypeScriptSerializer.prototype.serialize &&` was changed back to `serializer instanceof TypeScriptSerializer &&` (anchor x1 -> x0, blob `b2313f272f2e` -> `9c909b67b590`). - Result: **1 failed / 29 passed**. The failure is `FilesystemLoader.save() calls a subclass that overrides serialize(), as it always did`, received `import type { ServiceObject } …` instead of `// header …`. - Restored: blob == HEAD `b2313f272f2e`, and `git diff HEAD` is empty. - **Earlier ablation, annotation choice, at `3ac5f81003`.** `serializeTypeScriptForMetadataType` was forced to an unconditional `['ServiceObject', 'data']`: **8 failed / 18 passed**, including the loader-wired view test and the in-test `tsc`. Restored to HEAD. ## Gates - **Derived at `8b51f96280`:** `node scripts/pm/dispatch-gates.mjs --commands` gave 59 commands, all exit 0, and `--ran` reported 59 run, 0 NOT-MEASURED. `ee7faa27ca` touches the same 6 paths. - **Re-run at `ee7faa27ca`:** - metadata tests, exit 0 - metadata typecheck, exit 0 - `node scripts/check-issue-citations.mjs`, exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main`, exit 0 - `pnpm check:nul-bytes`, exit 0 ## Acceptance notes - **`main` merged** at `2bbb462335` as a fast-forward merge commit, with no rebase and no force. It brings zod 4.6.1. The type-identity and `unknown` checks and the A3 table were re-read after it. - **`Clause-②: no`** stands, per the seat's ruling. No exported declaration changes (see Premise). - **Findings handed to the seat, not fixed here:** - `ViewMetadata` is `unknown` (`z.preprocess`). - The `typescript` format ignores the declared `sortKeys` save option. - `DeclarativeConnectorEntrySchema` strips an undeclared key. Written by session `session_01TEhopqrWQYBycZzyJHpAZr`, the dev for PM seat `domain:engine#1`, round 21, patch round 2. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04a160d commit e9eb224

6 files changed

Lines changed: 532 additions & 22 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
`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 …`).
6+
7+
If you type-check the `.ts` files that `MetadataManager.save()` / `FilesystemLoader.save()` write:
8+
9+
- 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 = …`.
10+
- 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`.
11+
- `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.
12+
- 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.
13+
- 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.
14+
15+
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.

‎packages/metadata/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Serializers convert metadata objects to/from different file formats:
7474

7575
- **JSONSerializer** — `.json` files with optional key sorting
7676
- **YAMLSerializer** — `.yaml`/`.yml` files (JSON_SCHEMA for security)
77-
- **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.
77+
- **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.
7878

7979
### 4. Overlay / Customization System
8080

‎packages/metadata/src/loaders/filesystem-loader.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
import type { Logger } from '@objectstack/core';
2323
import type { MetadataLoader, MetadataKeyedItem } from './loader-interface.js';
2424
import type { MetadataSerializer } from '../serializers/serializer-interface.js';
25+
import { TypeScriptSerializer, serializeTypeScriptForMetadataType } from '../serializers/typescript-serializer.js';
2526
import { AmbiguousMetadataStemError } from './ambiguous-metadata-stem.js';
2627

2728
/**
@@ -459,12 +460,25 @@ export class FilesystemLoader implements MetadataLoader {
459460
}
460461
}
461462

462-
// Serialize data
463-
const content = serializer.serialize(data, {
464-
prettify,
465-
indent,
466-
sortKeys,
467-
});
463+
// Serialize data. The built-in `typescript` serializer is the one format
464+
// that annotates, and the annotation depends on the metadata type, which
465+
// only this call knows: it goes through the package-internal
466+
// `serializeTypeScriptForMetadataType`, so the published
467+
// `SerializeOptions` does not grow a key.
468+
//
469+
// The test is the METHOD, not the class: only when this module's own,
470+
// un-overridden `TypeScriptSerializer.prototype.serialize` is the one
471+
// that would run. `instanceof` also matched a subclass whose overridden
472+
// `serialize()` must still be called. Any other serializer (a custom
473+
// one, a subclass that overrides `serialize()`, or a `TypeScriptSerializer`
474+
// from the package's other entry bundle, whose prototype is a different
475+
// object) is called as it always was.
476+
const serializeOptions = { prettify, indent, sortKeys };
477+
const content =
478+
serializer.serialize === TypeScriptSerializer.prototype.serialize &&
479+
serializer.getFormat() === 'typescript'
480+
? serializeTypeScriptForMetadataType(data, type, serializeOptions)
481+
: serializer.serialize(data, serializeOptions);
468482

469483
// Write to disk (atomic or direct)
470484
if (atomic) {

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

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi } 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';
4-
import { TypeScriptSerializer } from '../serializers/typescript-serializer.js';
8+
import { TypeScriptSerializer, serializeTypeScriptForMetadataType } from '../serializers/typescript-serializer.js';
9+
import type { MetadataFormat } from '@objectstack/spec/system';
10+
import type { MetadataSerializer, SerializeOptions } from '../serializers/serializer-interface.js';
11+
import { FilesystemLoader } from '../loaders/filesystem-loader.js';
512

613
describe('Serializers', () => {
714
describe('JSONSerializer', () => {
@@ -55,14 +62,143 @@ describe('Serializers', () => {
5562
describe('TypeScriptSerializer', () => {
5663
const serializer = new TypeScriptSerializer('typescript');
5764

65+
// The saved-view repro: a view used to be annotated `ServiceObject`, which
66+
// has no top-level `type` or `columns` key, so `tsc` refused the file (TS2353).
67+
const view = { name: 'all_accounts', type: 'grid', object: 'account', columns: ['name'] };
68+
const object = { name: 'account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } };
69+
const plain = (item: unknown) =>
70+
`export const metadata = ${JSON.stringify(item, null, 2)};\n\nexport default metadata;\n`;
71+
// Exactly what `FilesystemLoader.save('object', …)` wrote before this fix, and still writes.
72+
const annotatedObject =
73+
`import type { ServiceObject } from '@objectstack/spec/data';\n\n` +
74+
`export const metadata: ServiceObject = ${JSON.stringify(object, null, 2)};\n\n` +
75+
`export default metadata;\n`;
76+
5877
it('should serialize to TypeScript module', () => {
5978
const data = { name: 'test', value: 42 };
6079
const result = serializer.serialize(data);
61-
expect(result).toContain('import type');
6280
expect(result).toContain('export const metadata');
6381
expect(result).toContain('export default metadata');
6482
});
6583

84+
it('the public serialize() writes no annotation, even for an object: it does not know the metadata type', () => {
85+
expect(serializer.serialize(object)).toBe(plain(object));
86+
expect(serializer.serialize(view)).toBe(plain(view));
87+
});
88+
89+
it('writes a view with no annotation: no ServiceObject, no import type', () => {
90+
const result = serializeTypeScriptForMetadataType(view, 'view');
91+
expect(result).not.toContain('ServiceObject');
92+
expect(result).toBe(plain(view));
93+
});
94+
95+
it('still annotates an object ServiceObject, byte-identical to the earlier output', () => {
96+
expect(serializeTypeScriptForMetadataType(object, 'object')).toBe(annotatedObject);
97+
});
98+
99+
it.each([
100+
['a metadata type the spec has no type for', 'external_catalog'],
101+
['a metadata type whose spec type is narrower than its schema', 'book'],
102+
['a plugin-registered metadata type', 'acme_widget'],
103+
['a plural spelling', 'objects'],
104+
])('writes no annotation for %s', (_label, metadataType) => {
105+
expect(serializeTypeScriptForMetadataType({ name: 'x' }, metadataType)).toBe(plain({ name: 'x' }));
106+
});
107+
108+
it('the javascript format never annotates', () => {
109+
expect(new TypeScriptSerializer('javascript').serialize(object)).toBe(plain(object));
110+
});
111+
112+
it('reads back a file written before this fix: a view annotated ServiceObject', () => {
113+
const legacy =
114+
`import type { ServiceObject } from '@objectstack/spec/data';\n\n` +
115+
`export const metadata: ServiceObject = ${JSON.stringify(view, null, 2)};\n\n` +
116+
`export default metadata;\n`;
117+
expect(serializer.deserialize(legacy)).toEqual(view);
118+
});
119+
120+
it('round-trips a view and an object through serialize and deserialize', () => {
121+
for (const [metadataType, item] of [['view', view], ['object', object]] as const) {
122+
expect(serializer.deserialize(serializeTypeScriptForMetadataType(item, metadataType))).toEqual(item);
123+
expect(serializer.deserialize(serializer.serialize(item))).toEqual(item);
124+
}
125+
});
126+
127+
it('FilesystemLoader.save() annotates by metadata type: the view file is unannotated, the object file is ServiceObject', async () => {
128+
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-'));
129+
try {
130+
const manager = new NodeMetadataManager({ rootDir, watch: false });
131+
await manager.save('view', 'all_accounts', view);
132+
await manager.save('object', 'account', object);
133+
const viewFile = await fs.readFile(path.join(rootDir, 'view', 'all_accounts.ts'), 'utf-8');
134+
const objectFile = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8');
135+
expect(viewFile).not.toContain('ServiceObject');
136+
expect(viewFile).toBe(plain(view));
137+
expect(objectFile).toBe(annotatedObject);
138+
} finally {
139+
await fs.rm(rootDir, { recursive: true, force: true });
140+
}
141+
});
142+
143+
it('FilesystemLoader.save() calls a custom serializer registered for typescript as it always did', async () => {
144+
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-custom-'));
145+
try {
146+
const seen: unknown[] = [];
147+
const custom: MetadataSerializer = {
148+
serialize: (item, options) => {
149+
seen.push(options);
150+
return `// custom\nexport const metadata = ${JSON.stringify(item)};\n`;
151+
},
152+
deserialize: (content) => serializer.deserialize(content),
153+
getExtension: () => '.ts',
154+
canHandle: (format) => format === 'typescript',
155+
getFormat: () => 'typescript',
156+
};
157+
const loader = new FilesystemLoader(rootDir, new Map<MetadataFormat, MetadataSerializer>([['typescript', custom]]));
158+
await loader.save('object', 'account', object);
159+
const file = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8');
160+
expect(file).toBe(`// custom\nexport const metadata = ${JSON.stringify(object)};\n`);
161+
expect(seen).toEqual([{ prettify: true, indent: 2, sortKeys: false }]);
162+
} finally {
163+
await fs.rm(rootDir, { recursive: true, force: true });
164+
}
165+
});
166+
167+
// A FilesystemLoader wired by hand with one `typescript` serializer, saving `object`.
168+
const saveObjectWith = async (tsSerializer: MetadataSerializer): Promise<string> => {
169+
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-wired-'));
170+
try {
171+
const loader = new FilesystemLoader(rootDir, new Map<MetadataFormat, MetadataSerializer>([['typescript', tsSerializer]]));
172+
await loader.save('object', 'account', object);
173+
return await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8');
174+
} finally {
175+
await fs.rm(rootDir, { recursive: true, force: true });
176+
}
177+
};
178+
179+
it('FilesystemLoader.save() calls a subclass that overrides serialize(), as it always did', async () => {
180+
class HeaderSerializer extends TypeScriptSerializer {
181+
override serialize<T>(item: T, options?: SerializeOptions): string {
182+
return '// header\n' + super.serialize(item, options);
183+
}
184+
}
185+
expect(await saveObjectWith(new HeaderSerializer('typescript'))).toBe('// header\n' + plain(object));
186+
});
187+
188+
it('FilesystemLoader.save() annotates through the built-in serialize(), inherited by a subclass or not', async () => {
189+
class KeepsSerialize extends TypeScriptSerializer {}
190+
expect(await saveObjectWith(new TypeScriptSerializer('typescript'))).toBe(annotatedObject);
191+
expect(await saveObjectWith(new KeepsSerialize('typescript'))).toBe(annotatedObject);
192+
});
193+
194+
it('FilesystemLoader.save() calls a TypeScriptSerializer from another module copy through its own serialize(): no annotation', async () => {
195+
// The published `.` and `./node` entries are separate bundles, each with its own class copy.
196+
vi.resetModules();
197+
const other = await import('../serializers/typescript-serializer.js');
198+
expect(other.TypeScriptSerializer).not.toBe(TypeScriptSerializer);
199+
expect(await saveObjectWith(new other.TypeScriptSerializer('typescript'))).toBe(plain(object));
200+
});
201+
66202
it('should get correct extension', () => {
67203
const ts = new TypeScriptSerializer('typescript');
68204
expect(ts.getExtension()).toBe('.ts');

0 commit comments

Comments
 (0)