Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24950-sqlite-open-failure-no-stepdown
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24950-sqlite-open-failure-no-stepdown

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24950

Clause-②: no

What was wrong

resolveSqliteDriver (packages/services/service-datasource/src/sqlite-driver-fallback.ts) probes a native better-sqlite3 connection in development and steps down to wasm SQLite, then to an in-memory engine. It stepped down on ANY probe failure. Its own module note says the step-down exists for one cause: the native addon loads lazily, so an ABI or load failure stays invisible until the first query.

So under bin/run-dev.js (NODE_ENV=development), a declared SQLite datasource whose FILE could not be opened was handed to the in-memory engine and read connected. Measured at the public door before this change (below): os migrate apply --yes with a bound archive datasource on a path under a regular file printed "neither native nor wasm SQLite available — dev falling back to InMemoryDriver", then datasource 'archive': connected, Applied 0 change(s), and exit 0.

What changed

  • In development, a probe failure that is the database FILE failing to open or be created is re-thrown, as it is in production. The datasource's connect fails, and listConnectionStates() reports it failed. This applies to every SQLite datasource, the primary included.
  • The classification reads the thrown value's code only, never its message (isSqliteFileOpenFailure, module-private). A file failure is an operating-system code (E plus capitals and digits, no underscore: EEXIST, ENOTDIR, ENOENT, EACCES, …) or SQLITE_CANTOPEN. SqlDriver.connect() raises these as its SqliteDatabaseFileError.
  • A native addon that cannot load still steps down to wasm SQLite, with the same warning. Its failures carry ERR_DLOPEN_FAILED, MODULE_NOT_FOUND, or no code at all, and at the probe's SELECT 1 they arrive wrapped as DATABASE_ERROR. None of these matches. Production is unchanged.
  • The module note now says which failures step down and why.
  • One changeset: @objectstack/service-datasource patch.

Not touched: packages/cli/** (bin/run-dev.js keeps NODE_ENV=development), connect-failure-remedy.ts, datasource-connection-service.test.ts, packages/drivers/**. No new env var, flag or gate.

Premises, measured before the change was committed

All readings were taken on this branch's base feb09db14a with real better-sqlite3 13.0.3 on Node v22.22.0, running the base copy of sqlite-driver-fallback.ts byte for byte (git show BASE:path) against the real drivers.

  • P1 holds, on codes. Raw better-sqlite3: under a regular file, SqliteError SQLITE_CANTOPEN. A directory as the file: SQLITE_CANTOPEN. A missing parent: a code-less TypeError ("directory does not exist"). Through SqlDriver.connect(), which makes the directory first, the probe's thrown value is SqliteDatabaseFileError with code EEXIST (under a regular file), ENOTDIR (deeper), ENOENT (/proc/missing/), EACCES (mkdir as an unprivileged user in a 0555 directory) or SQLITE_CANTOPEN (a directory, or a new file in a read-only directory). Addon-load shapes: a real process.dlopen of a non-addon gives ERR_DLOPEN_FAILED. A missing .node path gives MODULE_NOT_FOUND. bindings' "Could not locate the bindings file" has no code. A missing better-sqlite3 package gives knex's install hint, which has no code either. No message text was needed to separate them.
  • P2 holds. An absent file in an existing directory opens native, and the file is created. An absent file under two missing directory levels also opens native: connect() makes the directories. Neither hits the re-throw. The standalone host already refuses a primary whose directory it cannot make (createStandaloneStack, runtime/cli: os start on an OS_DATABASE_URL sqlite file whose directory cannot be made hangs with no timeout: createStandaloneStack's synchronous recursive mkdirSync loops on ENOENT under an existing parent (the #23696 class, a second producer) #23737), and no test or flow in the tree relies on the in-memory rung for an unopenable file.
  • P3 holds for the shapes the ruling names, with one refinement. On base, under a regular file, /proc/missing/ and an EACCES mkdir, wasm failed on the same path and the in-memory rung answered (engine: memory). For a directory as the file, and for a new file in a read-only directory, wasm's connect() RESOLVED, so the base answer was engine: sqlite-wasm over a file it could not read. For the directory, the first query then failed with DATABASE_ERROR (cause EISDIR). The native re-throw catches both shapes before wasm.
  • Why the re-throw sits at the native rung only. I first put the same re-throw on the wasm rung too. The ablation showed that no measured path reaches it. connect() makes the file's directory before the first statement loads the addon, so even with an addon that cannot load, the native rung refuses every path the wasm rung would. I removed the wasm-rung re-throw (4da3d4bf0c). The step-down for an addon that cannot load behaves exactly as before. The wasm rung's catch only moved its best-effort disconnect() into the shared closeQuietly.

Pins (new file src/__tests__/sqlite-driver-fallback-file-open-failure.test.ts)

These use real better-sqlite3, real wasm SQLite and real files in a scratch directory. The one thing faked is an addon that cannot load. The fake sits where better-sqlite3 loads the addon, on the knex better-sqlite3 client's own acquireRawConnection, and throws a REAL load error: Node's ERR_DLOPEN_FAILED, or bindings' own "not built" error. The existing src/sqlite-driver-fallback.test.ts mocks all three driver packages and is unchanged; its 11 cases pass.

  • development: under a regular file, the probe rejects with SqliteDatabaseFileError: EEXIST, and ENOTDIR one level deeper. No warning, never wasm or memory.
  • development: a directory as the file rejects with SQLITE_CANTOPEN.
  • development: an absent file in an existing directory opens native, and the file is created.
  • development: an absent file under missing directories opens native.
  • development: ERR_DLOPEN_FAILED steps down to wasm SQLite with NATIVE_SQLITE_WASM_FALLBACK_WARNING, and SELECT 1 answers.
  • development: the code-less "not built" error steps down to wasm SQLite.
  • development: an addon that cannot load plus a path under a regular file still rejects, never memory.
  • production: under a regular file, the resolver returns native unprobed with no warning, and its connect() refuses the file (EEXIST), as before.
  • DatasourceConnectionService + createDefaultDatasourceDriverFactory({ dev: true }): a declared archive (autoConnect: true) under a regular file reads availability: 'failed', its reason names the file, and no driver is registered.

Ablation, one leg for the one behaviour changed. The native re-throw was replaced through scripts/ablation-replace.mjs: anchor 1 → 0, marker 0 → 1, blob ec4203705cea → fc817e7389d4. The subject is imported relatively from src, so no dist leg applies. Predicted 4 red / 5 green, observed 4 red / 5 green: regular file, directory, addon+regular file and connection-state turned red; both absent-file pins, both addon-load pins and production stayed green. The restore was proved by blob ec4203705cea equal to HEAD and an empty git diff HEAD.

Public door, before and after

Each run is bin/run-dev.js migrate apply --yes --database-url file:main.db in a throwaway project whose dist/objectstack.json declares archive (driver: 'sqlite') on a path under a regular file.

fixture before (base behaviour planted in dist/) after (05e9a8e09a)
an object binds to archive "neither native nor wasm SQLite available — dev falling back to InMemoryDriver", datasource 'archive': connected, Applied 0 change(s), exit 0 datasource 'archive': connect failed — SQLite database file '…/plain/archive.db' cannot be created: its directory '…/plain' could not be made: EEXIST … (fail-fast per ADR-0062 D5), exit 1
autoConnect: true, nothing bound connected on the in-memory engine, exit 0 WARN datasource 'archive': connect failed — SQLite database file … (degraded, as D5 says), exit 0

For the before leg, the native re-throw was mutated away in src through ablation-replace (WRAP). Then service-datasource was rebuilt and ablation-dist-preflight confirmed the marker in 2 built files. After the restore, the package was rebuilt again and --absent confirmed the marker gone from all 24 built files, with a clean tree.

The card's own path, os migrate audit-mail-bodies over an archive datasource: NOT MEASURED. The archive examination exists only on PR #24934, which is not merged at this head. The --apply boot of that command builds its datasources through this same factory, so the after row above is that boot's mechanism.

Local verification, on head d353b3979b (after merging origin/main ea08ce584c, which landed #24933)

  • pnpm exec turbo run build --filter='@objectstack/service-datasource...' --concurrency=2: 19/19 successful.
  • pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2: 50 files, 892 tests passed.
  • pnpm --filter @objectstack/service-datasource typecheck (tsc --noEmit): exit 0. --listFiles includes the new test file.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 66 commands at d353b3979b. All 66 ran one at a time, each exit captured before any pipe: 66 × exit 0. --ran printed: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. Before the merge, the same 66 at 05e9a8e09a gave 65 × exit 0, and check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: unbuilt packages elsewhere in the tree). Every family that CI runs is read from this PR's check-runs, not re-run here.

Acceptance notes

  • Boundary, left as is: a file that is not a SQLite database still steps down, and wasm then moves it aside. The native probe answers SQLITE_NOTADB, which is not an open failure, so it keeps today's path. Wasm SQLite then renames the file to archive.db.corrupt-TIMESTAMP, creates an empty database in its place, and the datasource reads connected. The warning says "native better-sqlite3 unavailable (ABI mismatch or not built)", which is false. Measured at the same public door on this head: exit 0, the operator's file moved aside. This is outside the ruling's named route; the report puts it to the seat as an open question.
  • Boundary, left as is: the read-only boot treats an unopenable path as an ABSENT file. os migrate plan, and the dry runs that boot with readOnlyProbe, pass sqliteAbsentFile: 'empty-in-memory'. driver-sql's resolveSqliteAbsentFileTarget sees existsSync(path) === false for a path under a regular file and opens an empty in-memory SQLite in its place. So bin/run-dev.js migrate plan with the bound fixture above prints datasource 'archive': connected and plans + os24950_note [create_table, 8 column(s)] into a file that cannot exist, exit 0. This PR does not change that: no probe failure occurs, and the fix belongs in packages/drivers/**. The report hands it to the seat.
  • Not reachable from here: with an addon that cannot load, wasm's connect() resolves over a file it cannot read (a directory as the file, a new file in a read-only directory). Its first query fails instead. applyAutoVacuumIncremental swallows the open error, and the wasm rung's probe is connect() alone. This is the addon-cannot-load path, which this card does not change. Carrier: none.
  • os db clean on a target it cannot open now prints the driver's SqliteDatabaseFileError text, which names the file. Before, it printed its own "native and wasm both failed to load". provisionTelemetryDatasource already swallows any throw, and returns undefined exactly as it did for the memory engine.

Generated by Claude Code

…ed is not stepped down in development

resolveSqliteDriver stepped down native -> wasm -> in-memory on ANY probe
failure in development, so a declared datasource on a path under a regular
file (or a parent it cannot make, or a permission refusal) read connected
on an in-memory engine. The step-down now applies only to a native addon
that cannot load; a file-open failure (an operating-system code or
SQLITE_CANTOPEN on the thrown value) is re-thrown at the native and wasm
rungs, as in production.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
… reaches

connect() makes the file's directory before the first statement loads the
addon, so the native rung already refuses every path the wasm rung would;
the composite pin now says where it is refused.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…lite-open-failure-no-stepdown

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 865d2c8cf4accaaa5d1885d512f694e4abdce440 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fbe205f391e285447c2a7ea3619ad1dfed615157 — the merge of head d353b3979bfaab76b4c0bf84af06294cddecde80 into base 865d2c8cf4accaaa5d1885d512f694e4abdce440, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fbe205f391e285447c2a7ea3619ad1dfed615157 && git checkout fbe205f391e285447c2a7ea3619ad1dfed615157
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 865d2c8cf4accaaa5d1885d512f694e4abdce440 d353b3979bfaab76b4c0bf84af06294cddecde80 && git checkout -B drift-repro 865d2c8cf4accaaa5d1885d512f694e4abdce440 && git merge --no-ff d353b3979bfaab76b4c0bf84af06294cddecde80

node scripts/docs-audit/affected-docs.mjs --json 865d2c8cf4accaaa5d1885d512f694e4abdce440

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T18:58Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 2941a1929d, not against the report (os-dev report on #24950). Head under review:

d353b3979bfaab76b4c0bf84af06294cddecde80
  1. Shape.
    • Draft, base main, first line Fixes #24950, line-start Clause-②: no in the body and the changeset.
    • 3 files, +333/−21, none on a governed surface (governedPathsIn answers []).
    • The trial merge onto origin/main is clean.
    • The review face is declaration at TIER_DEFAULT, so this ACCEPT is the review of record.
  2. The ruling's route, read in the code (sqlite-driver-fallback.ts, claim 6297153129).
  3. Premises.
    • P1 holds: the codes were read from a real better-sqlite3 13.0.3.
    • P2 holds: an absent file and a missing parent still open natively.
    • P3 holds, with one refinement: for a directory as the file and a new file in a read-only directory, base wasm connect() resolved rather than falling to memory. The native re-throw catches both.
  4. Pins and ablation.
    • The new file sqlite-driver-fallback-file-open-failure.test.ts has 9 cases on real better-sqlite3, real wasm and real files:
      • an unopenable path throws;
      • an absent file opens;
      • ERR_DLOPEN_FAILED and not-built step down to wasm;
      • production is unchanged;
      • a DatasourceConnectionService failed-state case.
    • Ablation of the native re-throw: 4 red, 5 green, the predicted split. The restore was proven by blob equality.
    • Public door (bin/run-dev.js migrate apply --yes, an archive under a regular file):
      • before: connected on InMemoryDriver, exit 0;
      • after: a refusal naming the file (EEXIST), exit 1.
  5. Changeset, @objectstack/service-datasource patch, read sentence by sentence: "Now" and "Unchanged" match 2–4. It says plainly that a development boot whose primary file cannot be opened now stops and names the file.
  6. Gates.
    • At ACCEPT, every check-run on this head is success or an expected skip, among them TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core, Governed Surface Queue Guard and Check Documentation Links.
    • Locally, 66 of 66 derived families exited 0 on the merge head; --ran reads 0 NOT MEASURED.
    • service-datasource: 50 files, 892 tests passed.
  7. Seat ruling on the report's open question. SQLITE_NOTADB still steps down, and wasm quarantines the file. Ruled A (the producer side, driver-sql, 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 did for SQLITE_CANTOPEN), filed as 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 for domain:engine. Not this PR.
  8. Out-of-scope dispositions.
  9. Accepted deviations.
  10. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants