Skip to content

Commit ed9dc25

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19875-sys-user-role-help-text
2 parents 2007d3e + 3bd221d commit ed9dc25

10 files changed

Lines changed: 1813 additions & 291 deletions
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.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a parent-scoped `readonlyWhen` lock is judged against the invoice a line STAYS under, not the one the update names (#19853)
6+
7+
**What a caller could do before.** On a master-detail child whose parent field
8+
is read-only, a caller with edit rights could change a field locked by a
9+
`parent`-scoped `readonlyWhen` by naming a different, unlocked parent in the
10+
same update. With `amount: { readonlyWhen: "parent.status == 'paid'" }` and a
11+
`readonly: true` `invoice` field, `update(line, { amount: 999, invoice:
12+
'open_invoice' })` on a line of a PAID invoice committed `amount = 999`: the
13+
lock was judged against the open invoice the payload named, then the read-only
14+
`invoice` was stripped, so the line stayed under the paid invoice with its
15+
frozen amount rewritten. The same happened when the parent field carried a
16+
`readonlyWhen` lock of its own that kept the line where it was, and on bulk
17+
(`multi: true`) updates for every matched row.
18+
19+
**What happens now.** The engine settles whether the update really moves the
20+
line BEFORE it judges any `parent`-scoped lock, and judges every lock against
21+
the parent the row is stored under afterwards. In the example above `amount` is
22+
dropped as locked, exactly as `update(line, { amount: 999 })` on its own always
23+
was. A legitimate move — the parent field writable, or an `isSystem` /
24+
`preserveAudit` write the read-only strip exempts — is still judged against the
25+
parent it moves to, unchanged.
26+
27+
**What else you may see move, all in the same direction (the parent the row is
28+
stored under decides):**
29+
30+
- Naming a LOCKED parent beside a read-only parent field no longer locks a line
31+
that stays under an open one — its field now commits.
32+
- `requiredWhen` reads the same parent binding, so a `parent`-scoped requirement
33+
is also judged against the parent the row stays under: clearing a required
34+
field on a paid line by naming an open parent is now refused
35+
(`VALIDATION_FAILED`), and naming a paid parent beside a read-only parent
36+
field no longer refuses an open line.
37+
- `onFieldsDropped` now reports the locked field (`readonly_when`) beside the
38+
parent field (`readonly`), and a `strictReadonlyWrites` refusal names both.
39+
- When the parent field carries its own `readonlyWhen`, that lock is still
40+
judged against the parent the update names, as before.

‎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)