Uh oh!
There was an error while loading. Please reload this page.
fix(cli): the composed migrate plan examines the host's declared object set, reports what it could not, and exits - #13057
Conversation
…and says what it could not A declaration-phase host composition runs `init()` and suppresses `start()`. The pass that hands registered objects to their driver — the one that fills `managedObjectFields`, which `detectManagedDrift()` diffs — lives in `ObjectQLPlugin.start()`. A host that brings its own `ObjectQLPlugin` under the framework's own plugin name DISPLACES the standalone one (duplicate registration overwrites by name), so no `ObjectQLPlugin.start()` ran at all: every host plugin declared its objects and none reached a driver. Measured on ObjectStack Cloud's staging control plane: 36 plugins composed, ~80 `sys_*` tables declared, 8 examined — all eight belonging to the one service that provisions its own tables from a `kernel:ready` hook. The composed boot now drives that pass itself over the deferral it already armed (`engine.syncObjectSchema` per declared object, reaching `SqlDriver.initObjects` exactly as the suppressed `start()` would have), and reports what it could not reach: `composition.coverage` on the `--json` payloads, and a refusal to print the unqualified "in sync" line when the plan is partial. `driver-sql`: `initObjects` no longer calls `ensureDatabaseExists()` while DDL is deferred — the one line there that can write, for a phase that runs no DDL. The flush clears the flag before re-entering, so real DDL still ensures first.
…s written Measured on ObjectStack Cloud's staging control plane inside `docker run --rm`: the CLI finished in 4.3s, printed `Graceful shutdown complete`, and the run was cancelled by hand 78 minutes later with the shell still blocked on it. The declaration-phase host composition runs `init()` and replaces `start()` with a no-op, so anything a host plugin armed during Phase 1 whose release would have been installed by Phase 2 has no release path and the event loop never drains. Chasing the handle means auditing host code this repo cannot see — the same argument that made the composition declaration-only. Both commands now exit deliberately after the teardown they already ran, draining stdout/stderr first (a `--json` payload on a pipe must not be truncated) under a bounded wait (a pipe whose reader is gone must not become a second way not to return). Failure paths are unchanged: `this.exit(n)` throws an oclif ExitError that oclif's own handler already turns into a process exit.
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 30 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 27f41fdb33ab64af36e2f25a059a1b08e9739a58 && git checkout 27f41fdb33ab64af36e2f25a059a1b08e9739a58
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df59de0d69130fee44602f4cb1368b07639a886c 557697df64ce61c09df5b64b20ee7bac07526a2c && git checkout -B drift-repro df59de0d69130fee44602f4cb1368b07639a886c && git merge --no-ff 557697df64ce61c09df5b64b20ee7bac07526a2c
node scripts/docs-audit/affected-docs.mjs --json df59de0d69130fee44602f4cb1368b07639a886c
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13028
Fixes#13027
Part of objectstack-ai/cloud#1653
Two consequences of one seam: the declaration-phase host composition #12952 introduced runs
init()and replacesstart()with a no-op. One of them makesos migrate planreport coverage it does not have; the other makes the process never return.Both cards infer, from "36 plugins composed → 8 tables, all messaging", that 35 of 36 host plugins register their objects in
start(). #13028's triage flags that inference as its own confidence gap, and the reviewer correction on #13028 asks for it to be enumerated before anything is chosen. Enumerated — read out of the source on both sides rather than reasoned from the count — it does not hold.Where host plugins actually declare their objects. Every registration goes through
ctx.getService('manifest').register({ … objects … }). In THIS repo, sixteen plugins make such a call and all sixteen make it frominit()—plugin-security(security-plugin.ts:1007, insideasync initat 983,objects: securityObjects),plugin-auth,plugin-audit,plugin-approvals,plugin-sharing,plugin-email,plugin-reports,plugin-webhooks,service-settings,service-storage,service-job,service-queue,service-realtime,service-datasource,service-messaging,platform-objects. Thestart()-phasemanifest.registercalls that do exist carry no objects:apps/studio,apps/setup,apps/accountregister apps and navigation, andmcp/cloud-connectionregister UI bundles.On the consumer side, checked the same way against
objectstack-ai/cloud:organizations(initat 174),service-ai(initat 653) andservice-tenant(initat 46) all declare frominit();app-cloudandapp-cloud-adminregister fromstart()but carryappsanddocs, not objects. Exactly one composed plugin registers an OBJECT fromstart()—security-enterprise's AI-governance plugin (startat 76 →registerManifest,objects: [AiAuditLogObject]), a single object.So the residue is 1, not 35, and dispatch option (c) — "move the registrations to
init()in the consumer repo" — has essentially nothing to move. There is no consumer-side phase migration worth enumerating, and #12952's ACCEPT premise ("zero measured instances") was off by one, not by thirty-five.What was actually missing. A plugin declaring an object in
init()puts it in the ObjectQL registry. What fills the SQL driver'smanagedObjectFields— the mapdetectManagedDrift()diffs the physical schema against, and the thingmanagedTablescounts — is a separate pass:installRegisteredSchemas(), called fromObjectQLPlugin.start()(packages/objectql/src/plugin.ts:683,706). One pass, onestart(), for the whole deployment.And on a real control plane that
start()never runs at all. ObjectStack Cloud'screateControlPlanePlugins()wraps each plugin in alazyPlugin(...)and names the wrapper'com.objectstack.engine.objectql'— the framework's own plugin name, deliberately, so the CLI's capability injector de-dups against it (the preset's own comment: "it MUST EQUAL one of those identities"). Duplicate plugin registration overwrites by name (packages/core/src/plugin-registration.ts), so the host's wrapper displaces the standalone stack'sObjectQLPlugin— andcomposeForDeclarationsthen suppresses the wrapper'sstart(). Net: noObjectQLPlugin.start()in the boot, every host plugin's declarations stranded in a registry, and the driver told about nothing.That predicts the measurement exactly, including the part the cards found strangest — why the 8 were all messaging.
service-messagingis the one service that does not rely on that pass:provisionSystemTables()callsengine.syncObjectSchema()for its own eight objects from akernel:readyhook (messaging-service-plugin.ts:224-238, 352-381), which is what putsys_notification,sys_inbox_message,sys_http_deliveryand the rest intomanagedObjectFieldswhile nothing else got there.Reproduced locally, deterministically. A fixture with that shape — a lazy wrapper around the framework's own
ObjectQLPluginunder the framework's own name, plusSecurityPlugin— on this branch's base:Zero tables, and the green sentence. That is #13028's "one composed plugin away from printing again", printing.⚠️ The plugin names in the fixture are load-bearing: rename
com.objectstack.engine.objectqland the two plugins coexist, bothinit()s run, and the boot dies onService 'objectql' already registered— a different defect.#13028 — the plan examines the declared set, and reports the boundary either way
Dispatch shape (a): the composition drives a narrow, measured-safe registration path. ⛔ Not option (b) — no kernel contract change, no
declarationsOnlyboot mode, nothing added topackages/spec.measureComposedCoverage()callsengine.syncObjectSchema(name)per declared object — the same publicIDataEngineentry pointservice-messagingalready uses — which reachesSqlDriver.initObjectsexactly as the suppressedstart()would have. No host code runs. With DDL deferred,initObjectsregisters the metadata in memory, records the create-table work as PENDING, and returns.deferredis a parameter, not a deduction (Route and surface ownership §2): the same call on a non-deferred boot would take the DDL path and a "plan" would create tables. A non-deferred composed boot reports UNMEASURED coverage instead.driver-sql:initObjectsno longer callsensureDatabaseExists()while DDL is deferred. It is the one line in that method that can WRITE —mkdir -pfor a sqlite parent directory, and on Postgres/MySQL aSELECT 1that CREATEs the database on3D000/ER_BAD_DB_ERROR. Under the deferral every DDL branch is skipped, so there is nothing for a database to exist for; cli/driver-sql:os migrate plan自称 dry-run,却仍会在全新项目上创建空数据库文件(#6469 的残余写副作用) #6743 closed the sqlite half of this one layer up, in the CLI. It is also the cost half: ~80 objects, one call each, would otherwise be ~80 round-trips against a database the command never touches.flushDeferredSchemaDdl()clears the flag before re-entering, so the confirmedos migrate applystill ensures the database ahead of the firstCREATE TABLE— pinned in both directions.Coverage honesty, deliverable independently of the coverage itself (the triage's option C):
plan --jsonandapply --jsoncarrycomposition.coverage:⭐
unexaminedObjectsis the discriminator a consumer gate needs (cloud#1710's):managedTablescannot tell a genuinely small deployment apart from a mostly-unexamined one, and both raise the count above the artifact-less baseline. The counts are of OBJECTS, not plugins — which plugin an object came from is not observable at this seam (a manifest registration carries a package id, not a plugin instance), and inventing that attribution would be a second thing that reads like coverage.When
unexaminedObjectsis above zero the human output refuses the unqualified success line. Measured end to end on a fixture declaring one object on a datasource nothing provides:os migrate applygets the same treatment, including anin_sync_partialmessage on its--jsonpayload where it used to sayin_sync.#12952's byte-pinned artifact-less baseline is unchanged. A project with neither an
objectstack.config.*nor a compiled artifact composes nothing, carries nocompositionkey and diffs the same five tables; its pin passes untouched, and the ablation below leaves it green.#13027 — the process ends when the work does
Measured: 4.3s of work,
Graceful shutdown complete, then 78 minutes of nothing until the run was cancelled by hand. A host plugin that arms something duringinit()whose release would have been installed bystart()has no release path, so the event loop never drains while the kernel reports a clean shutdown.planandapplynow exit deliberately once their document is written, after the kernel teardown they already ran. Chasing the handle would mean auditing host code this repo cannot see — the same argument that made the composition declaration-only in the first place, and the card's own second candidate.exitOneShotCommand()drains stdout and stderr first.process.exiton an undrained pipe truncates: exactly the defectemitJsonexists to prevent, re-introduced one statement later, invisible on a TTY.this.exit(n)throws an oclifExitErrorthat oclif's ownhandle()already turns into aprocess.exit; catching them here to exit "tidily" would swallow the report with them. The body moved into a private method so every earlyreturnon the SUCCESS path funnels through one exit.Consumer acceptance — cloud#1710's stage-2 shapes, on this branch
A fixture carrying both halves at once (lazy-wrapped framework
ObjectQLPluginunder the framework's own name +SecurityPlugin+ a plugin holding a ref'd interval frominit()),os migrate plan --json:Both tables #12938 named by hand are in the plan; the process returns in two seconds. Against the same fixture on the base, the same command reported
Examined 0 managed table(s)andPhysical schema is in sync, and (with the interval fixture) did not return at all.apps/cloud/objectstack.config.ts, and deliberately. That checkout'snode_modules/@objectstack/*symlink into the SHAREDframeworkcheckout, so a run from there would load two different copies of the framework in one process — the CLI's from here, the config's from there. Any number it produced would be about a tree nobody is on. The fixture above reproduces the seam mechanically instead, and its plugin names are copied from cloud's preset.Verification
Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usableflock. The shared verify lock is declared Linux-only (flockis util-linux, and a stock macOS does not ship it), so the commands below were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.Exit codes captured before any pipe; every result quotes the gate's own verdict line. Final head
557697df6, tree clean, and the union below was run on that head.Tests.
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/utils/schema-migrate src/utils/schema-migration-plugins src/utils/one-shot-exit src/commands/migrate test/migrate-plan-exits.e2e.test.ts→Test Files 21 passed (21),Tests 117 passed (117)— that is #12952's whole migrate surface plus the four new suites.pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 src/sql-driver-deferred-ddl*.test.ts src/sql-driver-deferred-datetime-convergence.test.ts→Test Files 3 passed (3),Tests 37 passed (37).pnpm --filter @objectstack/cli typecheck && pnpm --filter @objectstack/driver-sql typecheck: exit 0.Ablations — one per member, disk-proven, absolute paths,
EXIT INT TERMtrap, restored from the COMMITTED implementation. No build step is involved on either leg: vitest resolves these workspace imports to source, and the e2e's child isbin/run-dev.js(tsx oversrc/), so both legs measure the tree on disk.#13028 — the coverage call in
schema-migrate.tsdisarmed:The artifact-less baseline pin and #12938's four host-composition cases stay green through it — the point: they must not depend on this fix. The same ablation, driving the CLI by hand against the cloud-shaped fixture, is what produced the
Examined 0 managed table(s)/Physical schema is in syncreading quoted above. Restore leg:git checkout HEAD -- <abs path>, anchor back to 1 occurrence, injected text to 0,git diff HEAD --statempty, hash back to82c0fc22….#13027 — the deliberate exit in
plan.tsdisarmed:90 seconds and killed, against 2 seconds and exit 0 with the fix. Restore leg proved the same way, hash back to
ae9e5e25….Gates, derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-written path list), all exit 0:check:nul-bytes,check:cli-test-child-env,check:cross-package-test-inputs,check:test-source-alias,check:objectql-double-limit,check:page-declaration-shape,check:slot-lookup,check:published-files,check:type-source-resolution,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:type-check-coverage,check:type-check-debt,check:driver-conformance,check:i18n,check:i18n-coverage, plusscripts/check-adr-0087-registration.mjsandscripts/check-changeset-no-major.mjs. Sample verdict lines: "check-nul-bytes: OK (scanned 7244 text file(s) … no raw ASCII control bytes)." · "check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt." · "check-type-check-coverage --re-measure: OK — 31 ledger entr(ies) re-measured in 65.7s, 1570 raw tsc error(s) total, none above its recorded number." · "check:cli-test-child-env: 37 spawner source(s) … all 43 spawn call(s) declare their child's env."check:type-check-debtfirst refused with--re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk. That isPREREQUISITE NOT MET, not a red gate — it was re-run green afterpnpm exec turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*'(70 successful, 70 total), exactly aslint.ymldoes before that step.Repo-wide
pnpm lintwas RUN, not narrowed:node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config, exit 0, 20s.Out of scope — one verified consumer-side finding, for the PM to file
Not filed from here (it lands in
objectstack-ai/cloud, and this seat files in the repo the fix lands in).Cloud's
lazyPluginwrapper never forwardsdestroy(), so no lazily-wrapped control-plane plugin is ever torn down — in productionos servetoo, not only under the CLI.packages/service-cloud/src/control-plane-preset.tsbuilds each wrapper withinit/start/stop, and thecom.objectstack.driverwrapper below it does the same. The kernel's ONLY teardown entry point isplugin.destroy()—packages/core/src/kernel.ts:718,776andkernel-base.ts:242; nothing inpackages/core/srcever callsplugin.stop(), andmessaging-service-plugin.tscarries the same lesson learned the hard way ("IT USED TO BEstop(), WHICH NOTHING CALLED", #9371). Consequence: roughly twenty control-plane plugins never release anything on shutdown —service-messaging's notification and HTTP dispatchers keep their intervals, the queue and job services keep theirs. Renamingstoptodestroyon both wrappers is the whole fix. It is the cloud-side root of #13027 and is NOT addressed by this PR, which fixes the framework half (the command exits regardless of what the host left running).Generated by Claude Code