Repository navigation
fix(cli,runtime): a dangling symlink as the SQLite database's directory is refused at once by os dev and os serve, naming the file and the directory - #24233
Conversation
…ch package's makeDirectorySync os dev's prepareDevDatabaseDirectory and the standalone host's makeSqliteDatabaseDirectory each carried an async copy of the bounded walk that accepted any EEXIST on a created level. A dangling symlink answers EEXIST to mkdir, so it went through to the driver and the boot failed later in a plugin's start, naming neither the file nor the directory. Both wrappers now call their package's makeDirectorySync, which accepts EEXIST only when a directory stands there; each keeps its own error text. A dangling symlink as the directory is refused at once, naming the file and the directory. Every other measured input class answers as before. Pins: one dangling-symlink case per package, plus the unchanged classes the existing pins did not cover (an existing directory, a live symlink to one, and, for dev, a file holding the directory's name). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
📓 Docs Drift CheckThis PR changes 2 package(s): 4 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 — 45 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 3c0fff7153a71c8ee13374c8a2d38b7153dba135 && git checkout 3c0fff7153a71c8ee13374c8a2d38b7153dba135
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6e43b7bc3f8cec05e65e200f28c9e6074c585228 09c0232dc742339ea6be3a4d2103bedbd96b475a && git checkout -B drift-repro 6e43b7bc3f8cec05e65e200f28c9e6074c585228 && git merge --no-ff 09c0232dc742339ea6be3a4d2103bedbd96b475a
node scripts/docs-audit/affected-docs.mjs --json 6e43b7bc3f8cec05e65e200f28c9e6074c585228
|
席位核对 — ACCEPT · PR #24233 at
|
Fixes #24090
Clause-②: no
Route A, as triage
6228217116ruled. The two async copies of the bounded walk now call their own package'smakeDirectorySync:prepareDevDatabaseDirectoryinpackages/cli/src/commands/dev.ts, andmakeSqliteDatabaseDirectoryinpackages/runtime/src/standalone-stack.ts. Each keeps its own error text. The lenientEEXISTindriver-sql'ssqlite-database-file.tsbelongs to the driver lane, as the card says, so it is not in this diff.What was wrong
Both copies accepted ANY
EEXISTwhile they made a missing level. A symbolic link that points nowhere answersENOENTtostatandEEXISTtomkdir. So the copies took it for a directory and returned, and the boot went on until the SQLite driver could not open the file. Each package'smakeDirectorySyncalready acceptedEEXISTonly when a directory stands there.What changed
dev.ts:prepareDevDatabaseDirectoryis now synchronous. It callsmakeDirectorySync(directory)inside its existingtry, and the wrapper and its message are unchanged. The one caller (run,:624) drops itsawait, and itstry,printError, remedy line andexit 1are unchanged.standalone-stack.ts:makeSqliteDatabaseDirectoryis now synchronous. It callsmakeDirectorySync(directory)and throws the sameSqliteDatabaseDirectoryError. Both SQLite arms (sqlite,sqlite-wasm) drop theirawait. They run inside the asynccreateStandaloneStack, so a throw is the same rejection as before. Thenode:fs/promisesimport anddirnameare no longer used, so they are removed.make-directory.tsfiles, stop describing an async copy (comment-only edits in the fold targets)..changeset/24090-db-dir-dangling-symlink.md:@objectstack/cliand@objectstack/runtimepatch,Clause-②: no. Every input whose answer changes already failed the boot.Measured at the two doors (Done when, first bullet)
Built CLI (
turbo run build --filter='@objectstack/cli...'), Node v22.22.0. The scratch projects are outside the repo:defineStack({})foros devandexport default {};foros serve. Each run is in its own process group, killed whole at 90 s or onPress Ctrl+C to stop.Wis the scratch directory.b9ac35cbf09c0232dcOS_HOME=W/dl-home os dev --no-watch, whereW/dl-home/datais a dangling symlink✗ Plugin com.objectstack.auth failed to start - rollback complete: The database refused to run this query for object 'sys_organization'. …✗ The default database 'W/dl-home/data/objectstack.db' cannot be created: its directory 'W/dl-home/data' could not be made: EEXIST: file already exists, mkdir 'W/dl-home/data', plus the existing remedy lineOS_DATABASE_URL=file:W/dl-db/link/ctl.db os serve --port 0, wherelinkis dangling✗ Plugin package-service failed to start - rollback complete: The database refused to run a raw statement. …✗ [StandaloneStack] SQLite database file 'W/dl-db/link/ctl.db' cannot be created: its directory 'W/dl-db/link' could not be made: EEXIST: file already exists, mkdir 'W/dl-db/link'OS_DATABASE_URL=file:W/dl-mid/link/sub/ctl.db os serve, a dangling link mid-path… its directory 'W/dl-mid/link/sub' could not be made: ENOENT: …… could not be made: EEXIST: file already exists, mkdir 'W/dl-mid/link'OS_HOME=W/dl-oshome os dev, whereOS_HOMEitself is dangling… its directory 'W/dl-oshome/data' could not be made: ENOENT: …EEXISTos dev, a creatableOS_HOMEtwo levels deepos serve, a creatablefile:path three levels deepThe card's numbers (11.2 s and 5.3 s) are from
b6805ac0f. Re-measured at this base, the same two failures take 8.3 s and 2.7 s, and their text is unchanged. The two mid-path rows were already immediate named refusals. Only their code changes, fromENOENTat the level below the link toEEXISTat the link itself. The changeset says so.The five unchanged input classes (Done when, second bullet), pinned
dev-database-directory.test.ts)standalone-stack-uncreatable-db-directory.test.ts)/proc/MISSING(ENOENT)ENOTDIR)EEXIST)EEXIST, names the file and the directory, the link stays a link and nothing is made at its targetThe new cases go into each existing pin file as a second
describe. The existing cases and their assertions are byte-identical. The dev cases share the file's one boundedtsxchild, because importingdev.tscosts about 7 s there. Against the base sources, only the dangling case is red in each file. Every new unchanged-class case is green there too, so the five classes answer as before.The event-loop yield (claim H3)
The copies' async walk yielded the loop (1 to 100 turns, measured on #23858). The fold yields 0. Nothing consumes that timing.
prepareDevDatabaseDirectoryhas one caller, a sequential step inos dev'srun. Before it, only the synchronous compile and the signal handlers have run.makeSqliteDatabaseDirectoryruns insidecreateStandaloneStack, and every production caller awaits that call in sequence:serve.ts(about:2847),schema-migrate.ts(about:1189) anddefault-host.ts(:166).Red at base, and the ablation
Both runs come after the fix was committed (
09c0232dc), under the verify lock. Both pins import the source (atsxchild ofdev.ts, and a relative import ofstandalone-stack.ts), so nodist/is in the path.git restore --source=b9ac35cbfputdev.ts(blobde384bf3) andstandalone-stack.ts(blob6ddd65e0) back to the base sources, working tree only. Runtime pin: 1 failed, 5 passed:expected a refusal, but createStandaloneStack returned. Dev pin: 1 failed, 6 passed:expected 'returned' to be 'rejected'. Both were restored withgit checkout HEAD: the blobs equal HEAD (17fc74e9,8b74041b),git diff HEADis empty and the status is clean.scripts/ablation-replace.mjsin wrap mode, once per package. In eachmake-directory.ts, the anchorif (errorCode(e) !== 'EEXIST' || !isDirectory(each)) throw e;went from x1 to x0, and the lenient lineif (errorCode(e) !== 'EEXIST') throw e;went from x0 to x1. Runtime blob:b6544e2ato1c66dc92. CLI blob:a4e52da6tof1e8b164. Each package's pin then had exactly its dangling case red: runtime 1 failed, 5 passed; CLI 1 failed, 6 passed. Both were restored: blob == HEAD,git diff HEADempty, and the anchor back to x1.Tests and gates
Exit codes are captured before any pipe, on
09c0232dc.pnpm --filter @objectstack/cli run typecheck: exit 0. The test layer's ledger was unchanged (3 files, 28 errors).pnpm --filter @objectstack/runtime run typecheck: exit 0. The test layer's ledger was unchanged (27 files, 190 errors).pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project unit(the whole unit tier): exit 0, 310 files, 4,602 tests. The integration tier is left to CI: no integration file and no spawn entry is touched.pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 --project local(the runtime's wholepnpm test): exit 0, 409 files, 6,282 passed, 19 skipped.pnpm exec eslint --no-inline-config --max-warnings=0over the six changed.tsfiles: exit 0. The repo-wide run is CI'sLint & Repo Gates.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 65 commands (7 paths against the merge baseb9ac35cbf), run one by one.PREREQUISITE NOT MET(exit 3), and each was re-run to a reading.check:dual-build-cjs-loadsandcheck:i18n-coveragehad found packages withoutdist/. Afterturbo run build --filter='!@objectstack/docs'under the lock, both exited 0: 111 entries across 69 packages load, and 14 configs had none new.check:type-check-debthad not got the lock for its closure build. Re-run under the lock, it exited 0:none above its recorded number.--ranreconciliation:65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED, exit 0.node scripts/check-nul-bytes.mjs: exit 0. The control-byte grep over the 7 files found no hit.Acceptance notes
makeDatabaseDirectoryinpackages/drivers/driver-sql/src/sqlite-database-file.ts(about:55, itsEEXISTarm about:74) still accepts anyEEXIST. The card and the triage leave it to that lane. Both doors measured above now refuse in the host before the driver runs.os servewithdefineStack({ datasources: [{ driver: 'sqlite', autoConnect: true, … }] }). It did not connect the datasource at boot: its creatable CONTROL directory was never made. So reach through that door is NOT MEASURED. Carrier: none.os startis not in the table. It makesOS_HOME/datawith the CLI's strictmakeDirectorySyncbefore it starts a serve child (PR fix(cli,runtime): refuse an uncreatable state, output or cache directory at once instead of looping in recursive mkdir #23979), so a danglingOS_HOME/datawas already refused there. A dangling link in anOS_DATABASE_URLpath reaches the same standalone host asos serve.origin/mainhas gained 4 commits since the base (6e43b7bc3). In these packages they touch onlymigrate/schema-migrate/unmanaged-tablesandruntime/src/domains, none of this PR's files, andgit merge-treeis clean. They are not merged in; the queue rebuilds onmain.Generated by Claude Code