Repository navigation
test(runtime): pin the multi-value cascade-delete path on PostgreSQL, not only SQLite - #18732
Conversation
… not only SQLite `cascade-delete-multivalue-lookup-real-driver.integration.test.ts` was the only real-stack pin of the multi-value cascade-delete path, and it hard-coded `client: 'better-sqlite3'`. That single literal is the named mechanism by which #18172 shipped in two published releases: every DELETE of an object targeted by a `multiple: true` reference returned 500 on PostgreSQL, always, and the pin stayed green throughout because SQLite stores the lookup in a TEXT column where the membership lowering and the substring lowering are indistinguishable. The file now declares a driver axis: the embedded SQLite cell, plus a live PostgreSQL cell provisioned by `OS_TEST_POSTGRES_URL`. An unprovisioned cell is a NAMED skip and a failure under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`, so a run that did not measure PostgreSQL says so rather than counting as coverage. The live cell runs in a per-file schema derived from the file's repo-relative path (never a literal — `scripts/check-live-db-isolation.mjs`), recreated per test and dropped afterwards, and opens with a non-vacuity case that asserts the server really is PostgreSQL and that the driver really gave the lookup a JSON column there. `expected-read-refusal-noise.ts` had to move with it: its reason half matched the literal `no such table:`, which is SQLite's phrasing alone, so on any other dialect a fail-soft probe's refusal was never recognised — printed to the log while `silentChannels()` reported the channel silent. It now asks `isMissingTableError` from `@objectstack/types`, the repo's one vocabulary for that question, keeping the "envelope and reason name the same table" property. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
…ntence The first cut of this reached for `isMissingTableError` in `@objectstack/types`, which is the right shape for an error OBJECT and the wrong one here: this module is imported by RELATIVE path out of `trigger-record-change` and `plugin-approvals`, so a bare workspace specifier added to it lands in THEIR vitest resolution domain. Measured — `check:test-source-alias` went red naming `@objectstack/trigger-record-change: NEW unaliased artifact import(s) … @objectstack/types`, and green again on the reverted tree — and the gate's own prescribed repair is an alias in those packages' configs, which is outside this card's file surface and a different defect class from the one it is about. So the reason half becomes the CALLER'S: `MissingTableReason`, defaulting to SQLite's sentence so all eighteen existing call sites are byte-unchanged, with the PostgreSQL spelling exported beside it and the cascade-delete matrix picking per cell through a `Record` keyed on the cell id — a new cell cannot be added without answering the question. Neither half of the predicate is widened: the envelope still pins the table, the reason must still be that dialect's missing-table sentence about that same table. MySQL is deliberately absent — its sentence is database-qualified, so the plain fragment form cannot carry it and no fixture here rigs MySQL. It arrives with the fixture that needs it. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
…scade-delete-pin-postgres-face
📓 Docs Drift CheckThis PR changes 1 package(s): 6 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 — 26 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 e76e3d54a3bcd89ce6bfe48b4f21f3f080fa0262 && git checkout e76e3d54a3bcd89ce6bfe48b4f21f3f080fa0262
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 080688b561417fcbe6f3d28f88bac73a0c06b953 bb71ba4526415b16d2adc1cbb0201ec6a8383b46 && git checkout -B drift-repro 080688b561417fcbe6f3d28f88bac73a0c06b953 && git merge --no-ff bb71ba4526415b16d2adc1cbb0201ec6a8383b46
node scripts/docs-audit/affected-docs.mjs --json 080688b561417fcbe6f3d28f88bac73a0c06b953
|
…ve PostgreSQL URL (objectstack-ai#18772) Fixes objectstack-ai#18734 Clause-②: no ## What moved Two steps into `Temporal Conformance (live PG + MySQL)`, the job that already provisions a health-checked PostgreSQL 16 and already runs driver-sql, the non-SQL temporal backends and metadata-protocol against it. PR objectstack-ai#18732 gave `packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts` a live PostgreSQL cell and **showed it red** before allowing it green (ablation of `SqlDriver.applyJsonMembership`: `6 failed | 9 passed`, all six in the live cell, zero in SQLite, reproducing `operator does not exist: json ~~ text` / `42883` byte for byte). Nothing in CI handed that cell a URL, so it reported itself un-run on every job. "There is a pin that can go red" and "that pin will be run" are two different claims, and only the first was true. **The two steps are byte-identical to the derivation in PR objectstack-ai#18732's body** — same md5 over the ten lines, not retyped from memory and not re-derived here. The only thing this PR adds around them is the comment block that records why they exist and what must not be done to them. ## The positional is a SUBSTRING, not a glob — measured on THIS file The card and objectstack-ai#18732 both carry this reading from metadata-protocol. It was re-measured here, on the file this step actually selects, rather than carried over: | form | reading | |:--|:--| | `cascade-delete-multivalue-lookup-real-driver` (the substring, as shipped) | 1 test file selected — `Test Files 1 passed (1)`, `Tests 7 passed \| 1 skipped (8)` | | `src/cascade-delete-*.integration.test.ts` (the "improvement") | **`No test files found, exiting with code 1`** | ⇒ The step runs tests, stated as a **count and not an exit code**: **8 tests across 1 file**, 7 of them the SQLite cell and 1 the live cell naming itself as un-provisioned. On CI, where the URL is supplied, that cell expands to the 8 live tests objectstack-ai#18732 measured, for 15 total. `packages/runtime`'s vitest config also runs `runFilterPreflight` (objectstack-ai#17853/objectstack-ai#17978), which speaks up when a named path selects nothing — a second net, and the comment says explicitly that it is not a licence to change the form. ## Both env lines are load-bearing — positive control `OS_EXPECT_LIVE_DIALECT_MATRIX: '1'` with the URL withheld: ``` FAIL |local| ... (live postgres) > is provisioned — set OS_TEST_POSTGRES_URL to run this cell AssertionError: OS_TEST_POSTGRES_URL is unset while OS_EXPECT_LIVE_DIALECT_MATRIX=1: this runner declared it provisions a live server, so the live postgres cell ... must not be skipped. Tests 1 failed | 7 passed (8) ``` ⇒ If the URL line is ever dropped while the flag stays, this step reds instead of quietly degrading to SQLite-only coverage. That is the same vacuous-pass guard the driver-sql and metadata-protocol legs above already carry, and it is scoped to the STEP rather than the job for the same reason theirs is: it asserts "the runner provisioned what THIS step needs", so it stays honest if a later step's server is ever dropped. The card listed the step-versus-job question as Not measured; this is the answer and the reason. ## Job wall-clock — the cost on every PR, priced The seat asked to hear this plainly rather than discover it later. It is **not** four minutes. | reading | value | |:--|:--| | the job today, run 35263914432 (`95b21b33b`) | **345s (5.8 min)** | | the job today, run 35262334045 (`09e16a574`, colder cache) | **432s (7.2 min)** | | its `timeout-minutes` | **30** | | build tasks in `@objectstack/runtime...` | 31 | | build tasks the job's three existing legs already cover | 18 | | ⇒ **packages this build step newly adds** | **13** | | the new build step, warm Turbo cache | **71ms — `FULL TURBO`, 30/30 cached** | | the new build step, fully cold on this container | 4m20s for all 30 tasks (only 1 cached) | | the new test step, SQLite-only shape | **8.00s** | The warm figure is the common path, and it is what CI actually shows: in run 35263914432 all three existing build steps took 0–1s, because this job restores the Turbo cache from the Build Core namespace, which builds every package. The cold figure bounds the other end — and note the 4m20s is for the **whole 30-task closure**, of which 18 tasks are ones the job already pays for today; the marginal 13 sit inside the band CI's own colder run shows for the sibling steps (driver-sql's closure 106s, the non-SQL one 42s). ⇒ **Honest estimate: a few seconds on the common path, roughly 1–2 minutes when the Turbo cache misses**, against a job that runs 5.8–7.2 min with about 23 minutes of headroom under its own timeout. No job name and no matrix shape changed, so the merge-queue required set is untouched (`check:required-contexts` green). ## Scope and grading - **Clause-②: no** — measured, not assumed. The diff is one file. Predicate: is a changed path inside any workspace package's shipping set (package dir joined with each entry of its `files[]`, private packages excluded)? Enumerated **217 shipping roots** across the workspace; `.github/workflows/ci.yml` is inside **0** of them. Positive control on the same predicate: `packages/adapters/hono/dist/index.js` resolves to 1 package (`@objectstack/hono`). No accept set moves, no schema, no exported symbol, no door. - **skip-changeset** — same measurement, and it is also this workflow's own prescription: `pr-automation.yml` lists `.github/` under "It releases nothing ... apply the `skip-changeset` label. PREFERRED". - Not a governed surface: `scripts/pm/check-governed-merges.mjs --test .github/workflows/ci.yml` exits **0**, 0 of 1 path hits the register. - `needs:contract-review` is the seat's label; this PR neither hangs nor removes it. ## Verification Base `95b21b33be`; final commit measured below. Heavy runs through `scripts/pm/os-verify-lock.sh`. | what | reading | |:--|:--| | derived gate families (`scripts/pm/dispatch-gates.mjs --repo ... --ran`) | **44 derived, 44 accounted — 42 green, 2 NOT MEASURED** | | — of those, the workflow-reading ones | `check:ci-filter-parity`, `check:required-contexts`, `check:workflow-step-name-quoting`, `check:workflow-status-functions`, `check:step-collectors`, `check:self-test-workflow-commands`, `check:stall-guard-budget`, `check:stall-guard-headroom`, `check:pnpm-filter-targets` — all **green** | | `pnpm check:nul-bytes` | green (8835 files); plus a direct control-byte scan of the diff — 0 hits | | workflow parses | `yaml.parse` — steps land in job `temporal-conformance`, 16 steps, env and run bodies as intended | | the two steps vs PR objectstack-ai#18732's derivation | **md5 identical** (`26e85b39...`) | | eslint | the changed file is **outside eslint's population** — `isPathIgnored('.github/workflows/ci.yml') = true`, 1 result object with 0 errors (the ignored-file warning); positive control `eslint.config.mjs` returns `isPathIgnored = false`. `eslint.config.mjs` is not in this diff, so no untouched file's verdict can move. The repo-wide scan is CI's run, not one this PR narrows away from. | The 2 NOT MEASURED, named: - `pnpm check:dual-build-cjs-loads` — **exit 3, `PREREQUISITE NOT MET`**: it reads built output for ~30 packages this worktree never built and says so itself ("This is NOT a pass: nothing was measured"). CI builds the repo and measures it there. - `pnpm check:pm-dispatch-gates` — **exit 124**, killed by this container's foreground wall at 540s. Not a refusal and not a red: **1768 self-test assertions printed green and zero failed** before the kill. CI measures it. ## Acceptance notes -⚠️ **A stray remote branch this round created and could not delete: `claude/issue-18734-workflow-scope-probe`** (commit `f22c6321`, a throwaway comment appended to `ci.yml`, no PR). The dispatch required proving a `.github/workflows/**` push is accepted before spending the round, and it is — but **deleting the probe afterwards is blocked on both channels**: `git push origin --delete` returns `RPC failed; HTTP 403` from the egress proxy, and `DELETE /repos/.../git/refs/heads/...` returns `403 {"message":"Write access to this GitHub API path is not permitted through this proxy."}`. It needs a hand with delete rights. The lesson is cheap and worth writing down: **probe on the branch you are going to keep**, because a probe branch is easier to create than to remove here. - The **29 other** `better-sqlite3` literals under `packages/runtime/src/` — **noted, not filed**, unchanged from objectstack-ai#18732's reading: none carries a shipped dialect-specific defect, and this card explicitly does not reopen that. Carrier: whichever card next names a dialect-specific defect on one of those paths. - Nothing here repairs anything the newly-powered cell may turn red. If it goes red on this PR's own CI run, that is a finding for the seat, not a repair to smuggle into a workflow PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18617
Clause-②: no
What moved
packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.tswas the only real-stack pin of the multi-value cascade-delete path — ObjectQL over a realSqlDriverthrough the protocol data-plane delete — and it hard-codedclient: 'better-sqlite3'. That literal is the named mechanism by which #18172 reached two published releases: every DELETE of an object targeted by amultiple: truereference answered 500 on PostgreSQL, always, and this pin stayed green throughout.The file now declares a driver axis: the embedded SQLite cell plus a live PostgreSQL cell provisioned by
OS_TEST_POSTGRES_URL. Everything below the onenewDriver()seam is written once and measured on both.packages/runtime/src/expected-read-refusal-noise.tshad to move with it, and that is declared here rather than buried — see In-place repair below.The measurement — this pin was shown to go RED before it was allowed to be green
The acceptance bar was not coverage. Ablation:
SqlDriver.applyJsonMembershipmade to answerfalseunconditionally behind a runtime-evaluated marker, so the cascade probe's$containsfalls through to the pre-#17590 substring emitter. driver-sql rebuilt each leg (runtime's tests resolve@objectstack/driver-sqlthroughexports, i.e.dist/), andscripts/ablation-dist-preflight.mjsused to prove the mutation reached the artifact before any colour was read.The fault reproduced byte for byte, on a live PostgreSQL 16.13 with
timezone=Asia/Shanghai:That asymmetry is the whole card. A
multiple: truelookup is a TEXT column on SQLite and a realjsoncolumn on PostgreSQL — measured throughinformation_schemaon the live server, and now asserted as this cell's non-vacuity guard. On TEXT the membership lowering and the substring lowering are indistinguishable, so no assertion written against SQLite can separate them. Only the live cell can, and now it does.Without a server
The unprovisioned cell is a NAMED skip that says which variable would run it, never a silent omission — the suite is emitted either way, because a cell that emits nothing at all is not a skip and vitest's summary counts would read as coverage. Under
OS_EXPECT_LIVE_DIALECT_MATRIX=1the missing URL is a failure instead, so a runner that declared it provisioned a server cannot quietly degrade to SQLite-only. Measured: the full@objectstack/runtimesuite without a URL reports3661 passed | 1 skipped, and that one skip is this cell naming itself.Isolation
The live cell owns a PostgreSQL schema named from this file's repo-relative path (
os_lv_cascade_delete_multivalue_lookup_r_b7005a0a8835), recreated per test and dropped inafterAll. Object names here are short and generic (zz_account,zz_guard) and CI provisions ONE server for every live leg, so a shared schema would not be contention but destruction. Same derivation as driver-sql'slive-dialect-matrix.testkit.tsand metadata-protocol'slive-mysql-database.testkit.ts— sameos_lv_prefix, same 34-character slug cap, same 12 hex of sha256 over the workspace-relative path — so the copies stay jointly injective on that one server.pnpm check:live-db-isolationgreen.In-place repair, declared: the noise capture recognised only SQLite
expected-read-refusal-noise.tsmatched its reason half against the literalno such table: TABLE_NAME, which is SQLite's phrasing and only SQLite's. On PostgreSQL the driver writesrelation "sys_organization" does not exist, so the fail-soft tenancy probe's refusal was never recognised: the capture printed the noise it exists to withhold ANDsilentChannels()reported the channel silent. Measured — all eight cases of the live cell red on that assertion, with the driver's(42P01)line sitting in the log immediately above it. Relaxing the pin is explicitly forbidden by its own docblock, and the pin was not wrong; the recogniser was SQLite-shaped.So the reason half became the caller's:
MissingTableReason, defaulting to SQLite's sentence so all eighteen existing call sites are byte-unchanged, with the PostgreSQL spelling exported beside it and this matrix picking per cell through aRecordkeyed on the cell id — a new cell cannot be added without answering the question. Neither half is widened: the envelope still pins the table positively and the reason must still be that dialect's missing-table sentence about that same table, so a permission denial, a dropped connection or a syntax fault still fails it on every dialect.isMissingTableErrorin@objectstack/typesinstead, and that was measured and withdrawn. This module is imported by RELATIVE path out oftrigger-record-changeandplugin-approvals, so a bare workspace specifier added to it lands in THEIR resolution domain:check:test-source-aliaswent red naming@objectstack/trigger-record-change: NEW unaliased artifact import(s) ... @objectstack/types, and green again on the reverted tree (controlled revert, both directions run). The gate's prescribed repair is an alias in those packages' vitest configs — outside this card's file surface and a different defect class — so the design changed instead of the surface.MySQL is deliberately absent from that vocabulary: its sentence is database-qualified (
Table 'db.t' doesn't exist), the plain fragment form cannot carry it, and no fixture here rigs MySQL. Declaring an entry nothing exercises is the declared-not-enforced trap. It arrives with the fixture that needs it.Not done here: the CI leg (outside this card's file surface)
Temporal Conformance (live PG + MySQL)job runs driver-sql, the non-SQL temporal backends, and metadata-protocol. It does NOT run@objectstack/runtime, so nothing in CI hands this cell a URL and the cell will report itself un-run on every job until that changes. The honest wiring is two steps in.github/workflows/ci.yml, in the job that already provisions the postgres service:The positional is a SUBSTRING, not a glob — the same reading the metadata-protocol step records for itself, where the glob form matched zero files.
.github/workflows/**is outside the declared surface for this card, so no workflow is edited here; this is reported for the seat to place.Does this fold into #18200?
Measured, and the reading is no. #18200 is about WHICH FILES a local green covers — a
driver-sqlrun blind to 11 of its 188 files. This card is about WHICH DIALECT one file runs on. Running all 188 driver-sql files would not have caught #18172, because the only real-stack pin of the cascade path does not live in driver-sql at all; it lives inpackages/runtimeand it ran, green, on the one dialect that cannot fail. Two different mechanisms with the same symptom. #18200 is not addressed here and remains open.Scope and grading
packages/runtime/src/and neither is reachable fromsrc/index.ts, which is tsup's single entry. Grepping the built artifact across the package's wholefiles[](dist,README.md,CHANGELOG.md):captureExpectedReadRefusals,POSTGRES_MISSING_TABLE_REASON,MissingTableReason,zz_field_zoo,os_lv_,OS_TEST_POSTGRES_URL— 0 files each. Positive control on the same built tree:AppPlugin6 files,DriverPlugin4. Nothing published moves.needs:contract-reviewis the seat's label; this PR neither hangs nor removes it.Verification
Run on
bb71ba4526(this branch merged withorigin/mainthroughscripts/pm/os-regen-merge.sh), every heavy run throughscripts/pm/os-verify-lock.sh.@objectstack/runtimesuite (no PG URL)3661 passed | 1 skippedOS_EXPECT_LIVE_DIALECT_MATRIX=115 passed (15)— 7 SQLite, 8 live postgres6 failed | 9 passed— all six in the live cell, none in SQLite@objectstack/runtime typecheck@objectstack/trigger-record-change test101 passed (101)— cross-package consumer of the changed helper@objectstack/plugin-approvals test764 passed (764)— same@objectstack/spec check:generatedscripts/pm/dispatch-gates.mjs)pnpm check:live-db-isolationeslint . --no-inline-configbb71ba4526pnpm check:dual-build-cjs-loadsis the one NOT MEASURED: it exits 3 withPREREQUISITE NOT METbecause it reads built output for ~30 packages this worktree never built. That is not a pass and not a finding; CI builds the repo and measures it there.Acceptance notes
Observations found on the way, filed nowhere and fixed nowhere, per Prime Directive #10:
packages/runtime/src/also hard-codeclient: 'better-sqlite3'. noted, not filed — none of them carries [finding] The only real-stack pin of the multi-value cascade-delete path hard-codesbetter-sqlite3— the named mechanism by which #18172 (p1, PostgreSQL) shipped in two releases #18617's receipt (a dialect-specific defect that shipped), most pin behaviour with no dialect axis at all, and converting them wholesale would be exactly the scope expansion the directive forbids. The carrier for the next one is whichever card names a dialect-specific defect on that path.expected-read-refusal-noise.tsis imported by RELATIVE path from two packages outsidepackages/runtime(trigger-record-change/src/record-change-integration.test.ts,plugin-approvals/src/status-mirror-cascade.integration.test.ts), which is what makes any bare workspace import added to it a change to their resolution domains. noted, not filed — it is a coupling, not a defect, and it is now recorded in the module's own header where the next author will meet it.Generated by Claude Code