Repository navigation
fix(driver-sql): a SQLite database whose header SQLite cannot read, and a dangling-link directory on the boot that writes, fail connect() naming the file - #25080
Conversation
…, and a dangling-link directory on the writing boot WIP checkpoint: SQLITE_CORRUPT joins SQLITE_FILE_REFUSALS and the step-down's file-failure codes; makeDatabaseDirectory refuses a level a dangling symbolic link holds, as its read-only twin does; the read-only probe follows a dangling link that is the file itself to the directory the writing boot would create it in. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…the dangling-link directory on the writing boot, and the dangling file link on the read-only boot Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…Lite file-failure family Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…lite-file-failure-family
📓 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 — 13 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 d7f4667d1dbe9a8c17925d88d2ddd074aa449962 && git checkout d7f4667d1dbe9a8c17925d88d2ddd074aa449962
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b1382a5b3887e4abd194411ef77a7cd368c95c2 cc15060f7e5bb2ff2e1efc905688bc9f0dc83362 && git checkout -B drift-repro 2b1382a5b3887e4abd194411ef77a7cd368c95c2 && git merge --no-ff cc15060f7e5bb2ff2e1efc905688bc9f0dc83362
node scripts/docs-audit/affected-docs.mjs --json 2b1382a5b3887e4abd194411ef77a7cd368c95c2
|
Contract reviewServed-tier: Inputs: card #25019 (its body and comments 6305577138, 6305864303, 6307297304, 6310958104, 6311031589); PR #25006 and PR #25007 (bodies) with their contract-review records 6305645762 and 6305566864; PR #25080 (body, 6-file list, and the net diff Check-runs on the head, exactly as read (48 runs): 38 ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each named right or wrong:
(a) Classifying (b) The dangling-link refusals: right. Directory: the writing boot refuses exactly the levels the read-only boot refuses (the same upward walk from (c) Every caller whose answer flips: no FAIL item. No caller crashes, swallows a refusal into success, or renames or replaces an operator's file. The census: outside tests the tree holds no direct
(d) The refusal envelope: right. Each refusal is
② Semver levelClause-②: no (narrowing)
③ Boundary flagsEvery dev deviation and out-of-scope finding in 6310958104, answered or escalated; the member-3 fork is answered by the seat's ruling 6311031589.
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对Seat review ·
Generated by Claude Code |
Part of #25019
The card keyword above is the seat's: member 3 (the wasm quarantine) stays on #25019 under the seat's ruling 6311031589 (route A, inherited from #24960), dispatched on the card after this PR lands, so this merge does not close the card.
Clause-②: no (narrowing)
Narrowing face: three answers that read success now refuse, each naming the file.
SqlDriver.connect()rejects a SQLite database whose header or schema SQLite cannot read (SQLITE_CORRUPTat the first statement), and a path whose directory is a dangling symbolic link (ENOENT), where both used to resolve. The read-only boot (sqliteAbsentFile: 'empty-in-memory') rejects a dangling symbolic link as the file itself when the directory its chain of links ends in does not exist, where it opened an empty in-memory database and readconnected. Nothing exported changes shape. It owes aCONTRACT_REVIEW_TIERreview before the queue (the claim's line).Dev session
session_01QK6pdbxKZiHdFApL8RYVh9(amode:subagentrun), dispatched by thedomain:engineseat 2 under claim 6307297304. This is the SQLite file-failure family's closing card. It builds on PR #25006 (SQLITE_NOTADB) and PR #25007 (the read-only boot's unopenable path), both onmain.What changed
SQLITE_CORRUPT.packages/drivers/driver-sql/src/sql-driver.ts, only theapplyAutoVacuumIncrementalcomment andSQLITE_FILE_REFUSALS: one more row,SQLITE_CORRUPTwith the words "is damaged (SQLite cannot read its header or its schema), and was left as it is".packages/services/service-datasource/src/sqlite-driver-fallback.ts:isSqliteFileOpenFailureadds the same code, with its doc list and the module note (the cross-lane line declared todomain:serviceson the claim).packages/drivers/driver-sql/src/sqlite-database-file.ts,makeDatabaseDirectory: the walk now asks the read-only twin's ownisDanglingLinkat each levelstatanswersENOENTfor, and refuses that level withstat's error ascodeand the link named. Before, the level went on the "missing" list,mkdiransweredEEXIST, and the creating loop accepted that as a concurrent creator. Both twins now print the link through one helper (danglingLinkWords), so their words cannot drift.ensureDatabaseExistsinsql-driver.tsis untouched: it wraps what the walk throws, as it already did.SQLITE_CANTOPEN.probeAbsentSqliteDatabaseFilenow follows a dangling link that is the file to the end of its chain (danglingLinkEnd, reading each relative target from the real directory of the link that holds it, as the kernel does) and judges that directory without making it: it must exist, and allow write and search. Otherwise'absent', as before.driver-sqlite-wasm's quarantine does, so it is the card's fork, reported to the seat with the four-axis analysis. No line ofpackages/drivers/driver-sqlite-wasmchanged..changeset/25019-sqlite-file-failure-family.md:@objectstack/driver-sqland@objectstack/service-datasourceminor, with theClause-②line, the BREAKING banner (Before, Now, Fix) and the ADR-0087 dispositionnot-required (no-migration-prescription), as PR fix(driver-sql): a SQLite file that is not a database fails connect() at once, naming it, and a dev boot no longer renames and replaces it #25006's changeset does.packages/drivers/driver-sql/src/sql-driver-25019-sqlite-damaged-file-and-dangling-link.test.ts(relativesrcimport) andpackages/services/service-datasource/src/__tests__/sqlite-driver-fallback-damaged-file-and-dangling-link.test.ts(resolves@objectstack/driver-sqlthroughdist/, an existingKNOWN_UNALIASED_TEST_IMPORTSpair).Premise, measured on base
d68aab6fd(better-sqlite3 13.0.3, SQLite 3.53.4, Node v22.22.0)H1 holds, and both shapes were measured. Statement by statement, through better-sqlite3 directly, on a database written and then damaged:
PRAGMA auto_vacuum)SELECT 1connect()on baseSQLITE_NOTADBSQLITE_NOTADBSQLITE_CORRUPTSQLITE_CORRUPTSQLITE_CORRUPTSQLITE_CORRUPTSQLITE_CORRUPTSQLITE_CORRUPTSQLITE_CORRUPTSQLITE_CORRUPTSQLITE_CORRUPTSo
SQLITE_CORRUPTat the FIRST statement is a verdict on the whole file: every statement answers the same code,SELECT 1included, and nothing can be read. A database damaged deeper inside answers the first statement and connects, and its damage surfaces on the read that reaches it. The refusal is worded for the first kind ("SQLite cannot read its header or its schema"), and a control pins the second (it still connects). No extended code (SQLITE_CORRUPT_*) appeared at the first statement. Nothing in the tree readsSQLITE_CORRUPTor "disk image is malformed" outsidedriver-sqlite-wasmand two unrelated REST and runtime fixtures.H2 holds. On base, the writing boot over
link/archive.db(a dangling link) resolvedconnect(), then the first statement failed with a code-lessTypeError("Cannot open database because the directory does not exist"). Overlink/sub/archive.dbit already refused (ENOENTfrommkdir), without naming the link. The read-only boot refused both naming the link. After the change, both boots answerSqliteDatabaseFileError,code: 'ENOENT', naming the file and the link, at both depths. Their words differ by one verb ("could not be made" on the writing boot, "cannot be made" on the read-only one), as they already did for every other shape. A directory that is a link to an existing directory still connects and creates the file in it.The dangling link as the file. Base and after, both boots:
archive.dbis a link totarget.dbin a directory that exists (absolute or relative target, direct or through a second link)target.dbmissing/target.db(direct, or through a second link)SQLITE_CANTOPENconnectedENOENT, naming the targetSQLITE_CANTOPENELOOPELOOP(unchanged)Nothing on disk changed in any read-only run.
H3, member 3. With the #24950 suite's load-failure seam (
acquireRawConnectionanswering a realERR_DLOPEN_FAILED), developmentresolveSqliteDriverover a plain-text file and over a header-truncated database, after this change:SQLITE_NOTADB,SQLITE_CORRUPT), the file untouched;sqlite-wasm, anddata/holdsarchive.db(an empty database) andarchive.db.corrupt-TIMESTAMP;The rename happens in
driver-sqlite-wasm,WasmSqliteConnection.open()(wasm-connection.ts), which callsquarantineCorruptFilewhennew SQL.Database(bytes)orPRAGMA quick_check(1)fails. The wasm driver takes no option that could say "refuse";resolveSqliteDriver's wasm rung also catches every wasm failure and goes on to the in-memory rung. A second door reaches the same quarantine with no step-down at all: a datasource declareddriver: 'sqlite-wasm'over a plain-text file, throughDatasourceConnectionService.connectDeclaredon the builtdist/, was renamed and readavailablein development AND in production. Every in-tree construction that reaches the quarantine is a declared datasource: thedefaultdatasource is one since #3826 (the CLI and the standalone stack hand it toDefaultDatasourcePlugin), so "the dev server's own self-heal" and "a declared datasource" are one population. The self-heal was written for torn images, which wasm's atomic flush (atomicWriteFile, write thenrename) has since closed. The fork and its options are in the report to the seat.Evidence
Public door, the card's own (
bin/run-dev.js migrate apply --yesthroughtsx, a throwaway project whoseobjectstack.config.tsdeclaresarchive(driver: 'sqlite') and bindsdoor_noteto it), afterturbo run build --filter='@objectstack/cli^...'(60 of 60) on the merge headcc15060f7:archive.db(a database this driver wrote, cut to 50 bytes):datasource 'archive': connect failed — SQLite database file 'PROJECT/data/archive.db' is damaged (SQLite cannot read its header or its schema), and was left as it is: PRAGMA auto_vacuum - database disk image is malformed. (1 object(s) bind to it explicitly (door_note) …), exit 1. The md5 is unchanged (dc1e7bea…before and after), anddata/holdsarchive.dbalone. No step-down warning printed.data/linkpointing at a path that does not exist):connect failed — SQLite database file 'PROJECT/data/link/archive.db' cannot be created: its directory 'PROJECT/data/link' could not be made: 'PROJECT/data/link' is a symbolic link to a path that does not exist: ENOENT …, exit 1. No "native better-sqlite3 unavailable" line, no wasm line; the link still dangles and its target was not created.datasource 'archive': connected (driver=sqlite, schemaMode=managed), exit 0, not renamed.archive.db.corrupt-1792539192447with an empty database in its place and readconnected; the dangling-link directory readconnectedunder the false native-addon warning.Pins (both files run inside the whole-package suites on the final head
cc15060f7, underos-verify-lock):SqliteDatabaseFileError,codeSQLITE_CORRUPT,filename, a message naming the file and carrying the cause, underNODE_ENV=developmentandproduction; a database cut to its first page, the same. Each asserts no.corrupt-*sibling while the refused connection is open, then, closed, the same bytes and a directory holding only that file. The header still says WAL, so SQLite keeps its-shmand an empty-walwhile that connection is open, and removes both when it closes. CONTROL: a database damaged deeper inside connects, and the read of the damaged table fails withSQLITE_CORRUPTas its cause. The dangling-link directory refused on the writing boot (ENOENT, file and link named) with the read-only boot's answer for the same path asserted beside it (class,code,filename, the link words), at both depths, the link still dangling and nothing created. CONTROLS: a linked directory that exists connects; an absent file under makeable directories connects. The dangling file link: refused by the read-only boot (ENOENT, the target named), direct and through a second link, with the writing boot'sSQLITE_CANTOPENasserted beside it and nothing written; CONTROL: with the target's directory present, the read-only boot reads it absent (:memory:) and writes nothing, and the writing boot creates the target through the link (absolute and relative targets).resolveSqliteDriverover the header-truncated database rejects with the driver's refusal, warns nothing, and leaves the file and its directory as they were; production returns native unprobed and itsconnect()refuses;DatasourceConnectionService.connectDeclaredreportsavailability: 'failed'with a reason naming the file and registers no driver, in development and production, and moves nothing aside. The dangling-link directory: developmentresolveSqliteDriverrejects (ENOENT, file and link named) with no warning, and the read-only resolve over the same path gives the same class, code, file and words;connectDeclaredreportsfailednaming the file and the link, on the writing and the read-only factory, in development and production; nothing created. CONTROLS: a real database opens native and answers; an absent file in a makeable directory opens native and is created; a native addon that cannot load over a real database still steps down to wasm SQLite, withNATIVE_SQLITE_WASM_FALLBACK_WARNING, and reads the same rows, not renamed.cc15060f7(re-run after the worker restart, on a fullturbo run build, 77 of 77): driver-sql "Test Files 227 passed | 12 skipped (239) · Tests 3712 passed | 210 skipped (3922)" (the skips are the live PG/MySQL cells, not provisioned here); service-datasource "Test Files 53 passed (53) · Tests 930 passed (930)".tsc --noEmitexit 0 for both;tsc --listFilesincludes each new test file (1 hit each).Ablation (
scripts/ablation-replace.mjsWRAP, each leg underos-verify-lock; every restore proven by blob equality withHEAD, an emptygit diff HEAD, and an emptygit status --porcelain; at281d27bdf; the only later change to these files isf7ea1c503, a type annotation on two rows the damaged-page control reads):['SQLITE_CORRUPT', 'is damagedbecame['ABLATION_25019_CORRUPT', 'is damaged: anchor 1 to 0, blob778b08f36ba6to7048da65440a. driver-sql rebuilt;ablation-dist-preflight.mjsfound the marker in 2 built files. Predicted and observed: driver-sql "3 failed | 7 passed" (the two header-truncated cases and the first-page case; the controls and both link blocks green); service-datasource "4 failed | 6 passed" (development resolved tosqlite-wasm; the production refusal wasundefined; bothconnectDeclaredcases readavailable). Restored, rebuilt; preflight--absentfound the marker in none of the 6 built files, tree clean; both pin files 10/10.|| code === 'SQLITE_CORRUPT'became|| code === 'ABLATION_25019_SD': blob6e42085d4512to4e4570fda7a2. A relativesrcsubject, so no dist step. Predicted and observed: service-datasource "2 failed | 8 passed" (the two development cases; production, which the driver alone carries, green).if (isDanglingLink(level)) throw danglingLinkRefusal(level, e);was deleted: blobadf3e6b62148to8c7be7a55c8d. driver-sql rebuilt; preflight--absentondanglingLinkRefusal(level, e)found it in none of the built files (the same marker read present in 2 on the pristine build first). Predicted and observed: driver-sql "2 failed | 8 passed" (both depths: the first resolved, the second refused without the link's words); service-datasource "3 failed | 7 passed" (development resolve, andconnectDeclaredin both modes). Restored, rebuilt; preflight found the guard back in 2 built files, tree clean; both pin files 10/10.const linkEnd = danglingLinkEnd(filename);becameconst linkEnd = undefined as string | undefined; // ABLATION_25019_FILELINK: blobadf3e6b62148toad84f6bf25a0. Relativesrcsubject. Predicted and observed: driver-sql "1 failed | 9 passed" (the refusal; its control green).Driver ledger (
pnpm check:driver-conformance): before, atd68aab6fd: "55 covered cell(s), 0 in the DEBT ledger, 0 exempt"; dialect axis 9 suites, 8 matrix and 1 named, 0 in the DIALECT ledger. After, atcc15060f7: the same counts. The ledger did not move.Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) atcc15060f7derived 72 commands. I ran each one once, sequentially, with its exit captured before any pipe. 70 exited 0 on the first pass. Two answered exit 3 (PREREQUISITE NOT MET) and were re-run on the same head after their prerequisite:check:dual-build-cjs-loadsneeded every package'sdist/(a fullturbo run build, 77 of 77), andcheck:type-check-debtneeded a turn on the verify lock for its closure build ("1 ledger entr(ies) re-measured … none above its recorded number"). Both then exited 0.--ranreconciliation: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN". No exported symbol was renamed or removed, socheck-platform-checklist.mjs --changed-fromwas not owed. CI owns the full farm andtest:repo.Dogfood battery (owed: the
Clause-②line carries the(narrowing)arm): running oncc15060f7underos-verify-lockas this PR opens; its verdict line and counts are in the dev report on the card.Acceptance notes
driver: 'sqlite-wasm'over such a file is renamed and readsavailablein development and production. Both live inWasmSqliteConnection.open()'s quarantine. The options and their four-axis analysis are in this PR's dev report on the card; this PR changes nothing there.DatasourceConnectionServicedoes not disconnect a handle whoseconnect()rejected, andconnect()does not close its own pool when it refuses the file. For a damaged file whose header says WAL, SQLite therefore keeps-shmand an empty-walbeside the file for as long as the process lives (measured at the unit level; the development path closes the probe and leaves nothing). The operator's bytes are not touched. It predates this PR (aSQLITE_NOTADBfile holds the same open handle, without visible siblings) and is outside this claim's file surface. Observation, not filed; carrier: none.packages/cli/src/utils/telemetry-datasource.ts) still catches every throw, so a damaged telemetry sibling is now neither renamed nor reported. That is its pre-existing best-effort shape (family note 6305864303); out of the claim's scope.SQLITE_CANTOPEN("unable to open database file"), which names the file but not the link. The read-only boot names the target. Both refuse; the words were left as SQLite gives them.mainwas merged at157ebd008(merge headcc15060f7). It brought eight commits touching spec, objectql, plugin-security, core, rest, lint, cli and docs, none of this diff's packages; the closure was rebuilt and both whole packages and the pins re-run on the merge head. The worker restarted during the first full build; that run's readings were discarded and the build, both suites and the two prerequisite gates re-run on the same head.test:repo; this diff touches norepo-project test.Generated by Claude Code