Skip to content

fix(service-datasource): emit the authorised ObjectSchema.create shape for object drafts - #19776

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-19723-object-ts-emitter-shape
Sep 23, 2026
Merged

huangyiirene merged 3 commits into
mainfrom
claude/issue-19723-object-ts-emitter-shape

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

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 diff touches no packages/spec path and adds no export to @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 unexported const NAME : ServiceObject = { … }, closed by export default NAME.

Ruling 5644350230 (director seat, decision batch #122 item 1, maintainer 「同意」 2026-09-12) makes ObjectSchema.create({ … }) the one authorised shape for a *.object.ts:

Derived first from the long-term axis: one authoring shape, validated against the protocol at the moment of definition. The factory parses the object against ObjectSchema when the file is evaluated, so an error surfaces where it was written; the typed literal defers everything to a build the author may never run.

Both premises on the card hold as written — I re-read them on origin/main rather than inheriting them:

  • the shape at :931 is the annotated literal (confirmed);
  • the function's own docblock at :857 reads 「Render a reviewable *.object.ts source string for an object draft」 (confirmed), and os datasource introspect --out objects/wh_order.object.ts is 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 of ObjectSchema. import type is 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 = { becomes export 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.ts is export { 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 tsc over the string is the whole acceptance instrument, and it names the pin that now holds the other half. The REMOTE_PRIMARY_KEY_COMMENT docblock, 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:

// Generated by `os datasource introspect` (ADR-0015). Review before committing.
import { ObjectSchema } from '@objectstack/spec/data';

export const wh_fact_orders = ObjectSchema.create({
  name: 'wh_fact_orders',
  label: 'Fact Orders',
  datasource: 'warehouse',
  external: { remoteSchema: 'mart', remoteName: 'fact_orders' },
  // Remote primary key: order_id
  // Preserved as a COMMENT because 'ServiceObject' has no authorable key for a
  // federated object's remote primary key (#11000): 'fields.[f].primaryKey' is
  // not part of the field schema, so emitting it produced a draft that neither
  // 'tsc' nor 'ObjectSchema' accepted. Nothing below reads this line.
  // It names the column(s) THIS DRAFT WAS GIVEN as the key. For a COMPOSITE key
  // some drivers report only the first column (#10997), so treat the list as a
  // lower bound and check it against the remote table before relying on it.
  fields: {
    order_id: { type: 'text' },
    customer: { type: 'text' },
    placed_at: { type: 'datetime' },
    geom: { type: 'text' }, // REVIEW: unrecognised remote type — defaulted to 'text', verify
  },
  // Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1
  // requires this to be an authored decision rather than an accident — the
  // `security-owd-unset` author-time rule refuses an object without it, so a
  // draft that omitted it could not compile. 'private' is the rule's own
  // recommended default: owner + explicit shares. Widen it deliberately.
  sharingModel: 'private',
});

(The remote-key block renders fields.[f].primaryKey with 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:

  1. form — asserts the value import, export const wh_customers = ObjectSchema.create({, the call's closing });, and — separately — the absence of each piece of the refused form by name (: ServiceObject = {, the import type of ServiceObject, the unexported const, export default). A pin that only asserts the new spelling cannot say the old one left.
  2. round-trip — strips the import line, rewrites the single named export to a return, and evaluates the emitted module body through the real ObjectSchema from @objectstack/spec/data with new Function. That is the same factory call the committed file makes on the author's machine; if create() would throw there, it throws here. The evaluated value is asserted equal to ObjectSchema.parse(draft.definition), so the rendered file and the structured definition are proven to describe one object.
  3. negative control — the same harness is fed a source with one unknown top-level key spliced in and is required to throw. A round-trip that cannot fail is not a measurement.

The round-trip earned its keep on the first run: it failed because external.writable: false is 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.ts had the only other shape assertion; it moved to the factory spelling. Its sibling not.toContain('_customers:') leg is replaced rather than deleted: the old spelling read the annotated literal's const _customers: and goes vacuous once the type annotation leaves the file, and a bare not.toContain('_customers') cannot replace it because the no-namespace TODO block legitimately renders that substring. It is now toContain('export const customers = ObjectSchema.create({') plus not.toContain('export const _customers').

Lane split — it stays in this lane

The card and ruling item 4 warn that the door is a packages/cli command. Measured: packages/cli/src/commands/datasource/introspect.ts is a pure pass-through — it POSTs to the draft route and writes draft.source verbatim (await writeFile(target, draft.source, 'utf8')), with no knowledge of the shape. Its description, its two --out objects/wh_order.object.ts examples and its *.object.ts prose describe the destination, not the form, and all stay true. No packages/cli change is owed, so no sibling PR is needed for this card.

Verification, all at 78a7783cd5

what command result
dependency closure pnpm --filter '@objectstack/service-datasource^...' build --concurrency=2 VERDICT command-exit 0
package tests pnpm --filter @objectstack/service-datasource test Test Files 34 passed (34) · Tests 693 passed (693)
package typecheck pnpm --filter @objectstack/service-datasource typecheck VERDICT command-exit 0
repo lint pnpm lint (full repo, eslint . --no-inline-config) exit 0
derived gate families node scripts/pm/dispatch-gates.mjs --commands then --ran 61 derived, 59 run green, 0 unrun

tsc --listFiles confirms 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-loads and check:type-check-debt both exit 3 — PREREQUISITE NOT MET — because each reads built output for the whole workspace ("Run pnpm build first. ⛔ 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


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 041c8cf62b05e5ab0d7c1270a17565cc2da6f5fd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d9ce0f5a327d90cdcd897aa44545f426dd546d88 — the merge of head 78a7783cd544fde9dfdf11789d2b08b3243c6756 into base 041c8cf62b05e5ab0d7c1270a17565cc2da6f5fd, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants