Repository navigation
fix(objectql,runtime): install datasourceMapping before the first schema sync, so a mapped boot keeps one store (#23649) - #23694
Conversation
…ema sync Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
… sync; measured test budget Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
…tasource-mapping-before-sync Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
… pin Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 9 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 — 35 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 b8d878bf4206f9fa9c06ba6140cbbd225a6eb838 && git checkout b8d878bf4206f9fa9c06ba6140cbbd225a6eb838
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f1e21c4f45001dd3c206019359137226197ee659 700a1bf30c344dc31cf5ed668c4e36788347b18b && git checkout -B drift-repro f1e21c4f45001dd3c206019359137226197ee659 && git merge --no-ff 700a1bf30c344dc31cf5ed668c4e36788347b18b
node scripts/docs-audit/affected-docs.mjs --json f1e21c4f45001dd3c206019359137226197ee659
|
Seat review, first pass ·
|
…tasource-mapping-before-sync Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
…d kernel event widen the published surface Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
…in it among the pre-settings-bind hooks The settings bind-window guard derives the hooks fired before the settings bind from their call sites and reads only a literal name; it named the new event as unresolvable. Fired by literal, the event is a pre-bind hook, so it joins PRE_BIND_HOOKS with its fire site, the gate's own prescribed remedy. Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: The lane seat's own review of record. The face is the declaration face:
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | PR → main. The first line is Fixes #23649. A closing-keyword scan of the whole body finds one hit, line 1. |
| Scope | 8 files (+665 / −11): packages/objectql/src/{plugin,index}.ts, packages/runtime/src/app-plugin.ts, the runtime and CLI pins, two gate-script census rows, and the changeset. The cross-lane parts are declared: domain:cli on #6024 (6196575850), and domain:spec on #18883 (6196585005, plus the addendum 6198854597). No content/docs/releases/ edit, no governed path, under the 3,000-line threshold. origin/main f77ba411e is merged in. |
Clause-② |
yes, in the changeset and the PR body. The claim's no predates the export; the seat's first pass 6196599954 ruled yes, and the changeset now ships @objectstack/objectql minor. |
| Contract review | The lane seat's own record at TIER_DEFAULT (the declaration face): 6198867878, PASS at this head. |
| CI | Round 2 fixed the first-round red (Settings bind-window guard) at its root: the fire site is now a literal, and the event has its census row. On this head, Lint & Repo Gates, TypeScript Type Check, Test Core 1–6 and Build Core are green. The body edit re-triggered the changeset and claim guards, and landing waits for them. |
| PR body | Refreshed by the seat from the dev's round-2 text. |
Disposition:
out_of_scope_findings: the sqlite hang is filed as runtime: a declared sqlite datasource whose file cannot be created never settles its connect, so the boot is refused only by a plugin start timeout (30 s, 120 s after PR #23694) that names neither the datasource, the path nor the open error #23696. The other three are Acceptance notes.deviations: accepted, as recorded in the review.
Generated by Claude Code
Fixes #23649
Clause-②: yes
What was wrong
A host config declaring
datasourceMapping: [{ default: true, datasource: 'primary' }]over a declared SQLiteprimarybooted toServer is readyand then answeredno such tableon every platform read.AppPlugin.start()installed the rules (ql.setDatasourceMapping) afterObjectQLPlugin.start()had schema-synced every registered object onto the host's default store, and after the auth plugin had written the Default Organization there. From that moment the engine routed every unbound object toprimary(step 2 ofresolveDatasourceBinding), andprimary's schema had never been synced.The same late install had a second form. The connect gate (ADR-0062 D2 (d)) counted only the app's own objects. A rule that routes only platform objects (
objectPattern: 'sys_*', the spec's own example shape) therefore never connected its datasource at all. Every platform read then failed with "mapped for object ... is not registered".Honour or refuse: measured first
Triage left the choice open, with one guard: if honouring the mapping changes which store a config that boots correctly today resolves to, stop and return the fork. Honouring changes nothing for any such config I could build, so this PR honours the mapping. The readings are below.
Every boot used the built CLI (
bin/run.js) withNODE_ENVunset (production),OS_AUTH_SECRETset andOS_CLOUD_URL=off. Each store was opened read-only after the process exited. "Before" meansorigin/mainat57994c311for the host-config rows. For the rows marked (a), it means this branch with the fix's one subscription line removed andpackages/runtimerebuilt, which makes the boot behave exactly as before the change. "After" means this branch at4c64e2aed.Boots that work today. All of them are unchanged:
OS_DATABASE_URL=file:ctl.dbctl.db72 tables, 1 organizationdefault: trueto the host's owndefaultctl.db72 tables, 1 organizationdefault: truetoprimary, withOS_DATABASE_URLset toprimary's own fileos starton the config-declared default (default: truetoprimary, no URL), which is the shape the shared resolver honours (a)ds-primary.db72 tables, 1 organizationdriver.*service ininit(), plus a declaredarchivewithautoConnect, no mapping, no URLcom.objectstack.driver.sql; plugin store 72 tables, 1 organization;archive0 tablesobjectPattern: 'fx_*'toprimary(the app's own object only), URL setfx_taskrows served fromprimary;ctl.dbalso held an emptyfx_tasktable from the first syncfx_taskonprimaryonly, soctl.dbhas 71 tablesdefault: truetoprimary, production, no URLThe one difference among these is that empty
fx_taskcopy: the first sync no longer creates it in a store the object never routes to. No row moves. The rules were already in force for every write oncestart()had run, and app seeds run after that point.Boots that are broken today:
default: truetoprimary,OS_DATABASE_URL=file:ctl.dbDATABASE_ERROR ... no such table;ctl.db72 tables and the organization,primary13 tables and no organizationprimary71 tables and 1 organization;ctl.dbholdssys_packagesonly (see the acceptance notes)--devDATABASE_ERROR;primary13 tables, no organizationprimary71 tables and 1 organizationos starton the same config withOS_DATABASE_URL=file:ctl.db(a)DATABASE_ERROR; split as in M7primary71 tables and 1 organizationobjectPattern: 'sys_*'toprimary, URL setprimarynever connectedprimary70 tables and 1 organization;fx_taskstays inctl.dbdefault: truetoarchiveDATABASE_ERROR; splitarchive71 tables and 1 organization; the default driver is still the plugin'sobjectPattern: 'sys_*'to a declared postgres that refuses connections (a)[AppPlugin] declared-datasource auto-connect failed for app ...: datasource 'primary': connect failed — connect ECONNREFUSED ... (70 object(s) are routed to it by a datasourceMapping rule ...)The last row is the one boot this change refuses that used to print a banner. The published contract already says so:
content/docs/data-modeling/external-datasources.mdxsays "If adatasourceMappingrule routes an object to a datasource that cannot be connected, the boot fails with the connect error". The publishedskills/objectstack-data/rules/datasources.mdsays the same ("A rule pointing at a datasource that cannot be connected fails the boot"), and so does ADR-0062's #4462 amendment ("adeclared-autofailure is fatal"). The implementation counted only the app's objects, so it never reached that refusal for platform objects. The seat ruled this refusal is the implementation pulled back to the published declaration (review 6196599954, answer A), so it is not a narrowing. The declaration'syesis owed by the widening below, not by this refusal.What changed
Public surface (why the declaration reads
yes).@objectstack/objectqlgains one entry export,OBJECTQL_DRIVERS_READY_HOOK, andObjectQLPlugin.start()triggers one new kernel event,objectql:drivers-ready, which any plugin can subscribe to. The changeset bumps@objectstack/objectqlminorfor it;@objectstack/runtimestayspatch.packages/objectql/src/plugin.ts.ObjectQLPlugin.start()triggersobjectql:drivers-ready(exported asOBJECTQL_DRIVERS_READY_HOOK) once per boot, with the engine as its argument. It fires afterdriver.*adoption andObjectQL.init(), and before the firstinstallRegisteredSchemas, thesys_metadatahydration and any read or write. A throwing handler propagates and fails the boot. The call site spells the name as the literal'objectql:drivers-ready', because the settings bind-window guard derives the hooks fired before the settings bind from their call sites and reads only a literal. The event is such a hook, so it is pinned in that guard'sPRE_BIND_HOOKSwith its fire site (scripts/check-settings-bind-window.mjs, the guard's own prescribed remedy). Its one handler, AppPlugin's, reads no setting. The runtime pin subscribes through the exported constant, so it goes red if the literal and the constant ever differ.packages/runtime/src/app-plugin.ts.AppPlugin.init()subscribes to that event. The handler,honourDatasourceMappingBeforeSchemaSync, installs the rules. It then calls the sameconnectDeclaredthatstart()uses, for every declared datasource the rules route an already-registered object to. That set is asked of the engine's own matcher (resolveMappedObjectsoverql.registry.getAllObjects()), platform objects included. The fail-fast verdict and the error line are the same asstart()'s.start()installs the rules only when nothing announced the pre-sync moment, for example a declaration-only boot that suppresses the engine plugin's start, or a mock context with no hook bus.driver.*services. That is the readings' M9 concern: the engine makes its first registered driver the default, so aninit()-time connect would have taken the default slot from a stack plugin's driver. The M9 rows above show the default driver unchanged.Landing point: the boot order, as triage expected. The announcement lives in
packages/objectql, and the install and connect live in the runtimeAppPlugin, cross-lane on the claim's declared surface.resolveDatasourceBinding's order is unchanged, because the defect was when the rules arrived, not the order.Tests
packages/runtime/src/datasource-mapping-before-schema-sync.test.ts(new, in-process): the served host composition with a real SQL default driver, the engine plugin, a fixture platform plugin that registers an object the app does not declare and writes a row instart()(the Default Organization's slot), the app, and the datasource-admin plugin with the real driver factory. All stores are:memory:. It checks three legs:default: true(the table and the boot row are onprimary, and neither is on the default store),sys_*(primaryis connected and holds the platform object, and the app object stays on the default store), and the no-mapping CONTROL (everything on the default store,primarymetadata-only). Every leg also asserts that the default driver is theDriverPlugin's. Result: 3 passed.packages/cli/test/serve-datasource-mapping-before-sync.integration.test.ts(new, integration tier, run with--project integration): the M7 shape at the public door, two production boots of the built CLI. Mapped: the banner, no "no such table" and no "is not registered",primaryholdssys_organization(1 row),sys_metadata,sys_userandfx_task, andctl.dbhas nosys_organizationand nosys_metadata. CONTROL: everything inctl.db, andds-primary.dbis never created. Result: 2 passed (17.6 s, so it declares atest-budgetof 30).4c64e2aed:@objectstack/runtime389 files and 5862 tests passed (19 skipped), and@objectstack/objectql422 files and 8118 tests passed.origin/main(09dd53719), at18baa1fce, with the CLI closure rebuilt (61 turbo tasks):pnpm --filter @objectstack/objectql run typecheck,pnpm --filter @objectstack/runtime run typecheckandpnpm --filter @objectstack/cli run typecheckeach exit 0; runtime pin 3 passed; CLI pin (--project integration) 2 passed. The probe on the merged build reads the same as above for M7, CONTROL,os starton the config-declared default,sys_*and the unreachablesys_*target. The final commit4b45f9957adds only the child-env census line (see Gates), which neither the typechecks nor the pins read.700a1bf30(origin/mainmerged again atf77ba411e; fix(objectql): keep the metadata service's copy of a runtime-authored object current after boot (MCP stdio exposure gate) #23687 had changedpackages/objectql/src/plugin.tsin its metadata-event bridge, a region disjoint from the start-order lines this PR edits):objectqlrebuilt, and the dist carries the literal fire site once.pnpm --filter @objectstack/objectql run typecheckandpnpm --filter @objectstack/runtime run typecheckexit 0. Runtime pin 3 passed. CLI pin (--project integration) 2 passed.packages/plugins/plugin-auth/src/auth-settings-ordering.pin.test.ts, which names the edited guard, passed. Suites:@objectstack/objectql425 files and 8134 tests passed,@objectstack/runtime389 files and 5866 tests passed (19 skipped).Ablation (negative control). I committed the fix first, then removed the one subscription line in
AppPlugin.init()(ctx.hook(OBJECTQL_DRIVERS_READY_HOOK, ...)) withscripts/ablation-replace.mjs(anchor 1 to 0, blob changed) and rebuiltpackages/runtime.scripts/ablation-dist-preflight.mjs --absentshowed the marker absent from all 6 built files. Results with the line removed:expected [ 'fx_task' ] to include 'sys_fx_ledger'anda datasource only platform objects route to was never connected.a platform read hit an unsynced datasource ... not to contain 'no such table'.The tool restored the line (blob equals HEAD,
git diff HEADempty,git status --porcelainempty). I then rebuilt, the preflight showed the marker present indist/index.js, and both pins passed again.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranat700a1bf30, every command run on that tree with its exit code recorded: 87 derived, 87 run, 0 NOT MEASURED, and all 87 exit 0. In the first pass,check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, unbuilt packages); its re-run after the list measured and passed. CI on700a1bf30: 40 check-runs, 34 success, 6 skipped, none failed;Lint & Repo Gates,TypeScript Type Check,Test Core,Build CoreandGoverned Surface Queue Guardare all green.The red this round fixed. CI on
4b45f9957and3a70801fcfailed one gate,Settings bind-window guard(pnpm check:settings-bind-window). It refused the fire sitectx.trigger(OBJECTQL_DRIVERS_READY_HOOK, this.ql)because it cannot name a hook given through a constant. Locally the gate exits 1 on3a70801fcand 0 onorigin/mainf77ba411e, so the red was this branch's own. Round 1's local derivation did not name that gate: it was not among the 86 families. It derives now because its script is edited. The fix is the literal fire site plus thePRE_BIND_HOOKSrow above. The gate now reads 9 pre-bind hooks fired, all 9 pinned.Census and pin edits in gate scripts, both forced by this change and both the gate's own prescribed remedy:
scripts/check-cli-test-child-env.mjsnames the new production-posturebin/run.jsspawner (eight files to nine; its self-test passes 152 cases), andscripts/check-settings-bind-window.mjspinsobjectql:drivers-ready. Families CI runs beyond this list are read from the check-runs above.Acceptance notes
sys_packagesstays on the default driver. The package registry's table is not an object, andservice-packagedeliberately creates and reads it on the engine's default driver (package-table.tssays why). Under adefault: truerule with a separate URL store, that table is the one thing left in the URL store (M7 after:ctl.dbholdssys_packagesonly). It is read and written consistently there. Noted, not filed. Carrier: none.AppPlugin.start(). Mapping targets now connect atobjectql:drivers-ready, which is earlier and still AppPlugin's code. The ADR's decision text (D2 (d): "a datasource a mapping rule routes at least one registered object to is auto-connected at boot") is what this implements. The note is left alone becausedocs/adr/**is a governed surface. Carrier: none.objectql:drivers-readyis not inIPluginLifecycleEvents. Custom names are legal on the bus, and the registry is inpackages/spec, which is outside this claim. Carrier: none./proc) hangs its connect until the plugin start timeout. Before this change, a mapping-routed one timed out as the app plugin's start (30 s). Now it times out as the engine plugin's start (120 s), because the connect moved into that start. Neither message names the datasource. The hang predates this change. Filed follow-up: runtime: a declared sqlite datasource whose file cannot be created never settles its connect, so the boot is refused only by a plugin start timeout (30 s, 120 s after PR #23694) that names neither the datasource, the path nor the open error #23696.Body refreshed 2026-10-15T18:01Z by
domain:engineseat 1 from the dev's round-2 text (report 6198789360).