akm-cli 0.9.1 → 0.9.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/CHANGELOG.md +103 -28
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -12,9 +12,8 @@
12
12
  * importers reference those modules directly. The migration engine
13
13
  * lives in `./state/migrations`.
14
14
  *
15
- * The state DB replaces flat-file storage for data that is NON-REGENERABLE —
16
- * events (events.jsonl), proposals (per-uuid JSON directories), task history
17
- * (per-task JSONL), and the improve-pipeline ledgers.
15
+ * The state DB stores non-regenerable events, proposals, task history, workflow
16
+ * runs, and improve-pipeline ledgers.
18
17
  *
19
18
  * ## Why a separate database from index.db
20
19
  *
@@ -22,34 +21,29 @@
22
21
  * regenerable from the stash on disk, so a corrupt index is recovered by deleting
23
22
  * it and re-running `akm index` (no destructive version-bump rebuild). Events,
24
23
  * proposals, and task history are NON-REGENERABLE — losing them is data loss. They
25
- * must live in a database whose schema evolves via incremental, additive migrations
26
- * that never drop rows.
24
+ * live in a database whose released migration ledger is immutable and whose
25
+ * application policy is explicit.
27
26
  *
28
27
  * ## Migration-safety contract
29
28
  *
30
29
  * The `schema_migrations` table records every applied migration by a stable string
31
- * ID. `runMigrations(db)` is idempotent: new installs run all migrations in order;
32
- * upgrades run only the ones not yet applied. No migration may DROP a table that
33
- * holds durable data, RENAME a column, or change a column's type.
30
+ * ID. New installs run all migrations in order. Existing exact-prefix ledgers
31
+ * automatically apply additive migrations and the verified data-preserving 002
32
+ * table rebuild. Released migration 018 contains destructive cleanup DDL and is
33
+ * never applied by an ordinary managed open. The successful `akm upgrade` path
34
+ * must first create and verify a sibling `VACUUM INTO` snapshot, then supplies
35
+ * the narrow explicit intent that admits 018. A pre-existing file with no
36
+ * applied migration IDs (whether the ledger table is absent or empty) is also
37
+ * rejected without writes; explicit upgrade snapshots its exact inode before
38
+ * creating the ledger or applying migration 001, then retains the same writer
39
+ * lock through migration 002's rebuild. Unknown and divergent ledgers fail
40
+ * closed.
34
41
  *
35
- * Permitted schema evolution operations (always migration-safe in SQLite):
42
+ * Normal automatic schema evolution uses:
36
43
  * - ALTER TABLE … ADD COLUMN <name> <type> DEFAULT <value>
37
44
  * - CREATE INDEX IF NOT EXISTS …
38
45
  * - CREATE TABLE IF NOT EXISTS … (additive new tables)
39
46
  *
40
- * ## Three-DB cutover carve-out (Chunk 8, migration `020-three-db-cutover`)
41
- *
42
- * The 0.9.0 three-DB merge folds workflow.db and index.db's durable rows
43
- * (`usage_events`, `legacy_state`) into state.db. That migration is still pure
44
- * additive DDL and DROPS NOTHING — it only `CREATE TABLE IF NOT EXISTS`es the
45
- * merge-target tables at their final shape. The one-time, filesystem-derived,
46
- * fail-closed DATA movement (the workflow.db merge, the usage_events rescue, the
47
- * full old-ref→item_ref re-key, and the workflow.db unlink / index.db quarantine
48
- * rename) runs as idempotent code in the migrate-apply coordinator
49
- * (`scripts/akm-migrate/migrate/legacy/three-db-cutover.ts`). So the no-DROP
50
- * contract here is intact: physical workflow.db deletion happens outside the
51
- * ledger DDL, after a verified backup and committed data transaction.
52
- *
53
47
  * ## Schema design: indexed columns vs. metadata_json
54
48
  *
55
49
  * Each table holds only the columns needed for indexed queries as first-class
@@ -68,13 +62,13 @@
68
62
  *
69
63
  * @module state-db
70
64
  */
65
+ import { randomUUID } from "node:crypto";
71
66
  import fs from "node:fs";
72
67
  import path from "node:path";
73
68
  import { openDatabase } from "../storage/database.js";
74
- import { assertCurrentMigrationLedger, assertMigrationLedger } from "../storage/engines/sqlite-migrations.js";
69
+ import { assertMigrationLedger } from "../storage/engines/sqlite-migrations.js";
75
70
  import { openManagedDatabase, withManagedDb } from "../storage/managed-db.js";
76
71
  import { acquireMaintenanceActivitySync } from "./maintenance-barrier.js";
77
- import { assertNoPendingMigrationOperation } from "./migration-operation.js";
78
72
  import { getDataDir } from "./paths.js";
79
73
  import { runMigrations, STATE_MIGRATIONS } from "./state/migrations.js";
80
74
  // ── Path helper ──────────────────────────────────────────────────────────────
@@ -87,6 +81,269 @@ import { runMigrations, STATE_MIGRATIONS } from "./state/migrations.js";
87
81
  export function getStateDbPath() {
88
82
  return path.join(getDataDir(), "state.db");
89
83
  }
84
+ function safetyCopyTimestamp() {
85
+ return new Date().toISOString().replaceAll(/[^0-9]/g, "");
86
+ }
87
+ function noFollowFlag() {
88
+ return process.platform !== "win32" && typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
89
+ }
90
+ function samePhysicalFile(left, right) {
91
+ if (left.dev !== 0n || left.ino !== 0n || right.dev !== 0n || right.ino !== 0n) {
92
+ return left.dev === right.dev && left.ino === right.ino;
93
+ }
94
+ return left.birthtimeNs === right.birthtimeNs && left.rdev === right.rdev;
95
+ }
96
+ function assertOwnedFileReservation(reservation, label) {
97
+ const descriptorStat = fs.fstatSync(reservation.fd, { bigint: true });
98
+ let pathStat;
99
+ try {
100
+ pathStat = fs.lstatSync(reservation.path, { bigint: true });
101
+ }
102
+ catch (error) {
103
+ const detail = error instanceof Error ? error.message : String(error);
104
+ throw new Error(`${label} ownership/inode verification failed because its path disappeared: ${detail}`);
105
+ }
106
+ if (pathStat.isSymbolicLink() ||
107
+ !pathStat.isFile() ||
108
+ !descriptorStat.isFile() ||
109
+ !samePhysicalFile(reservation.identity, descriptorStat) ||
110
+ !samePhysicalFile(descriptorStat, pathStat)) {
111
+ throw new Error(`${label} ownership/inode verification failed: its path is a symlink or was replaced.`);
112
+ }
113
+ if (process.platform !== "win32" && typeof process.geteuid === "function") {
114
+ const expectedUid = BigInt(process.geteuid());
115
+ if (descriptorStat.uid !== expectedUid || pathStat.uid !== expectedUid) {
116
+ throw new Error(`${label} ownership verification failed: the reserved file is not owned by the current user.`);
117
+ }
118
+ }
119
+ return pathStat;
120
+ }
121
+ function assertStateDatabaseSource(source) {
122
+ const descriptorStat = fs.fstatSync(source.fd, { bigint: true });
123
+ let pathStat;
124
+ try {
125
+ pathStat = fs.lstatSync(source.path, { bigint: true });
126
+ }
127
+ catch (error) {
128
+ const detail = error instanceof Error ? error.message : String(error);
129
+ throw new Error(`state.db source inode verification failed because its path disappeared: ${detail}`);
130
+ }
131
+ if (pathStat.isSymbolicLink() ||
132
+ !pathStat.isFile() ||
133
+ !descriptorStat.isFile() ||
134
+ !samePhysicalFile(source.identity, descriptorStat) ||
135
+ !samePhysicalFile(descriptorStat, pathStat) ||
136
+ descriptorStat.uid !== source.identity.uid ||
137
+ pathStat.uid !== source.identity.uid) {
138
+ throw new Error("state.db source ownership/inode verification failed: its path is a symlink or was replaced.");
139
+ }
140
+ return descriptorStat;
141
+ }
142
+ function descriptorAlias(handle) {
143
+ const candidates = process.platform === "linux"
144
+ ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
145
+ : process.platform === "win32"
146
+ ? []
147
+ : [`/dev/fd/${handle.fd}`];
148
+ for (const candidate of candidates) {
149
+ try {
150
+ const stat = fs.statSync(candidate, { bigint: true });
151
+ if (samePhysicalFile(handle.identity, stat))
152
+ return candidate;
153
+ }
154
+ catch {
155
+ // The caller verifies the pathname immediately around SQLite open on
156
+ // platforms without a SQLite-openable descriptor alias.
157
+ }
158
+ }
159
+ return undefined;
160
+ }
161
+ function sqliteBoundFilePath(handle, label) {
162
+ const alias = descriptorAlias(handle);
163
+ if (alias)
164
+ return alias;
165
+ // SQLite's Windows VFS keeps an open database pathname from being replaced;
166
+ // the caller still performs identity checks immediately around every open.
167
+ if (process.platform === "win32")
168
+ return handle.path;
169
+ throw new Error(`${label} cannot be bound to its held inode: this platform has no descriptor-backed path.`);
170
+ }
171
+ function closeFileIdentity(handle) {
172
+ try {
173
+ fs.closeSync(handle.fd);
174
+ }
175
+ catch {
176
+ // Preserve the authoritative operation failure.
177
+ }
178
+ }
179
+ function reserveFreshStateDatabase(dbPath) {
180
+ let fd;
181
+ try {
182
+ fd = fs.openSync(dbPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR | noFollowFlag(), 0o666);
183
+ }
184
+ catch (error) {
185
+ if (error?.code === "EEXIST")
186
+ return undefined;
187
+ throw error;
188
+ }
189
+ try {
190
+ const reservation = {
191
+ path: dbPath,
192
+ fd,
193
+ identity: fs.fstatSync(fd, { bigint: true }),
194
+ };
195
+ assertOwnedFileReservation(reservation, "Fresh state.db");
196
+ return reservation;
197
+ }
198
+ catch (error) {
199
+ try {
200
+ fs.closeSync(fd);
201
+ }
202
+ catch {
203
+ // Preserve the ownership failure.
204
+ }
205
+ throw error;
206
+ }
207
+ }
208
+ function openExistingStateDatabaseSource(dbPath) {
209
+ let fd;
210
+ try {
211
+ fd = fs.openSync(dbPath, fs.constants.O_RDONLY | noFollowFlag());
212
+ }
213
+ catch (error) {
214
+ const detail = error instanceof Error ? error.message : String(error);
215
+ throw new Error(`Could not bind the existing state.db source inode: ${detail}`);
216
+ }
217
+ try {
218
+ const source = {
219
+ path: dbPath,
220
+ fd,
221
+ identity: fs.fstatSync(fd, { bigint: true }),
222
+ };
223
+ assertStateDatabaseSource(source);
224
+ return source;
225
+ }
226
+ catch (error) {
227
+ try {
228
+ fs.closeSync(fd);
229
+ }
230
+ catch {
231
+ // Preserve the source identity failure.
232
+ }
233
+ throw error;
234
+ }
235
+ }
236
+ function reserveHistoricalSafetyCopy(source, migrationId) {
237
+ const sourceStat = assertStateDatabaseSource(source);
238
+ const finalMode = Number(sourceStat.mode & 384n);
239
+ const prefix = `${source.path}.pre-${migrationId}.${safetyCopyTimestamp()}`;
240
+ for (let attempt = 0; attempt < 32; attempt += 1) {
241
+ const candidate = `${prefix}.${randomUUID()}.bak`;
242
+ let fd;
243
+ try {
244
+ fd = fs.openSync(candidate, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR | noFollowFlag(), 0o600);
245
+ }
246
+ catch (error) {
247
+ if (error?.code === "EEXIST")
248
+ continue;
249
+ throw error;
250
+ }
251
+ let reservation;
252
+ try {
253
+ reservation = {
254
+ path: candidate,
255
+ fd,
256
+ identity: fs.fstatSync(fd, { bigint: true }),
257
+ };
258
+ // Keep recovery bytes owner-only throughout creation. The source-derived
259
+ // (never broader) final mode is restored only after verification.
260
+ fs.fchmodSync(fd, 0o600);
261
+ assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
262
+ return { reservation, finalMode };
263
+ }
264
+ catch (error) {
265
+ try {
266
+ fs.closeSync(fd);
267
+ }
268
+ catch {
269
+ // Preserve the reservation/ownership failure.
270
+ }
271
+ const detail = error instanceof Error ? error.message : String(error);
272
+ throw new Error(`Could not secure reserved state.db safety-copy path ${candidate}: ${detail}. ` +
273
+ "The reserved pathname was not removed.");
274
+ }
275
+ }
276
+ throw new Error("Could not reserve a unique randomized state.db safety-copy path after 32 attempts.");
277
+ }
278
+ function fsyncDirectory(directory) {
279
+ if (process.platform === "win32")
280
+ return;
281
+ const fd = fs.openSync(directory, fs.constants.O_RDONLY);
282
+ try {
283
+ fs.fsyncSync(fd);
284
+ }
285
+ finally {
286
+ fs.closeSync(fd);
287
+ }
288
+ }
289
+ /**
290
+ * Create one verified, standalone SQLite snapshot immediately before released
291
+ * migration 018 removes its retired tables/column. `VACUUM INTO` includes
292
+ * committed WAL content in one consistent sibling database; a raw file copy
293
+ * would not.
294
+ */
295
+ function createHistoricalStateSafetyCopy(source, migrationId) {
296
+ const { reservation, finalMode } = reserveHistoricalSafetyCopy(source, migrationId);
297
+ let reader;
298
+ try {
299
+ assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
300
+ assertStateDatabaseSource(source);
301
+ // The migration connection already holds BEGIN IMMEDIATE. A distinct
302
+ // read-only connection bound to the held source inode can snapshot the
303
+ // committed WAL view without trying to VACUUM from inside that transaction.
304
+ reader = openDatabase(sqliteBoundFilePath(source, "state.db snapshot source"), { readonly: true });
305
+ assertStateDatabaseSource(source);
306
+ reader.prepare("VACUUM INTO ?").run(sqliteBoundFilePath(reservation, "Reserved state.db safety-copy target"));
307
+ assertStateDatabaseSource(source);
308
+ reader.close();
309
+ reader = undefined;
310
+ assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
311
+ fs.fsyncSync(reservation.fd);
312
+ assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
313
+ const verified = openDatabase(sqliteBoundFilePath(reservation, "Reserved state.db safety-copy target"), {
314
+ readonly: true,
315
+ });
316
+ try {
317
+ const quickCheck = verified.prepare("PRAGMA quick_check").get();
318
+ if (!quickCheck || Object.values(quickCheck)[0] !== "ok") {
319
+ throw new Error("SQLite quick_check did not report ok");
320
+ }
321
+ assertMigrationLedger(verified, STATE_MIGRATIONS);
322
+ }
323
+ finally {
324
+ verified.close();
325
+ }
326
+ assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
327
+ fs.fchmodSync(reservation.fd, finalMode);
328
+ fs.fsyncSync(reservation.fd);
329
+ assertOwnedFileReservation(reservation, "Reserved state.db safety copy");
330
+ fsyncDirectory(path.dirname(reservation.path));
331
+ closeFileIdentity(reservation);
332
+ return reservation.path;
333
+ }
334
+ catch (error) {
335
+ try {
336
+ reader?.close();
337
+ }
338
+ catch {
339
+ // Preserve the snapshot/verification failure below.
340
+ }
341
+ closeFileIdentity(reservation);
342
+ const detail = error instanceof Error ? error.message : String(error);
343
+ throw new Error(`Could not create a verified state.db safety copy before ${migrationId}: ${detail}. ` +
344
+ `The reserved safety-copy pathname was not removed: ${reservation.path}`);
345
+ }
346
+ }
90
347
  // ── Database open ────────────────────────────────────────────────────────────
91
348
  /**
92
349
  * Open (and initialise / migrate) the state database.
@@ -114,34 +371,106 @@ export function getStateDbPath() {
114
371
  * matches the value used in openDatabase() for index.db; 5 s proved too
115
372
  * narrow when a post-inference reindex overlapped a parallel event write.
116
373
  */
117
- export function openStateDatabase(dbPath) {
374
+ export function openStateDatabase(dbPath, options) {
118
375
  const canonicalPath = getStateDbPath();
119
376
  const resolvedPath = dbPath ?? canonicalPath;
377
+ // `:memory:` is a SQLite connection identity, not a filesystem pathname.
378
+ // Never pass it through the durable-file reservation/inode/snapshot path:
379
+ // doing so creates a literal `:memory:` file and makes later in-process
380
+ // opens look like an unversioned durable database. Each in-memory handle is
381
+ // fresh and cannot be path-swapped, so the ownership proof is intrinsically
382
+ // satisfied for this explicit test/internal seam.
383
+ if (resolvedPath === ":memory:") {
384
+ return openManagedDatabase({
385
+ path: resolvedPath,
386
+ pragmas: { dataDir: path.dirname(resolvedPath) },
387
+ init: (db) => runMigrations(db, {
388
+ freshDatabase: true,
389
+ verifyFreshDatabaseOwnership: () => { },
390
+ }),
391
+ });
392
+ }
120
393
  const isCanonical = path.resolve(resolvedPath) === path.resolve(canonicalPath);
121
- if (isCanonical)
122
- assertNoPendingMigrationOperation();
123
394
  const releaseActivity = isCanonical ? acquireMaintenanceActivitySync("state-db") : undefined;
395
+ let freshReservation;
396
+ let existingSource;
397
+ let openedDb;
398
+ let existingUnversionedDatabase = false;
399
+ let stateSafetyCopyCreated = false;
124
400
  try {
125
- if (isCanonical)
126
- assertNoPendingMigrationOperation();
127
- const existed = fs.existsSync(resolvedPath);
128
- if (existed) {
129
- const preflight = openDatabase(resolvedPath, { readonly: true });
401
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
402
+ freshReservation = reserveFreshStateDatabase(resolvedPath);
403
+ if (!freshReservation) {
404
+ existingSource = openExistingStateDatabaseSource(resolvedPath);
405
+ assertStateDatabaseSource(existingSource);
406
+ const preflight = openDatabase(sqliteBoundFilePath(existingSource, "Existing state.db preflight"), {
407
+ readonly: true,
408
+ });
130
409
  try {
410
+ assertStateDatabaseSource(existingSource);
131
411
  preflight.exec("PRAGMA busy_timeout = 30000");
132
- if (isCanonical)
133
- assertCurrentMigrationLedger(preflight, STATE_MIGRATIONS);
134
- else
135
- assertMigrationLedger(preflight, STATE_MIGRATIONS);
412
+ const ledger = assertMigrationLedger(preflight, STATE_MIGRATIONS);
413
+ existingUnversionedDatabase = ledger.migrationIds.length === 0;
414
+ if (existingUnversionedDatabase && !options?.allowHistoricalDestructiveStateUpgrade) {
415
+ throw new Error("Refusing to migrate an existing unversioned state.db during an ordinary managed open. " +
416
+ "Run `akm upgrade --force` to create a verified snapshot before migration 001.");
417
+ }
136
418
  }
137
419
  finally {
138
420
  preflight.close();
139
421
  }
140
422
  }
141
- const db = openManagedDatabase({
142
- path: resolvedPath,
143
- init: (db) => runMigrations(db, { applyPending: !(isCanonical && existed) }),
423
+ const ownedFresh = freshReservation;
424
+ const boundSource = existingSource;
425
+ if (boundSource)
426
+ assertStateDatabaseSource(boundSource);
427
+ openedDb = openManagedDatabase({
428
+ path: boundSource ? sqliteBoundFilePath(boundSource, "Managed state.db writer") : resolvedPath,
429
+ pragmas: { dataDir: path.dirname(resolvedPath) },
430
+ init: (db) => {
431
+ if (boundSource)
432
+ assertStateDatabaseSource(boundSource);
433
+ runMigrations(db, {
434
+ freshDatabase: !!ownedFresh,
435
+ verifyFreshDatabaseOwnership: ownedFresh
436
+ ? () => assertOwnedFileReservation(ownedFresh, "Fresh state.db")
437
+ : undefined,
438
+ existingUnversionedDatabase,
439
+ allowHistoricalDestructiveStateUpgrade: options?.allowHistoricalDestructiveStateUpgrade,
440
+ beforeExistingUnversionedStateMigration: options?.allowHistoricalDestructiveStateUpgrade
441
+ ? (migration) => {
442
+ if (!boundSource)
443
+ throw new Error("An existing unversioned state.db has no bound source inode.");
444
+ const safetyCopyPath = createHistoricalStateSafetyCopy(boundSource, migration.id);
445
+ stateSafetyCopyCreated = true;
446
+ options.onHistoricalStateSafetyCopy?.(safetyCopyPath);
447
+ }
448
+ : undefined,
449
+ beforeHistoricalDestructiveMigration: options?.allowHistoricalDestructiveStateUpgrade
450
+ ? (migration) => {
451
+ if (stateSafetyCopyCreated)
452
+ return;
453
+ if (!boundSource)
454
+ throw new Error("Historical state migration has no bound source inode.");
455
+ const safetyCopyPath = createHistoricalStateSafetyCopy(boundSource, migration.id);
456
+ stateSafetyCopyCreated = true;
457
+ options.onHistoricalStateSafetyCopy?.(safetyCopyPath);
458
+ }
459
+ : undefined,
460
+ });
461
+ },
144
462
  });
463
+ if (existingSource) {
464
+ assertStateDatabaseSource(existingSource);
465
+ closeFileIdentity(existingSource);
466
+ existingSource = undefined;
467
+ }
468
+ if (freshReservation) {
469
+ assertOwnedFileReservation(freshReservation, "Fresh state.db");
470
+ closeFileIdentity(freshReservation);
471
+ freshReservation = undefined;
472
+ }
473
+ const db = openedDb;
145
474
  if (!releaseActivity)
146
475
  return db;
147
476
  let closed = false;
@@ -168,10 +497,47 @@ export function openStateDatabase(dbPath) {
168
497
  };
169
498
  }
170
499
  catch (error) {
500
+ if (openedDb) {
501
+ try {
502
+ openedDb.close();
503
+ }
504
+ catch {
505
+ // Preserve the open/migration ownership failure.
506
+ }
507
+ }
508
+ if (existingSource)
509
+ closeFileIdentity(existingSource);
510
+ if (freshReservation)
511
+ closeFileIdentity(freshReservation);
171
512
  releaseActivity?.();
172
513
  throw error;
173
514
  }
174
515
  }
516
+ /**
517
+ * Narrow state-schema step owned by `akm upgrade` after executable replacement.
518
+ * Missing/current databases are no-ops. A pre-018 exact ledger is snapshotted
519
+ * beside state.db and verified before the immutable released migration runs.
520
+ */
521
+ export function upgradeHistoricalStateDatabase(dbPath = getStateDbPath()) {
522
+ if (!fs.existsSync(dbPath))
523
+ return { upgraded: false };
524
+ let safetyCopyPath;
525
+ try {
526
+ const db = openStateDatabase(dbPath, {
527
+ allowHistoricalDestructiveStateUpgrade: true,
528
+ onHistoricalStateSafetyCopy(copyPath) {
529
+ safetyCopyPath = copyPath;
530
+ },
531
+ });
532
+ db.close();
533
+ }
534
+ catch (error) {
535
+ const detail = error instanceof Error ? error.message : String(error);
536
+ const recovery = safetyCopyPath ? ` Verified safety copy: ${safetyCopyPath}.` : "";
537
+ throw new Error(`${detail}${recovery}`);
538
+ }
539
+ return safetyCopyPath ? { upgraded: true, safetyCopyPath } : { upgraded: false };
540
+ }
175
541
  /**
176
542
  * Run `fn` against state.db, owning the handle unless one is borrowed. The loan
177
543
  * helper for state.db, mirroring `withIndexDb` / `withWorkflowRunsRepo`. Pass
@@ -248,42 +614,37 @@ function sleepSyncMs(ms) {
248
614
  return;
249
615
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
250
616
  }
251
- export function withImmediateTransaction(db, fn) {
252
- // Re-entrancy guard (issue #686): if a transaction is already open on this
253
- // connection (e.g. a nested withImmediateTransaction call inside an outer
254
- // frame's fn), join it run fn directly with no BEGIN/COMMIT/ROLLBACK of
255
- // our own. Without this, the nested BEGIN throws "cannot start a transaction
256
- // within a transaction", which the old retry path answered with an
257
- // unconditional ROLLBACK destroying the OUTER transaction and leaving its
258
- // COMMIT to fail with "cannot commit - no transaction is active".
617
+ /**
618
+ * Open, but deliberately do not finish, an immediate transaction.
619
+ *
620
+ * This is the split-phase counterpart to {@link withImmediateTransaction} for
621
+ * the source-update coordinator: index finalization must mutate state.db in a
622
+ * transaction that remains pending until content, lockfile, and index
623
+ * publication have all succeeded. The caller that asked for this split phase
624
+ * owns the matching COMMIT/ROLLBACK.
625
+ */
626
+ export function beginImmediateTransaction(db) {
259
627
  if (db.inTransaction) {
260
- return fn();
628
+ throw new Error("beginImmediateTransaction requires a connection with no active transaction");
261
629
  }
262
630
  let lastBeginErr;
263
631
  for (let attempt = 1; attempt <= WITH_IMMEDIATE_TX_MAX_ATTEMPTS; attempt++) {
264
632
  try {
265
633
  db.exec("BEGIN IMMEDIATE");
266
- // bun:sqlite can return from BEGIN IMMEDIATE under writer contention WITHOUT
267
- // actually opening a transaction (no throw). That phantom state otherwise
268
- // surfaces as "cannot commit - no transaction is active" at COMMIT — AFTER
269
- // fn() has already run in autocommit, so its writes escaped the intended
270
- // serialization (the concurrent proposal-queue race). Detect it here, before
271
- // fn(), and route it through the same retry path as a contended BEGIN.
272
634
  if (!db.inTransaction) {
273
635
  throw new Error("BEGIN IMMEDIATE did not open a transaction (phantom contention state)");
274
636
  }
637
+ return;
275
638
  }
276
639
  catch (err) {
277
640
  lastBeginErr = err;
278
641
  if (isRetryableBeginError(err) && attempt < WITH_IMMEDIATE_TX_MAX_ATTEMPTS) {
279
- // Only roll back a transaction we can see — never blind-ROLLBACK, since
280
- // that could destroy a transaction this frame does not own.
281
642
  if (db.inTransaction) {
282
643
  try {
283
644
  db.exec("ROLLBACK");
284
645
  }
285
646
  catch {
286
- // Transaction already gone — fine.
647
+ // Transaction already gone — safe to retry BEGIN.
287
648
  }
288
649
  }
289
650
  sleepSyncMs(2 ** (attempt - 1));
@@ -291,33 +652,45 @@ export function withImmediateTransaction(db, fn) {
291
652
  }
292
653
  throw err;
293
654
  }
294
- try {
295
- const result = fn();
296
- if (!db.inTransaction) {
297
- // The transaction we opened vanished while fn() ran (e.g. an
298
- // auto-rollback or a stray ROLLBACK inside fn). fn's writes may have
299
- // escaped serialization, so retrying is unsafe fail loudly instead of
300
- // letting COMMIT throw the opaque "cannot commit - no transaction is
301
- // active" SQLiteError.
302
- throw new Error("withImmediateTransaction invariant violated: transaction opened by BEGIN IMMEDIATE was no longer active after the transaction body ran; refusing to COMMIT (writes may have escaped serialization)");
303
- }
304
- db.exec("COMMIT");
305
- return result;
655
+ }
656
+ throw lastBeginErr;
657
+ }
658
+ export function withImmediateTransaction(db, fn) {
659
+ // Re-entrancy guard (issue #686): if a transaction is already open on this
660
+ // connection (e.g. a nested withImmediateTransaction call inside an outer
661
+ // frame's fn), join it run fn directly with no BEGIN/COMMIT/ROLLBACK of
662
+ // our own. Without this, the nested BEGIN throws "cannot start a transaction
663
+ // within a transaction", which the old retry path answered with an
664
+ // unconditional ROLLBACK — destroying the OUTER transaction and leaving its
665
+ // COMMIT to fail with "cannot commit - no transaction is active".
666
+ if (db.inTransaction) {
667
+ return fn();
668
+ }
669
+ beginImmediateTransaction(db);
670
+ try {
671
+ const result = fn();
672
+ if (!db.inTransaction) {
673
+ // The transaction we opened vanished while fn() ran (e.g. an
674
+ // auto-rollback or a stray ROLLBACK inside fn). fn's writes may have
675
+ // escaped serialization, so retrying is unsafe — fail loudly instead of
676
+ // letting COMMIT throw the opaque "cannot commit - no transaction is
677
+ // active" SQLiteError.
678
+ throw new Error("withImmediateTransaction invariant violated: transaction opened by BEGIN IMMEDIATE was no longer active after the transaction body ran; refusing to COMMIT (writes may have escaped serialization)");
306
679
  }
307
- catch (err) {
308
- if (db.inTransaction) {
309
- try {
310
- db.exec("ROLLBACK");
311
- }
312
- catch {
313
- // Ignore rollback failures so the original error is preserved.
314
- }
680
+ db.exec("COMMIT");
681
+ return result;
682
+ }
683
+ catch (err) {
684
+ if (db.inTransaction) {
685
+ try {
686
+ db.exec("ROLLBACK");
687
+ }
688
+ catch {
689
+ // Ignore rollback failures so the original error is preserved.
315
690
  }
316
- throw err; // a real error inside the transaction body — never retried.
317
691
  }
692
+ throw err;
318
693
  }
319
- // Exhausted retries on transient begin failures.
320
- throw lastBeginErr;
321
694
  }
322
695
  // ── schema introspection ─────────────────────────────────────────────────────
323
696
  /**
@@ -0,0 +1,32 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Return a Node-style system error code from an error or one of its causes.
6
+ * The bounded walk handles wrappers without trusting or parsing error text.
7
+ */
8
+ export function systemErrorCode(error) {
9
+ let current = error;
10
+ for (let depth = 0; depth < 8; depth += 1) {
11
+ if (!current || typeof current !== "object")
12
+ return undefined;
13
+ const candidate = current;
14
+ if (typeof candidate.code === "string")
15
+ return candidate.code;
16
+ current = candidate.cause;
17
+ }
18
+ return undefined;
19
+ }
20
+ /** Read-only telemetry writes are expected to disappear without diagnostics. */
21
+ export function isReadOnlyFilesystemError(error) {
22
+ let current = error;
23
+ for (let depth = 0; depth < 8; depth += 1) {
24
+ if (!current || typeof current !== "object")
25
+ return false;
26
+ const candidate = current;
27
+ if (candidate.code === "EROFS" || candidate.code === "EACCES")
28
+ return true;
29
+ current = candidate.cause;
30
+ }
31
+ return false;
32
+ }