Uh oh!
There was an error while loading. Please reload this page.
fix(service-datasource): generate an object draft that os build accepts - #11059
Conversation
…epts
`generateObjectDraft` rendered a `*.object.ts` the platform's own validator
refuses, on two independent counts: the object name carried no `${namespace}_`
prefix (`defineStack()` refuses it, ADR-0028) and no `sharingModel` was emitted
(`security-owd-unset` refuses it, ADR-0090 D1).
The namespace is derived from the datasource's own owning package and applied
through `validateObjectNamespacePrefix`, the single source of that rule. The
OWD follows the shape #9666 settled for generated scaffolds: an explicit
`'private'`, rendered with the reason attached. An unresolvable namespace keeps
the bare name plus a loud TODO rather than inventing a prefix.
Fixes#10712
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 0 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 81aeec00dfe2d7288499211472301695b47d2286 && git checkout 81aeec00dfe2d7288499211472301695b47d2286
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 072d072d2a74e358e923f6f6a76acbf7621305c3 14550cb58bbc1a0994345b329d59608acb01114e && git checkout -B drift-repro 072d072d2a74e358e923f6f6a76acbf7621305c3 && git merge --no-ff 14550cb58bbc1a0994345b329d59608acb01114e
node scripts/docs-audit/affected-docs.mjs --json 072d072d2a74e358e923f6f6a76acbf7621305c3
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32578772845 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10712. The generated object draft now passes
os buildon the defaultpath. What the
opts.primaryKeypath still does — and why that is a different card —is stated under "The acceptance boundary" below, measured rather than assumed.
The two defects, reproduced first
Both reproduce on
2866d5f97(the card measured at79ebb37; the tree has movedseveral times since, so neither failure is inherited). The generator was driven off a
real
SqlDriver.introspectSchema()against a live in-memory SQLite database, thenthe rendered draft was held to the same three stages
os buildholds it to.defineStack()— namespace-prefix validation (ADR-0028)Object 'customers' is missing the package namespace prefix. Rename it to 'wh_customers' (namespace = 'wh').authoringRulesFor('build')— the 41 rulesos compilerunssecurity-owd-unsetatobjects[0].sharingModeltsc --noEmitoverdraft.sourceStage 3 is the instrument the #10676 seat left in PR #11001 —
renderObjectSourceannotates its output
ServiceObject, sotscover the rendered file is a completecheck of the draft's shape. It is complete for shape and blind to the two defects
above, which is why stages 1 and 2 are here: neither an unprefixed name nor a missing
sharingModelis a type error.The namespace half — where the prefix comes from
Derived from the datasource's own owning package. A federated object is bound to
exactly one datasource (
definition.datasource), so the package that declared thatdatasource is the package the object belongs in. Both links are read, not assumed:
_packageIdis stamped onto every registered metadata item carrying package coordsby
applyProtection(@objectstack/spec/shared), on both load paths — the artifactloader and
registry.registerItem. The'sys_metadata'rehydration sentinel isexcluded, exactly as the registry's own
isCodeArtifactBodyexcludes it.installPackagestored undermanifest.id— the same{ manifest }shape the runtime publish gate reads for this identical check.Resolution is injected (
ExternalDatasourceServiceConfig.getNamespace), keeping theservice kernel-free like every other read it makes;
plugin.tssupplies the wiring.The prefix itself is applied through
validateObjectNamespacePrefix— the singlesource of the rule, shared verbatim with
defineStack()andMetadataManager.publishPackage. Imported rather than re-spelled asstartsWith: ahand-rolled check here could pass while the real gate refuses, which is the drift that
produced this defect. It also gets the already-prefixed case right for free — a remote
table
wh_accountsunder namespacewhstayswh_accounts, notwh_wh_accounts.When the namespace is absent or empty
The draft keeps the bare remote-table name — today's output, unchanged — and the
rendered source carries a loud
TODO(namespace)naming ADR-0028 and the rename tomake. It does not invent a prefix. That mirrors
defineStack, which skips thecheck entirely for a stack with no
manifest.namespaceand, in its own words, "doesnot invent a prefix on the author's behalf because doing so would silently introduce a
second writing style". A bare name is legal exactly where that stack is legal.
A blank or whitespace-only namespace is normalised to absent. That case is asserted,
not assumed:
validateObjectNamespacePrefixskips a falsy namespace, but' 'istruthy and would have rendered
_customers— one invalid draft traded for another,which is the failure this card exists to close.
The
sharingModelhalf — #9666's shape, not a new judgement#9666 hit this same rule family for the
os inittemplate, and its resolution(PR #9736, live in
packages/cli/src/commands/init.ts) is: declare the valueexplicitly, and pick
'private'— the rule's own recommended default — with thereason attached as a comment. This draft follows that verbatim, comment included.
It transfers for three reasons stated rather than assumed:
'private'is whatADR-0090 D1 already resolves an unset OWD to at runtime, so the draft describes the
posture the platform would apply anyway; it is the most restrictive of the four
canonical values, so this default can never publish a federated object wider than its
author intended; and the change is that the baseline becomes an authored decision
instead of an accident, which is the whole point of
security-owd-unset. No newsecurity posture is being chosen here.
The acceptance boundary — what the
opts.primaryKeypath still doesMeasured on the final commit, both paths, same harness:
opts.primaryKeydefineStacktscUnrecognized key(s) on this field: primaryKeyTS2353 … 'primaryKey' does not existThe default draft now builds; the
opts.primaryKeypath still does not, pending#11000. Both of this card's own defects are visibly discharged on that failing path
too — it renders
name: 'wh_customers'andsharingModel: 'private'— so what remainsis #11000's alone:
fields.FIELDNAME.primaryKeyis not an authorable spec field key.Its likely fix is
packages/specsurface and its routing is an open contract question,so it is deliberately untouched here. #11000 remains open and is not addressed by this PR.
Before the fix, the
primaryKey-set path failed onunrecognized_keysbefore thenamespace check ran — so on that path this card's first defect was masked, not absent.
What is pinned —
src/__tests__/external-object-draft-os-build.test.ts, 15 casesThe pre-existing 525 stay green unchanged, which is the finding in its own right:
that suite is blind to both defects, and stays blind because it wires no namespace.
which one it measures, nor fail informatively when one regresses alone. The prefix
case asserts through
validateObjectNamespacePrefix— the functiondefineStack()itself calls. The OWD case runs a full
ObjectSchema.safeParse, because what isguarded is a value's verdict, not a key's presence.
minimal valid stub would score green on a validator-only suite while destroying what
the generator is for. So the introspected columns and their mapped types, the remote
table name,
remoteSchema, and theexternalbinding are all asserted to survive —including that the object was renamed to
wh_customerswhileexternal.remoteNamestayed
customers.finds nothing,
'',' '), each asserting the bare name and never a leadingunderscore, plus the TODO's presence and — the other direction — its absence once a
namespace does resolve.
importObjectinherits both repairs through the draft pipeline.Verification —
14550cb58, clean treepnpm --filter @objectstack/service-datasource test→Test Files 24 passed (24)·Tests 540 passed (540).typecheck→ exit 0, noerror TS.Ablations — signatures predicted in writing BEFORE mutating, one per defect
Two ablations, because the card requires the two defects to be pinned separately and
separate ablations are what prove the separation.
A — revert the prefix only (
applyNamespacePrefix(shortName, ns)→shortName).Predicted: exactly 2 failures, both
AssertionErroron the name, in"prefixes the derived name…" and "persists the prefixed name and the explicit OWD";
totals
1 failed | 23 passed (24),2 failed | 538 passed (540). Predicted not toredden, with reasons: the double-prefix case (its table is already
wh_accounts, sodropping the step leaves the same correct name — it guards double-prefixing, not the
prefix being dropped), the label case, the whole absent-namespace block, the TODO-absence
case, and all of
still-generates.Observed:
2 failed | 538 passed (540),1 failed | 23 passed (24), bothAssertionError: expected 'customers' to be 'wh_customers', exactly those two cases.Prediction matched exactly.
B — revert the
sharingModelemission only (definition key + the six rendered lines).Predicted: exactly 4 failures — the three
defect 2cases and theimportObjectcase;totals
4 failed | 536 passed (540). Predicted not to redden: everyObjectSchema.safeParse(...).successassertion, becausesharingModelis optional inObjectSchemaandsecurity-owd-unsetis a lint rule, not a schema refusal — which isprecisely why reproducing this half needed a lint-level instrument at all.
Observed:
4 failed | 536 passed (540),1 failed | 23 passed (24), threeexpected undefined to be 'private'plus the sourcetoContainfailure, exactly thosefour cases, every
safeParseassertion green. Prediction matched exactly.Restores proved byte-identical by
git hash-object:60cc5dfde720df630e9db485be1fd16aa7aa8134before → mutated40893e6d1334d601888629ccf261fb576778400f(A) /a6e8e0327c6a83dad420146a8a6879645a4b741a(B)→
60cc5dfde720df630e9db485be1fd16aa7aa8134after, both times. Both restore legs werere-run to a real verdict rather than trusted on the hash:
540 passed (540)each.src/vsdist/, argued from the files — and re-verified, not inheritedTwo conflicting claims existed: #11001's seat measured
service-datasourceas havingno
dist/, while the card said the defect was "confirmed in the shippeddist".Re-verified here, and both readings need retiring as stated:
2866d5f97,packages/services/service-datasource/dist/didnot exist —
pnpm installbuilds nothing. So fix(service-datasource): read the introspected primary key at the isPrimary/primaryKey seam #11001's observation reproduces, butit is a fact about a fresh worktree, not a property of the package.
dist/(I built it deliberately). So the "no distexists" argument is not available here, and a different one is needed.
The argument that survives: the pin imports its subject by the relative specifier
../external-datasource-service.js, which cannot route through packageexportsatall — vitest resolves it to
src/external-datasource-service.ts. That is provedempirically, not just read: both ablations reddened with no rebuild between edit and
run, which is only possible if the suite is executing
src/.The
os buildharness is the opposite case and is treated as such: it drives thegenerator through the package's built
dist/(it must — it also needs@objectstack/lint,which is not a dependency of this package). Every measurement through it is preceded by
a rebuild, and the fix was proved to have reached the artifact with
node scripts/ablation-dist-preflight.mjs @objectstack/service-datasource GENERATED_SHARING_MODEL→
✓ marker present in 2 built files. The@objectstack/specand@objectstack/linthalves of that harness resolve through their own built
dist/; neither is mutated.Zero-hit counter-check — positive control run FIRST
The dispatch's stop-condition: the object NAME changes (
customers→wh_customers), so a consumer depending on the unprefixed name would make this readingwrong.
Positive control first, so the silence is readable: a sweep for
generateObjectDraftacross both repos, excluding
dist/andnode_modules, returned 13 files — andsurfaced a route surface not named in the card (
packages/rest/src/external-datasource-routes.ts),so the instrument is demonstrably reaching further than the assumption behind it.
Every one of those consumers, read individually, treats
draft.nameas opaque serveroutput: both route handlers (
service-datasource/admin-routes.ts,rest/external-datasource-routes.ts) pass the whole draft through untouched; the CLI(
os datasource introspect) writesdraft.sourceand never derives a path from thename; objectui uses it verbatim as a storage key
(
metaClient.save('object', draft.name, draft.definition)— both halves move together,so #7378's register contract still holds) and as display text. The two tests that assert
a bare name (
admin-routes.test.ts, objectui'sexternal/api.test.ts) are stubs —vi.fn().mockResolvedValue(...)and a stubbedfetch— asserting transport, notgenerator output; neither reaches the real generator. No consumer depends on the name
being unprefixed.
Gates
Union derived on the final commit with a clean tree via
node scripts/pm/dispatch-gates.mjs, no path arguments (exit 0; 11 path-matchedfamilies + 5 convention-triggered). Every exit code was captured before any pipe
(
cmd > file 2>&1; EXIT=$?), and each row below quotes the gate's own printedverdict line, never a bare
$?.check:changeset-gate-self-tests✓ check-empty-changeset --self-test: 118 assertions over real temp git reposcheck:objectui-changeset✓ objectui-range --self-test: all checks passedcheck:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none newcheck:test-source-aliascheck-test-source-alias OK — 72 packages with tests scannedcheck:type-source-resolutioncheck-type-source-resolution OK — 77 packages with a tsconfig.json scannedcheck-adr-0087-registration.mjs✓ … this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).check-changeset-no-major.mjs✓ This diff introduces no major bump.check-ci-filter-parity.mjsOK: all 83 declared cross-package glob(s) (72 unique) are coveredcheck-empty-changeset.mjs✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).check-plugin-teardown-shape.mjs✓ check:plugin-teardown-shape: 61 Plugin implementation(s) across 4444 source(s)check-affected-docs.mjs✓ affected-docs self-test: 339 cases pass.check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none newcheck:type-check-coveragecheck-type-check-coverage: OK — 65/78 workspace packages type-checkedcheck:engine-double-contractcheck-engine-double-contract: OK — 377 pinned, 133 in the DEBT ledger, 2 exempt.check:where-matcher✓ where-matcher conformance holds: 276 matcher(s) discoveredcheck:nul-bytescheck-nul-bytes: OK (scanned 6391 text file(s) … no raw ASCII control bytes)check:type-check-debt --re-measureOK — 33 ledger entr(ies) re-measured in 250.0s, 1908 raw tsc error(s) total, none above its recorded numberTwo notes on that table, both about not reading a green as more than it is:
check:type-check-debt --re-measurefirst REFUSED —--re-measure cannot run: 37 workspace dependenc(ies) … have no built type entry point on disk. A refusal is NOTMEASURED, never a pass, so the closure was built exactly as
lint.ymldoes(
turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful)and the gate re-run to the real verdict quoted above. It reports a pre-existing
-12surplus on@objectstack/plugin-auth; that package is untouched here.check:nul-bytesis not in the derived union. Class [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309 — the derivation hasbeen short on every services PR measured today — so it was added on judgement (any
edit can carry a control byte) and run explicitly. It is the only family added beyond
the derived set; nothing else the derivation named was skipped.
Declared narrowings, on the record rather than implied:
SqlDriverwas executed (better-sqlite3, in-memory) inthe reproduction. Postgres and MySQL were not run — no server reachable. This is a
weaker limitation here than it was for External object-draft drops the introspected primary key (isPrimary vs primaryKey seam) #10676: neither defect reads dialect-specific
introspection output at all. The object name derives from the remote table name and
the OWD is a constant, so no dialect can change either verdict.
packages/create-objectstack/bin/create-objectstack.jsappears in the derived changeset:
pnpm installmode-flipped it (100644 → 100755) in this worktree. Not my edit,deliberately left unstaged and out of the commit;
git diff --statconfirms0 insertions, 0 deletions. It only widened the derived gate set (packages/**families), never narrowed it.
os buildacceptance was driven through the harness described above rather thanby spawning the real
os buildbinary against a scaffolded project. The harness runsthe same three stages the command runs —
defineStack(),authoringRulesFor('build')(41 rules, the set
os compileruns, reached the same waypackages/cli/src/utils/scaffold-validate.tsreaches it), andtscover the renderedsource — but it is a reconstruction of that pipeline, not the binary.
Not addressed here
#11000 remains open, as covered above. Also untouched and out of scope: #10997
(SQLite composite-PK truncation in the driver) and #10998 (
introspectSchemaomitsthe contract's
dialect/introspectedAt) — two further findings from the sameintrospection seam, neither of which this change bears on.
Generated by Claude Code