Skip to content

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

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-24090-db-dir-dangling-symlink
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-24090-db-dir-dangling-symlink

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24090

Clause-②: no

Route A, as triage 6228217116 ruled. The two async copies of the bounded walk now call their own package's makeDirectorySync: prepareDevDatabaseDirectory in packages/cli/src/commands/dev.ts, and makeSqliteDatabaseDirectory in packages/runtime/src/standalone-stack.ts. Each keeps its own error text. The lenient EEXIST in driver-sql's sqlite-database-file.ts belongs to the driver lane, as the card says, so it is not in this diff.

What was wrong

Both copies accepted ANY EEXIST while they made a missing level. A symbolic link that points nowhere answers ENOENT to stat and EEXIST to mkdir. 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's makeDirectorySync already accepted EEXIST only when a directory stands there.

What changed

  • dev.ts: prepareDevDatabaseDirectory is now synchronous. It calls makeDirectorySync(directory) inside its existing try, and the wrapper and its message are unchanged. The one caller (run, :624) drops its await, and its try, printError, remedy line and exit 1 are unchanged.
  • standalone-stack.ts: makeSqliteDatabaseDirectory is now synchronous. It calls makeDirectorySync(directory) and throws the same SqliteDatabaseDirectoryError. Both SQLite arms (sqlite, sqlite-wasm) drop their await. They run inside the async createStandaloneStack, so a throw is the same rejection as before. The node:fs/promises import and dirname are no longer used, so they are removed.
  • The doc comments of both wrappers, and of both make-directory.ts files, stop describing an async copy (comment-only edits in the fold targets).
  • Net −37 lines across the four source files. No export changes: neither function is on a package entry.
  • .changeset/24090-db-dir-dangling-symlink.md: @objectstack/cli and @objectstack/runtime patch, 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({}) for os dev and export default {}; for os serve. Each run is in its own process group, killed whole at 90 s or on Press Ctrl+C to stop. W is the scratch directory.

door base b9ac35cbf this branch 09c0232dc
OS_HOME=W/dl-home os dev --no-watch, where W/dl-home/data is a dangling symlink exit 1 after 8.3 s: ✗ Plugin com.objectstack.auth failed to start - rollback complete: The database refused to run this query for object 'sys_organization'. … exit 1 after 4.8 s, before any serve child: ✗ 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 line
OS_DATABASE_URL=file:W/dl-db/link/ctl.db os serve --port 0, where link is dangling exit 1 after 2.7 s: ✗ Plugin package-service failed to start - rollback complete: The database refused to run a raw statement. … exit 1 after 2.4 s: ✗ [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 exit 1 after 2.4 s, already named: … its directory 'W/dl-mid/link/sub' could not be made: ENOENT: … exit 1 after 2.4 s: … could not be made: EEXIST: file already exists, mkdir 'W/dl-mid/link'
OS_HOME=W/dl-oshome os dev, where OS_HOME itself is dangling exit 1 after 4.9 s, already named: … its directory 'W/dl-oshome/data' could not be made: ENOENT: … exit 1 after 2.6 s, EEXIST
CONTROL: os dev, a creatable OS_HOME two levels deep banner at 6.3 s banner at 6.3 s
CONTROL: os serve, a creatable file: path three levels deep banner at 3.0 s banner at 3.1 s

The 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, from ENOENT at the level below the link to EEXIST at the link itself. The changeset says so.

The five unchanged input classes (Done when, second bullet), pinned

input (the database file's directory) dev pin (dev-database-directory.test.ts) runtime pin (standalone-stack-uncreatable-db-directory.test.ts)
creatable, three levels deep existing CONTROL existing CONTROL, both arms
already exists new CONTROL new CONTROL, both arms
/proc/MISSING (ENOENT) existing existing, both arms
through a regular file (ENOTDIR) existing existing
a regular file holds the directory's name (EEXIST) new existing
a live symlink to a directory (neighbour CONTROL: only the dangling link is refused) new new, both arms
a dangling symlink as the directory new: EEXIST, names the file and the directory, the link stays a link and nothing is made at its target new, both arms, the same

The 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 bounded tsx child, because importing dev.ts costs 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. prepareDevDatabaseDirectory has one caller, a sequential step in os dev's run. Before it, only the synchronous compile and the signal handlers have run. makeSqliteDatabaseDirectory runs inside createStandaloneStack, and every production caller awaits that call in sequence: serve.ts (about :2847), schema-migrate.ts (about :1189) and default-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 (a tsx child of dev.ts, and a relative import of standalone-stack.ts), so no dist/ is in the path.

  • Red at base. git restore --source=b9ac35cbf put dev.ts (blob de384bf3) and standalone-stack.ts (blob 6ddd65e0) 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 with git checkout HEAD: the blobs equal HEAD (17fc74e9, 8b74041b), git diff HEAD is empty and the status is clean.
  • Ablation. The run used scripts/ablation-replace.mjs in wrap mode, once per package. In each make-directory.ts, the anchor if (errorCode(e) !== 'EEXIST' || !isDirectory(each)) throw e; went from x1 to x0, and the lenient line if (errorCode(e) !== 'EEXIST') throw e; went from x0 to x1. Runtime blob: b6544e2a to 1c66dc92. CLI blob: a4e52da6 to f1e8b164. 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 HEAD empty, 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 whole pnpm test): exit 0, 409 files, 6,282 passed, 19 skipped.
  • The two pins plus both walk suites: CLI 2 files and 13 tests, exit 0. Runtime 2 files and 14 tests, exit 0.
  • pnpm exec eslint --no-inline-config --max-warnings=0 over the six changed .ts files: exit 0. The repo-wide run is CI's Lint & Repo Gates.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands (7 paths against the merge base b9ac35cbf), run one by one.
    • 62 exited 0 at once.
    • Three first answered PREREQUISITE NOT MET (exit 3), and each was re-run to a reading. check:dual-build-cjs-loads and check:i18n-coverage had found packages without dist/. After turbo 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-debt had not got the lock for its closure build. Re-run under the lock, it exited 0: none above its recorded number.
    • --ran reconciliation: 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

  • The lenient walk left in the repo is the driver lane's. makeDatabaseDirectory in packages/drivers/driver-sql/src/sqlite-database-file.ts (about :55, its EEXIST arm about :74) still accepts any EEXIST. The card and the triage leave it to that lane. Both doors measured above now refuse in the host before the driver runs.
    • A declared SQLite datasource reaches the driver's walk without the host. Its doc says so: "A declared SQLite datasource whose directory lay on such a path…".
    • I tried one probe: os serve with defineStack({ 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 start is not in the table. It makes OS_HOME/data with the CLI's strict makeDirectorySync before 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 dangling OS_HOME/data was already refused there. A dangling link in an OS_DATABASE_URL path reaches the same standalone host as os serve.
  • origin/main has gained 4 commits since the base (6e43b7bc3). In these packages they touch only migrate/schema-migrate/unmanaged-tables and runtime/src/domains, none of this PR's files, and git merge-tree is clean. They are not merged in; the queue rebuilds on main.

Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime, touching 3 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/cli/src/utils/make-directory.ts, packages/runtime/src/make-directory.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/data-modeling/drivers.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/deployment/single-project-mode.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/plugins/index.mdx (via createStandaloneStack (symbol, a top-level function))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-7.mdx (via createStandaloneStack (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/src/utils/make-directory.ts, packages/runtime/src/make-directory.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os dev (command, 34 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 45 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 6e43b7bc3f8cec05e65e200f28c9e6074c585228 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3c0fff7153a71c8ee13374c8a2d38b7153dba135 — the merge of head 09c0232dc742339ea6be3a4d2103bedbd96b475a into base 6e43b7bc3f8cec05e65e200f28c9e6074c585228, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6e43b7bc3f8cec05e65e200f28c9e6074c585228 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · PR #24233 at 09c0232dc (#24090)

domain:cli seat 2 · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-18T00:16Z · holder of claim 6242575704. The dev's report is on #24090, and the seat reviewed it against this PR, not the report's own account.

  1. Shape. Draft → main. The first line is Fixes #24090, the only closing keyword. Clause-②: no is line-initial, and the assignee is marchtian. 7 files, inside the claimed surface:

    • the two copies;
    • each package's makeDirectorySync, with doc comments only (they named the deleted copies);
    • one new describe in each existing pin file;
    • one changeset covering both packages.

    check-governed-merges.mjs --pr 24233: NOT governed, 216 changed lines.

  2. Route A as ruled (triage 6228217116), read on the diff.

    • prepareDevDatabaseDirectory and makeSqliteDatabaseDirectory now call their own package's makeDirectorySync inside their existing try, and keep their error wrappers.
    • The async walks are deleted, net −37 source lines.
    • Both become synchronous. Their three callers drop await, and each was a sequential one-shot boot step (H3).
    • prepareDevDatabaseDirectory is not on @objectstack/cli's entry (src/index.ts exports only DevCommand from dev.ts), and makeSqliteDatabaseDirectory is module-private, so no published signature moves.
    • driver-sql's lenient walk is untouched, as the card leaves it to its lane.
  3. Evidence.

    • Both doors re-measured at base and head on the built CLI. Base fails late in a plugin start (8.3 s / 2.7 s). Head refuses before the boot with the wrapper naming file, directory and EEXIST at the link.
    • Each new pin is red at base and red under its package's ablation (the lenient EEXIST restored in makeDirectorySync). The restores are proven by blob.
    • The five unchanged input classes are green at base and head.
    • Typechecks: exit 0. CLI unit tier: 310 files. Runtime local: 409 files.
    • 65 derived, 65 run, 0 NOT-MEASURED (two dist-reading gates re-run after a full build). ESLint exit 0.
  4. Clause-②: no, read. Every input whose answer changes already failed the boot. It now fails at once, with a named refusal. No accept set or export moves, and no contract-review face is touched.
    Changeset, read sentence by sentence:

    • "What was wrong" quotes the two measured late errors.
    • "What an operator sees now" matches both wrappers' texts.
    • "A dangling link higher up the path was already refused at once, with ENOENT … now refused with EEXIST at the link itself" is the dev's extra measurement, stated rather than hidden.
    • "Nothing else changes … (ENOENT, ENOTDIR, a regular file holding the directory's name) … same code and text" holds: those pins are unchanged and green.
  5. Accepted deviations:

    • comment-only edits in both make-directory.ts files;
    • pin cases added as a second describe in each existing file;
    • one ablation per package plus a red-at-base run on each;
    • main moved 4 commits, none of them on this PR's files (merge-tree clean). The queue builds the merge.
  6. Out of scope: driver-sql's lenient EEXIST is noted in the PR's Acceptance notes. Its reach through a declared datasource is NOT MEASURED, so it is not filed.

  7. Landing. Ordinary queue landing once every check on 09c0232dc is green or a roster skip. Fixes #24090 closes the card, and the seat strips pm:dispatched in the same act.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 00:38
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 00:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit d702b18 Oct 18, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24090-db-dir-dangling-symlink branch October 18, 2026 01:00
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