Skip to content

Commit 08484f3

Browse files
committed
fix(service-datasource): emit the authorised ObjectSchema.create shape for object drafts
`renderObjectSource` rendered the annotated-object-literal shape (`import type { ServiceObject }` + `const X: ServiceObject = { … }` + `export default X`) for a file its own docblock calls a `*.object.ts`, and `os datasource introspect --out objects/x.object.ts` tells the author to commit under that suffix. The director-seat ruling of 2026-09-12 (decision batch #122 item 1) makes `ObjectSchema.create({ … })` the one authorised shape for a `*.object.ts`, so the generator was handing users a file the platform refuses. Emit a value import of `ObjectSchema`, a single named export bound to `ObjectSchema.create({ … })`, and no `export default` — the shape the scaffolder's barrel re-exports by name. The two authored comment blocks (the remote-primary-key note and the ADR-0028 namespace TODO) are preserved verbatim, and the renderer's docblock moves with the code. Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
1 parent e99a14c commit 08484f3

3 files changed

Lines changed: 240 additions & 11 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The generated draft is emitted in the ONE authorised `*.object.ts` shape.
5+
*
6+
* Director-seat ruling, decision batch #122 item 1, maintainer 「同意」
7+
* 2026-09-12, verbatim:
8+
*
9+
* > `ObjectSchema.create({ … })` is the one authorised shape for a
10+
* > `.object.ts` … The factory parses the object against `ObjectSchema` when
11+
* > the file is evaluated, so an error surfaces where it was written; the typed
12+
* > literal defers everything to a build the author may never run.
13+
*
14+
* `renderObjectSource` wrote the other one — `const X: ServiceObject = { … }`
15+
* behind an `import type`, closed by `export default X`. Its own docblock calls
16+
* the output a `*.object.ts`, and `os datasource introspect --out
17+
* objects/x.object.ts` tells the author to commit it under that suffix, so the
18+
* bytes really do land where the ruling governs.
19+
*
20+
* ## Why this file is not one `toContain` line
21+
*
22+
* The failure mode a shape assertion invites is a generator that emits the
23+
* right-LOOKING call around a definition the factory refuses — the defect moved
24+
* one layer down, with every string assertion still green. Both halves are
25+
* therefore pinned separately, and they measure different things:
26+
*
27+
* 1. **the form** — the value import, the named export bound to
28+
* `ObjectSchema.create(`, and the ABSENCE of the refused annotated-literal
29+
* form. A shape pin that only asserts the new spelling cannot say the old
30+
* one left.
31+
* 2. **the round-trip** — the emitted module body is EVALUATED with the real
32+
* `ObjectSchema` from `@objectstack/spec/data`, which is the same factory
33+
* call the committed file makes on the author's machine. Nothing is
34+
* re-spelled here: if `create()` would throw in the author's file, it throws
35+
* in this test.
36+
*
37+
* ## Why the evaluation instrument carries its own negative control
38+
*
39+
* An evaluation harness that silently stopped running the factory — a transform
40+
* that no longer matched, an import that resolved to a stub — would report
41+
* every draft as valid forever. The last block feeds it a source with one
42+
* unknown top-level key spliced in and requires it to throw. A round-trip that
43+
* cannot fail is not a measurement.
44+
*/
45+
46+
import { describe, it, expect } from 'vitest';
47+
import type { IntrospectedSchema } from '@objectstack/spec/contracts';
48+
import { ObjectSchema } from '@objectstack/spec/data';
49+
import {
50+
ExternalDatasourceService,
51+
type DatasourceLike,
52+
} from '../external-datasource-service.js';
53+
54+
function remoteSchema(): IntrospectedSchema {
55+
return {
56+
dialect: 'postgres',
57+
introspectedAt: '2026-09-23T00:00:00.000Z',
58+
tables: {
59+
'mart.customers': {
60+
name: 'mart.customers',
61+
indexes: [],
62+
columns: [
63+
{ name: 'id', type: 'text', nullable: false, primaryKey: true },
64+
{ name: 'name', type: 'varchar(255)', nullable: true, primaryKey: false },
65+
{ name: 'signed_up_at', type: 'timestamptz', nullable: true, primaryKey: false },
66+
],
67+
},
68+
},
69+
};
70+
}
71+
72+
function serviceWith(namespace?: string): ExternalDatasourceService {
73+
return new ExternalDatasourceService({
74+
introspect: async () => remoteSchema(),
75+
getDatasource: async (name): Promise<DatasourceLike> => ({ name, schemaMode: 'external' }),
76+
getObject: async () => undefined,
77+
listObjects: async () => [],
78+
getNamespace: () => namespace,
79+
});
80+
}
81+
82+
const draftFor = (namespace?: string) =>
83+
serviceWith(namespace).generateObjectDraft('warehouse', 'customers');
84+
85+
/**
86+
* Evaluate the emitted module body and return what its single export is bound
87+
* to — i.e. run the author's own `ObjectSchema.create(…)` call.
88+
*
89+
* The transform is asserted rather than assumed: a silently non-matching
90+
* `replace` would hand `new Function` a body with no `return` in it, which
91+
* evaluates to `undefined` and throws nothing at all.
92+
*/
93+
function evaluateEmittedModule(source: string): unknown {
94+
const body = source
95+
.split('\n')
96+
.filter((line) => !line.startsWith('import '))
97+
.join('\n')
98+
.replace(/^export const [A-Za-z_$][\w$]* = /m, 'return ');
99+
expect(body, 'the emitted module has no single named export to evaluate').toContain(
100+
'return ObjectSchema.create(',
101+
);
102+
return new Function('ObjectSchema', body)(ObjectSchema) as unknown;
103+
}
104+
105+
describe('the emitted draft carries the authorised `ObjectSchema.create` shape', () => {
106+
it('imports the factory as a VALUE and binds a single named export to its call', async () => {
107+
const draft = await draftFor('wh');
108+
109+
expect(draft.source).toContain("import { ObjectSchema } from '@objectstack/spec/data';");
110+
expect(draft.source).toContain('export const wh_customers = ObjectSchema.create({');
111+
// The call is closed as a call, not as a bare object literal.
112+
expect(draft.source.trimEnd().endsWith('});')).toBe(true);
113+
});
114+
115+
it('carries the shape on the no-namespace path too, TODO block and all', async () => {
116+
const draft = await draftFor(undefined);
117+
118+
// The TODO block renders ABOVE the import; the shape must survive it.
119+
expect(draft.source).toContain('TODO(namespace)');
120+
expect(draft.source).toContain("import { ObjectSchema } from '@objectstack/spec/data';");
121+
expect(draft.source).toContain('export const customers = ObjectSchema.create({');
122+
});
123+
124+
it('emits a value import — an `import type` would be elided and the file would throw', async () => {
125+
const draft = await draftFor('wh');
126+
expect(draft.source).not.toContain('import type');
127+
});
128+
});
129+
130+
describe('the refused annotated-literal form is absent, by name', () => {
131+
it.each([
132+
['the `ServiceObject` type annotation', ': ServiceObject = {'],
133+
['the type-only spec import', "import type { ServiceObject } from '@objectstack/spec/data';"],
134+
['the unexported `const` binding', 'const wh_customers: ServiceObject'],
135+
['the default export that closed it', 'export default'],
136+
])('%s is gone', async (_label, refused) => {
137+
const draft = await draftFor('wh');
138+
expect(draft.source).not.toContain(refused);
139+
});
140+
141+
it('names no `ServiceObject` type at all outside the preserved remote-key note', async () => {
142+
const draft = await draftFor('wh');
143+
const mentions = draft.source
144+
.split('\n')
145+
.filter((l) => l.includes('ServiceObject'))
146+
.map((l) => l.trim());
147+
148+
// The one survivor is the remote-primary-key tombstone, which explains a
149+
// SPEC type rather than describing this file's shape.
150+
expect(mentions).toEqual([
151+
"// Preserved as a COMMENT because 'ServiceObject' has no authorable key for a",
152+
]);
153+
});
154+
});
155+
156+
describe('round-trip — the factory call inside the emitted file accepts the draft', () => {
157+
it('evaluates without throwing and yields the definition the draft reports', async () => {
158+
const draft = await draftFor('wh');
159+
const evaluated = evaluateEmittedModule(draft.source) as Record<string, unknown>;
160+
161+
expect(evaluated.name).toBe('wh_customers');
162+
expect(evaluated.label).toBe('Customers');
163+
expect(evaluated.datasource).toBe('warehouse');
164+
expect(evaluated.sharingModel).toBe('private');
165+
expect(Object.keys(evaluated.fields as Record<string, unknown>)).toEqual([
166+
'id',
167+
'name',
168+
'signed_up_at',
169+
]);
170+
expect(evaluated.external).toEqual({ remoteSchema: 'mart', remoteName: 'customers' });
171+
});
172+
173+
it('round-trips the no-namespace draft too — the TODO comment is inert to the factory', async () => {
174+
const draft = await draftFor(undefined);
175+
const evaluated = evaluateEmittedModule(draft.source) as Record<string, unknown>;
176+
expect(evaluated.name).toBe('customers');
177+
});
178+
179+
it('agrees with the structured definition the same draft carries', async () => {
180+
const draft = await draftFor('wh');
181+
const evaluated = evaluateEmittedModule(draft.source);
182+
// `create()` parses, so the comparison is against the parsed definition —
183+
// the rendered file and `draft.definition` must describe one object.
184+
expect(evaluated).toEqual(ObjectSchema.parse(draft.definition));
185+
});
186+
187+
it('NEGATIVE CONTROL — the evaluation really runs the factory', async () => {
188+
const draft = await draftFor('wh');
189+
const poisoned = draft.source.replace(
190+
" name: 'wh_customers',",
191+
" name: 'wh_customers',\n workflows: [],",
192+
);
193+
expect(poisoned).not.toBe(draft.source);
194+
195+
expect(() => evaluateEmittedModule(poisoned)).toThrow(/workflows/);
196+
});
197+
});

‎packages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ describe('defect 1 — the generated object name carries the package namespace p
135135
// …and the rendered file agrees with the structured definition.
136136
expect(draft.definition.name).toBe('wh_customers');
137137
expect(draft.source).toContain("name: 'wh_customers'");
138-
expect(draft.source).toContain('const wh_customers: ServiceObject = {');
138+
expect(draft.source).toContain('export const wh_customers = ObjectSchema.create({');
139139
});
140140

141141
it('does NOT double-prefix a remote table that already carries the namespace', async () => {
@@ -227,7 +227,14 @@ describe('an absent or blank namespace must not trade one invalid draft for anot
227227

228228
expect(draft.name).toBe('customers');
229229
expect(draft.name.startsWith('_')).toBe(false);
230-
expect(draft.source).not.toContain('_customers:');
230+
// Spelled against the binding the authorised shape emits. The old spelling
231+
// (`'_customers:'`) read the annotated literal's `const _customers:` and
232+
// went vacuous the moment the type annotation left the file — it matches no
233+
// substring of `export const _customers = ObjectSchema.create({`. A bare
234+
// `not.toContain('_customers')` cannot replace it either: the no-namespace
235+
// TODO block legitimately renders `'<namespace>_customers'`.
236+
expect(draft.source).toContain('export const customers = ObjectSchema.create({');
237+
expect(draft.source).not.toContain('export const _customers');
231238
expect(ObjectSchema.safeParse(draft.definition).success).toBe(true);
232239
});
233240

‎packages/services/service-datasource/src/external-datasource-service.ts‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -247,13 +247,19 @@ const GENERATED_SHARING_MODEL = 'private';
247247
* into the generated source — and the one place the reason is written down.
248248
*
249249
* `fields.<f>.primaryKey` is **not a key of the spec field schema**. Emitting
250-
* it produced a `*.object.ts` the platform's own toolchain refused on both
251-
* instruments it is annotated for: `tsc --noEmit` against `ServiceObject`
250+
* it produced a `*.object.ts` the platform's own toolchain refused on both of
251+
* its instruments: `tsc --noEmit` against `ServiceObject`
252252
* (`TS2353 … 'primaryKey' does not exist in type`) and
253253
* `ObjectSchema.safeParse` (`unrecognized_keys` at `["fields","<f>"]`). So the
254254
* generator had a pinned path that produced a draft neither the compiler nor
255255
* the validator would take (#11000).
256256
*
257+
* Both instruments still judge it, and the second one moved CLOSER: the
258+
* emitted file is now the authorised `ObjectSchema.create({ … })` shape (see
259+
* {@link renderObjectSource}), so the validator's verdict arrives when the
260+
* committed module is evaluated rather than only when somebody runs a build.
261+
* Re-emitting the key would therefore throw in the author's own file.
262+
*
257263
* Maintainer ruling, 2026-08-22 live session (「同意所有」, item 8) — **D**:
258264
*
259265
* > `generateObjectDraft`/`renderObjectSource` stop emitting
@@ -856,8 +862,26 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
856862
/**
857863
* Render a reviewable `*.object.ts` source string for an object draft.
858864
*
859-
* The output is annotated `ServiceObject`, which makes `tsc` over this string
860-
* a complete acceptance instrument for the draft's shape — use it that way.
865+
* The output is the ONE authorised `*.object.ts` shape: a single named export
866+
* bound to `ObjectSchema.create({ … })` — director-seat ruling, decision batch
867+
* #122 item 1, maintainer 「同意」 2026-09-12. Its reasoning is what the two
868+
* halves of this renderer have to preserve:
869+
*
870+
* > The factory parses the object against `ObjectSchema` when the file is
871+
* > evaluated, so an error surfaces where it was written; the typed literal
872+
* > defers everything to a build the author may never run.
873+
*
874+
* ⇒ `tsc` over this string is NO LONGER the whole acceptance instrument, and a
875+
* renderer that emitted the factory CALL around a definition the factory
876+
* refuses would have moved the defect rather than fixed it. Both instruments
877+
* are owed a pin: the emitted call's shape, and an evaluation of the emitted
878+
* module body through the real `ObjectSchema.create`
879+
* (`external-object-draft-authorised-shape.test.ts` holds both).
880+
*
881+
* The export is NAMED, and there is deliberately no `export default` beside
882+
* it: the scaffolded barrel re-exports object modules by name
883+
* (`export { X } from './x.object.js'`), and a second export form in a
884+
* generated file is exactly the parallel shape the ruling closed.
861885
*
862886
* `namespace` is passed in rather than re-derived from `definition.name`,
863887
* because the two absent cases are NOT the same file: a name that is already
@@ -926,9 +950,12 @@ function renderObjectSource(
926950
return [
927951
`// Generated by \`os datasource introspect\` (ADR-0015). Review before committing.`,
928952
...namespaceTodo,
929-
`import type { ServiceObject } from '@objectstack/spec/data';`,
953+
// A VALUE import, not a type-only one: the factory runs when the committed
954+
// file is evaluated. `import type` here would be elided at compile time and
955+
// the emitted module would throw on its own first line.
956+
`import { ObjectSchema } from '@objectstack/spec/data';`,
930957
``,
931-
`const ${definition.name as string}: ServiceObject = {`,
958+
`export const ${definition.name as string} = ObjectSchema.create({`,
932959
` name: '${definition.name as string}',`,
933960
` label: '${definition.label as string}',`,
934961
` datasource: '${definition.datasource as string}',`,
@@ -943,9 +970,7 @@ function renderObjectSource(
943970
` // draft that omitted it could not compile. '${GENERATED_SHARING_MODEL}' is the rule's own`,
944971
` // recommended default: owner + explicit shares. Widen it deliberately.`,
945972
` sharingModel: '${definition.sharingModel as string}',`,
946-
`};`,
947-
``,
948-
`export default ${definition.name as string};`,
973+
`});`,
949974
``,
950975
].join('\n');
951976
}

0 commit comments

Comments
 (0)