fix(service-datasource): emit the authorised ObjectSchema.create shape for object drafts - #19776
Conversation
…e 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>
…d-trip Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d9ce0f5a327d90cdcd897aa44545f426dd546d88 && git checkout d9ce0f5a327d90cdcd897aa44545f426dd546d88
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 041c8cf62b05e5ab0d7c1270a17565cc2da6f5fd 78a7783cd544fde9dfdf11789d2b08b3243c6756 && git checkout -B drift-repro 041c8cf62b05e5ab0d7c1270a17565cc2da6f5fd && git merge --no-ff 78a7783cd544fde9dfdf11789d2b08b3243c6756
node scripts/docs-audit/affected-docs.mjs --json 041c8cf62b05e5ab0d7c1270a17565cc2da6f5fd |
Fixes #19723
Clause-②: no — this pulls the emitter back onto a shape a ruling already declared; it adds no exported symbol and no key on any published payload. Verified:
git difftouches nopackages/specpath and adds noexportto@objectstack/service-datasource's public surface.The defect, re-measured at
origin/main(e99a14c)renderObjectSource(packages/services/service-datasource/src/external-datasource-service.ts) emitted the annotated-object-literal form —import type { ServiceObject } …, an unexportedconst NAME : ServiceObject = { … }, closed byexport default NAME.Ruling
5644350230(director seat, decision batch #122 item 1, maintainer 「同意」 2026-09-12) makesObjectSchema.create({ … })the one authorised shape for a*.object.ts:Both premises on the card hold as written — I re-read them on
origin/mainrather than inheriting them::931is the annotated literal (confirmed);:857reads 「Render a reviewable*.object.tssource string for an object draft」 (confirmed), andos datasource introspect --out objects/wh_order.object.tsis the command's published example, so the bytes are destined for a committed file the ruling governs.What changed
One emitter, three lines of rendered output:
import type { ServiceObject } from '@objectstack/spec/data';becomes a value import ofObjectSchema.import typeis elided at compile time, so a type-only import beside a factory call would leave a module that throws on its own first line — this is the half a shape-only fix silently gets wrong.const NAME : ServiceObject = {becomesexport const NAME = ObjectSchema.create({, closed with});.export default NAME;is dropped. The export is named only, which is what the scaffolded barrel re-exports (packages/create-objectstack/src/templates/blank/src/objects/index.tsisexport { Note } from './note.object.js';). A second export form in a generated file is the parallel shape the ruling closed.Both authored comment blocks the card names are preserved byte-for-byte: the remote-primary-key tombstone and the ADR-0028 unprefixed-name TODO. The renderer's docblock moved with the code — it no longer claims
tscover the string is the whole acceptance instrument, and it names the pin that now holds the other half. TheREMOTE_PRIMARY_KEY_COMMENTdocblock, which described the emitted file as "annotated", was corrected in the same edit.Real generated output, from
generateObjectDraft('warehouse', 'fact_orders')with a namespace resolved and a remote primary key reported:(The remote-key block renders
fields.[f].primaryKeywith angle brackets in the real output; they are spelled with square brackets here only because this body is stored through an API that rewrites short angle-bracket fragments.)The pin — form AND round-trip, because the form alone can be satisfied by a broken file
packages/services/service-datasource/src/__tests__/external-object-draft-authorised-shape.test.ts(new, 12 cases). It is not a snapshot:export const wh_customers = ObjectSchema.create({, the call's closing});, and — separately — the absence of each piece of the refused form by name (: ServiceObject = {, theimport typeofServiceObject, the unexportedconst,export default). A pin that only asserts the new spelling cannot say the old one left.return, and evaluates the emitted module body through the realObjectSchemafrom@objectstack/spec/datawithnew Function. That is the same factory call the committed file makes on the author's machine; ifcreate()would throw there, it throws here. The evaluated value is asserted equal toObjectSchema.parse(draft.definition), so the rendered file and the structured definition are proven to describe one object.The round-trip earned its keep on the first run: it failed because
external.writable: falseis applied by the schema's own default, which is standing evidence that the file really parsed rather than an object literal being handed back. That key is now asserted explicitly instead of the assertion being loosened.external-object-draft-os-build.test.tshad the only other shape assertion; it moved to the factory spelling. Its siblingnot.toContain('_customers:')leg is replaced rather than deleted: the old spelling read the annotated literal'sconst _customers:and goes vacuous once the type annotation leaves the file, and a barenot.toContain('_customers')cannot replace it because the no-namespace TODO block legitimately renders that substring. It is nowtoContain('export const customers = ObjectSchema.create({')plusnot.toContain('export const _customers').Lane split — it stays in this lane
The card and ruling item 4 warn that the door is a
packages/clicommand. Measured:packages/cli/src/commands/datasource/introspect.tsis a pure pass-through — it POSTs to the draft route and writesdraft.sourceverbatim (await writeFile(target, draft.source, 'utf8')), with no knowledge of the shape. Its description, its two--out objects/wh_order.object.tsexamples and its*.object.tsprose describe the destination, not the form, and all stay true. Nopackages/clichange is owed, so no sibling PR is needed for this card.Verification, all at
78a7783cd5pnpm --filter '@objectstack/service-datasource^...' build --concurrency=2VERDICT command-exit 0pnpm --filter @objectstack/service-datasource testTest Files 34 passed (34)·Tests 693 passed (693)pnpm --filter @objectstack/service-datasource typecheckVERDICT command-exit 0pnpm lint(full repo,eslint . --no-inline-config)node scripts/pm/dispatch-gates.mjs --commandsthen--rantsc --listFilesconfirms the new test file is inside the package's own tsc program (1 hit), so the typecheck above really covers it.Two of the 61 families are NOT MEASURED, not green and not red:
check:dual-build-cjs-loadsandcheck:type-check-debtboth exit 3 —PREREQUISITE NOT MET— because each reads built output for the whole workspace ("Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured."). That is a full-farm build this lane does not own; CI runs both against a built tree.Acceptance notes
packages/cli/src/commands/init.ts(twoTEMPLATESsites) andpackages/cli/src/commands/generate.tsstill renderData.ServiceObject = {. Those are ruling item 1 / thedomain:clihalf, tracked as os init and os generate still emit the annotated-literal.object.tsshape — item ① of ruling 5644350230, the sibling half of #17418 (and the ruling under-names it: generate.ts too) #19722 — ⛔ deliberately not touched here, per ruling item 4's lane split.check:keyed-text-boundsselects its population by filename suffix over the repo tree, and no*.object.tsfile exists underpackages/cliorpackages/services. The strings changed here are template literals inside a.tsfile, so the gate never reads them in any shape; this PR neither depends on nor unblocks feat(scripts): refuse a non-factory*.object.tsdeclaration by name (ruling item 2 + census) #19720.Generated by Claude Code