Repository navigation
fix(driver-sql): a read-only boot refuses a SQLite datasource whose file can be neither opened nor created, instead of reporting it connected - #25007
Conversation
… path that can be neither opened nor created resolveSqliteAbsentFileTarget answered ':memory:' whenever existsSync was false, and existsSync is false for every path it cannot stat: a path under a regular file (ENOTDIR), a directory this process may not search (EACCES). So a read-only boot (os migrate plan, sqliteAbsentFile: 'empty-in-memory') opened an empty in-memory database over a file that cannot exist and reported the datasource connected, while the boot that writes refuses the same path. The judgement now asks the operating system without writing anything (probeAbsentSqliteDatabaseFile, the read-only twin of makeDatabaseDirectory) and throws SqliteDatabaseFileError naming the file and the system's answer. knexConfigFor asks it for a SQLite client only. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…enable SQLite path on every rung; changeset The read-only boot os migrate plan composes (createDefaultDatasourceDriverFactory with sqliteAbsentFile: 'empty-in-memory') reports a declared SQLite datasource under a regular file as failed, naming the file, and registers no driver; the native rung, the wasm rung and production all reject with the driver's SqliteDatabaseFileError (ENOTDIR), and an absent file that can be created still opens empty in memory on both rungs with nothing written. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…wn from on the probe, leaving SqliteDatabaseFileError's own doc as it stands Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
… refusal pin, so tsc reads it Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…adonly-probe-unopenable-path Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 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 — 11 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 1c9342d044774d69aa0f2dd662f0f15188b07753 && git checkout 1c9342d044774d69aa0f2dd662f0f15188b07753
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 62bc1293080968f0ec8c3d31dc1f9ed8da4077df 86d0cbfb7eae953f633478be25b462a8bc5ae8ec && git checkout -B drift-repro 62bc1293080968f0ec8c3d31dc1f9ed8da4077df && git merge --no-ff 86d0cbfb7eae953f633478be25b462a8bc5ae8ec
node scripts/docs-audit/affected-docs.mjs --json 62bc1293080968f0ec8c3d31dc1f9ed8da4077df
|
|
Seat review ·
Generated by Claude Code |
…the launch window ships a BREAKING accept-set narrowing The changeset declared Clause-② no (narrowing) and graded driver-sql patch, which the Check Changeset level axis refuses: a narrowing is BREAKING, and during the launch window a BREAKING change ships minor. Raise driver-sql to minor and say so in the BREAKING banner, in the wording the other launch-window narrowings use. No other file changes. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsInputs: card #24961's body and its three comments (claim 6303642231, dev reports 6304625148 and 6305394134); PR #25007's body and file list (5 files, +483/-6); the net diff
② Semver levelClause-②: no (narrowing)
Check-runs on the head, read once at this record's write and not awaited, exactly as they stand: ③ Boundary flagsBoth dev reports carry
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对Seat review ·
Generated by Claude Code |
Fixes #24961
Clause-②: no (narrowing)
The narrowing face: a read-only boot (
sqliteAbsentFile: 'empty-in-memory', whichos migrate planand the read-only dry runs use) that answereddatasource 'NAME': connectedfor a SQLite path that can be neither opened nor created now reports that datasource failed, naming the file. No exported declaration changes. The review of record is owed atCONTRACT_REVIEW_TIERon that face.Dev session
session_01QK6pdbxKZiHdFApL8RYVh9(amode:subagentrun), dispatched by thedomain:engineseat 2 under claim 6303642231.What was wrong
resolveSqliteAbsentFileTarget(packages/drivers/driver-sql/src/sql-driver.ts) is the one place the "does the SQLite target exist?" judgement is made. Under'empty-in-memory'it answered:memory:wheneverexistsSync(filename)wasfalse.existsSyncisfalsefor every path it cannot stat, not only for an absent one. So a path under a regular file read as absent, the read-only boot opened an empty in-memory database over it, and the datasource readconnected. The boot that writes refuses the same path inSqlDriver.connect().What changed
probeAbsentSqliteDatabaseFile, new and module-private inpackages/drivers/driver-sql/src/sqlite-database-file.ts, next tomakeDatabaseDirectory, whose walk it mirrors without themkdir):stat(filename)succeeds: the file exists and is opened as before.stat(filename)answers anything butENOENT(ENOTDIR,EACCES,ELOOP, …): the file cannot exist. That answer is the refusal.ENOENT: walk up to the nearest level that exists. A level whose name a dangling symbolic link holds is refused. Otherwise the level must allow write and search (access(W_OK | X_OK)), the permission that creating the next level or the file needs. Its refusal (EACCES, orEROFSon a read-only mount) is the answer.SqliteDatabaseFileError, which names the file and carries the system'scode. It is the error the writing boot'sconnect()already raises (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), andisSqliteFileOpenFailure(fix(service-datasource): in development, a SQLite file that cannot be opened or created fails its connect instead of stepping down to an in-memory engine #24955) already classifies it.resolveSqliteAbsentFileTargetthrows it, so theSqlDriverconstructor throws it too, and no Knex instance is built over the file.resolveSqliteDriverresolves the wasm rung's filename throughresolveSqliteAbsentFileTargetbefore it builds any rung (sqlite-driver-fallback.ts, the line under the "filename the wasm rung must open" note). So the native rung, the wasm rung and production (dev: false, native unprobed) all reject with the same error. The factory'screate()rejects, andDatasourceConnectionServicerecords the datasourcefailedunder its declared name, through the samehandleFailurepath the writing boot takes. No line ofsqlite-driver-fallback.tschanged.applyAutoVacuumIncrementalandisSqliteFileOpenFailure, which driver-sql: a declared SQLite file that is not a database (SQLITE_NOTADB) is accepted by connect(); on a development boot the wasm rung then renames it to FILE.corrupt-TIMESTAMP, writes an empty database in its place, and reads connected #24960 is changing, are untouched.knexConfigForasks the judgement for a SQLite client only. A server dialect'sconnectionstring is a URL, not a filename. Measured on base:new SqlDriver({ client: 'pg', connection: 'postgres://app@db.example:5432/main', sqliteAbsentFile: 'empty-in-memory' })had its connection string rewritten to:memory:and answeredsqliteOpenedEmptyInMemory: true. That contradictsSqlDriverConfig.sqliteAbsentFile's "ignored for non-SQLite dialects". No producer passes the option to a server dialect, but the judgement now does filesystem work and can throw, so it must not be handed a URL. It sits insideknexConfigFor, on this claim's file surface.SqliteAbsentFileModeandresolveSqliteAbsentFileTargetsay that only a file'create'could bring into existence is absent. TheSqliteDatabaseFileErrorclass doc is left as it stands. The probe's own doc says where the error is thrown from. This avoids a textual conflict with driver-sql: a declared SQLite file that is not a database (SQLITE_NOTADB) is accepted by connect(); on a development boot the wasm rung then renames it to FILE.corrupt-TIMESTAMP, writes an empty database in its place, and reads connected #24960, which edits that block.@objectstack/driver-sqlpatch, with theClause-②line, the BREAKING banner, FROM and TO, and the ADR-0087 disposition (not-required (no-migration-prescription)).H1: what the filesystem answers, measured before the change
Measured on Node v22.22.0 with a probe script (deleted), as root and as uid 65534 (
setpriv). Nothing was written.existsSyncstat(file)access(W_OK and X_OK)thereplain/archive.db)ENOTDIRplain/sub/archive.db)ENOTDIR0700dir that is not ours (uid 65534)EACCES0555dirENOENTEACCES/proc/os-24961/archive.dbENOENT/procmkdiranswersENOENT(the #23696 reading); uid 65534:EACCES/opt/claude-code/os-24961/archive.db(ro mount)ENOENT/opt/claude-codeEROFSfor bothENOENTENOENTstatpasses over the link to its parent;lstatstill finds the linkstatalready separates "absent" (ENOENT) from "cannot exist" (anything else).accesson the nearest existing level covers permission and read-only mounts.Public door, before and after
bin/run-dev.js migrate plan --database-url file:main.dbin a throwaway project.dist/objectstack.jsondeclaresarchive(driver: 'sqlite') and binds one object,os24961_note, to it. The driver-sqldist/was built from base4b8f597bafor "before" and from3370501f9for "after". The parent directory listing was compared before and after each run.archivefilename…/plain/archive.db(under a regular file)datasource 'archive': connected;Planned 1 declared datasource(s) … archive (…/plain/archive.db) — connected under the declared name;+ os24961_note [create_table, 8 column(s)]; exit 0datasource 'archive': connect failed — SQLite database file '…/plain/archive.db' cannot be opened or created: ENOTDIR: not a directory, stat '…/plain/archive.db'. (1 object(s) bind to it explicitly (os24961_note) … fail-fast per ADR-0062 D5); nothing planned; exit 1…/plain/sub/archive.dbENOTDIR; exit 1…/link/archive.db(a dangling symlink as the directory)connected, plannedcreate_table, exit 0connect failed — … cannot be created: its directory '…/link' cannot be made ('…/link' is a symbolic link to a path that does not exist): ENOENT …; exit 1…/ok/archive.db(absent, directory exists)connected,+ os24961_note [create_table, 8 column(s)], exit 0, file not created…/ok/new/deeper/archive.db(absent, directories missing)connected, planned, exit 0, no directory createdEvery run, before and after, left the listing unchanged. The writing boot over the same path (
migrate apply --yes, measured on base; this PR does not touch that path) answersconnect failed — … cannot be created: its directory '…/plain' could not be made: EEXIST …, exit 1. The two boots now agree. Their codes differ (ENOTDIRhere,EEXISTthere) because the read-only judgement asksstatwhere the writing boot asksmkdir. Each is the system's own answer.Resolver answers on the built
dist/after the fix, as uid 65534: a0555directory givesEACCES("cannot be opened or created"), one level deeper givesEACCES("its directory … cannot be made"),/proc/os-24961/…givesEACCES, and the read-only mount givesEROFS. As root, the0555directory and/proc/os-24961/…read absent. Nothing was written in any case.Pins
packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts(relativesrcimport), a new describe block:SqliteDatabaseFileError,codeENOTDIR,filenamethe file. The directory listing is unchanged.ENOENT). The link is still dangling, and its target was not created.0555directory as a non-superuser:EACCES, at both depths, and nothing created (skipIfsuperuser). As the superuser the same directory reads absent, because the writing boot can create there (runIfsuperuser). CI's runner is not root, so CI runs the first. This container runs as root, so here it ran the second, and the first was measured ondist/as uid 65534 (above).:memory:and writes nothing.'create'and the default hand the path on unchanged.new SqlDriver({ … sqliteAbsentFile: 'empty-in-memory' })over the path throws the same error. Without the option, the same driver'sconnect()refuses the same file. Nothing is written.pgdriver with a connection string keeps its string and answerssqliteOpenedEmptyInMemory: false. This is an assertion added to the existing "inert for non-sqlite dialects" case.packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts(new; it resolves@objectstack/driver-sqlthroughdist/, aKNOWN_UNALIASED_TEST_IMPORTSpair). Realbetter-sqlite3, real wasm SQLite, real files. An addon that cannot load is faked wherebetter-sqlite3loads it, the same way as the #24955 file:resolveSqliteDriver({ sqliteAbsentFile: 'empty-in-memory' })under a regular file, on the development native rung, the development wasm rung, and production: each rejects withSqliteDatabaseFileError/ENOTDIR. It never resolves an engine, never warns, and writes nothing.sqliteOpenedEmptyInMemory: true), and the wasm rung opens:memory:(engine: 'sqlite-wasm'). Nothing is on disk for either.os migrate plancomposes (createDefaultDatasourceDriverFactory({ sqliteAbsentFile: 'empty-in-memory' })):create()rejects with the refusal (code asserted). ThroughDatasourceConnectionService.connectDeclared, in development and in production, the datasource readsfailed, its reason names the file, no driver is registered underarchive, and nothing is written.This pin file is the only
service-datasourceline in the diff. It is a test, outside the package'sfiles[], and the cross-lane declaration todomain:servicesis the seat's.Ablation
Both legs went through
scripts/ablation-replace.mjs(WRAP), each with atraprestore on absolute paths. Each restore was proven by blob equality withHEAD, an emptygit diff HEAD, and an emptygit status --porcelain.existsSyncsemantics: stat succeeds means exists, anything else means absent. The marker identifier wasABLATION_24961. Anchor 1 to 0. Blobfbde48ea8f70becamefb3ea19400daand then returned tofbde48ea8f70. Thenpnpm --filter @objectstack/driver-sql build, andablation-dist-preflightfound the marker in 2 built files. Predicted: driver-sql 3 failed, 12 passed, 1 skipped; service-datasource 6 failed, 22 passed. Observed the same. Red in driver-sql: under a regular file, the dangling link, and the constructor. Red in service-datasource: the native, wasm and production rungs, the factory, and the service in development and in production. The absent-file controls, the fix(service-datasource): in development, a SQLite file that cannot be opened or created fails its connect instead of stepping down to an in-memory engine #24955 file (9) andsqlite-driver-fallback.test.ts(11) stayed green. After the restore: rebuild, and--absentfound the marker in none of the 6 built files, with the tree clean. The first attempt at this leg was refused by the tool before anything ran: its anchor sat inside its own replacement, so the anchor count could not drop. Nothing was built or tested on that attempt. The anchor was changed and the leg re-run.knexConfigFor.|| !this.isSqlitewas removed. Blobdea47a1a378ebecame38af381b3aacand then returned. The subject is a relativesrcimport, so this leg has no dist step. Predicted and observed: 1 failed ("is inert for :memory: and for non-sqlite dialects"), 14 passed, 1 skipped.Local verification
Builds:
turbo run build --filter='@objectstack/cli^...' --concurrency=2, 60 of 60.origin/main(4fe3af9d7, merge head28d5dd67e):pnpm install --frozen-lockfile, thenturbo run build --filter='@objectstack/cli^...' --filter=@objectstack/cli --concurrency=2, 61 of 61.Tests:
pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2) on02302719e: 225 files passed, 12 skipped; 3698 tests passed, 210 skipped. The skips include the live PG and MySQL cells (no live server here) and this file's superuser split.02302719e: 51 files, 916 passed.28d5dd67e: the driver-sql pin file gave 15 passed and 1 skipped; the three service-datasource fallback files gave 28 passed.Merge state:
origin/mainwas merged at4fe3af9d7. Since thenmainhas gainedfbb42d900(plugin-auth) and86d236071(rest), which touch none of this diff's packages; the queue rebuilds on currentmain. Against #24960's branch head6d3dd6247(fetched into a ref of my own),git merge-treereports no conflict: that branch editsSqliteDatabaseFileError's doc block, which this diff leaves alone.Typecheck:
pnpm --filter @objectstack/driver-sql typecheckpassed, and itstsc --listFilesincludes the edited test file.pnpm --filter @objectstack/service-datasource typecheck: the first run (02302719e) failed on the new pin file (a.thenoncreate()'s union return type). It was fixed in7c66e98f6, and it passes there and on28d5dd67e.Driver ledger (
pnpm check:driver-conformance), before (4b8f597ba) and after (28d5dd67e):55 covered cell(s), 0 in the DEBT ledger, 0 exemptboth times. The dialect axis is unchanged: 9 suites, 11 of 11 dialect-scored cells matrix-routed. The ledger did not move.Gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 72 commands at the merge head28d5dd67e. The derivation's first line namesobjectstack-ai/objectstackat28d5dd67e. The PM's list ate4d604efdhad 55, and the 17 extra come from this diff's changeset and its new service-datasource test. All 72 ran one at a time, each exit code captured before any pipe.node scripts/check-plugin-teardown-shape.mjs --self-test: its positive-control commit621a48760788was outside this shallow clone. I fetched that one commit into a ref of my own (--depth=1) and re-ran: exit 0, 48 cases.pnpm check:dual-build-cjs-loads: ten packages outside the CLI closure had nodist/. I ranturbo run build --filter='!@objectstack/docs' --concurrency=2(76 of 76) and re-ran: exit 0.--ranover the first pass: 72 derived, 70 run, 2 NOT-MEASURED, 0 UNRUN. Over the final record: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN, and the tool exited 0.packages/specmoved onmain's side),pnpm --filter @objectstack/spec check:generated: all 14 generated artifacts up to date, exit 0.test:repo(the vitestrepoproject) was not run locally: the diff touches none of its tests, and the derivation does not name it. It is declared to CI (Test Core).Acceptance notes
/proc/MISSING/…as root:accessanswers ok, and onlymkdir(ENOENT) would say no. The governing text, "'empty-in-memory'leaves the filesystem untouched", rules out finding out by trying. So the read-only boot still opens it in memory, and the writing boot still refuses it. As a non-root user it is refused (EACCESon/proc). The probe's doc names this.provisionTelemetryDatasourcewrapsresolveSqliteDriverin a best-efforttry. Over an unopenable sibling path, the read-only boot now gets no sibling, as the writing boot already did, where before it got an in-memory one. The sibling sits next to the primary, and an unopenable primary already fails the boot first. This is by code reading only, not measured at a door.NODE_ENV=production: pinned at the service layer (dev: false, both the resolver and the connection service). The production entrybin/run.jswas not driven: NOT MEASURED at that door.os migrate audit-mail-bodieswithout--apply: its archive arm exists only on PR feat(cli,plugin-audit): os migrate audit-mail-bodies masks the archive datasource too #24934, which is unmerged. NOT MEASURED. Its read-only boot builds its datasources through the same factory, so the factory pins are its mechanism.migrate apply --yesprintsdatasource 'archive': connected, then a false "native better-sqlite3 unavailable (ABI mismatch or not built)" step-down warning, and exits 1 later. The driver'smakeDatabaseDirectoryacceptsEEXISTon the link. better-sqlite3's code-less "directory does not exist"TypeErroris not one of the codesconnect()refuses on. The report carries it as a finding.Generated by Claude Code