@mandujs/core 0.54.31 → 0.55.0-beta.1

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 (414) hide show
  1. package/README.md +654 -654
  2. package/package.json +106 -210
  3. package/scripts/postinstall-lock.ts +153 -153
  4. package/src/a11y/__tests__/run-audit.test.ts +333 -333
  5. package/src/a11y/fix-hints.ts +76 -76
  6. package/src/a11y/index.ts +18 -18
  7. package/src/a11y/run-audit.ts +15 -15
  8. package/src/a11y/types.ts +125 -125
  9. package/src/agent/__tests__/apply.test.ts +224 -0
  10. package/src/agent/__tests__/context.test.ts +97 -98
  11. package/src/agent/apply.ts +1031 -0
  12. package/src/agent/context.ts +552 -552
  13. package/src/agent/index.ts +4 -3
  14. package/src/agent/plan.ts +161 -243
  15. package/src/agent/repair.ts +239 -162
  16. package/src/agent/sync.ts +197 -198
  17. package/src/agent/types.ts +242 -94
  18. package/src/agent/verify.ts +55 -55
  19. package/src/auth/__tests__/login.test.ts +1 -1
  20. package/src/auth/__tests__/password.test.ts +15 -5
  21. package/src/auth/__tests__/reset.test.ts +2 -2
  22. package/src/auth/__tests__/tokens.test.ts +274 -274
  23. package/src/auth/__tests__/verification.test.ts +274 -274
  24. package/src/auth/index.ts +76 -76
  25. package/src/auth/login.ts +225 -225
  26. package/src/auth/password.ts +14 -2
  27. package/src/auth/reset.ts +243 -243
  28. package/src/auth/tokens.ts +612 -612
  29. package/src/auth/verification.ts +253 -253
  30. package/src/brain/__tests__/redactor.test.ts +94 -94
  31. package/src/brain/adapters/__tests__/_helpers.ts +83 -83
  32. package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
  33. package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
  34. package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
  35. package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
  36. package/src/brain/adapters/anthropic-oauth.ts +1 -1
  37. package/src/brain/adapters/chatgpt-auth.ts +300 -300
  38. package/src/brain/adapters/index.ts +319 -319
  39. package/src/brain/adapters/oauth-flow.ts +439 -439
  40. package/src/brain/consent.ts +240 -240
  41. package/src/brain/credentials.ts +396 -396
  42. package/src/brain/doctor/analyzer.ts +7 -7
  43. package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
  44. package/src/bundler/__tests__/build-runner.ts +166 -166
  45. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
  46. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  47. package/src/bundler/__tests__/css.test.ts +49 -20
  48. package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
  49. package/src/bundler/__tests__/extended-watch.test.ts +711 -711
  50. package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
  51. package/src/bundler/__tests__/generation.test.ts +447 -0
  52. package/src/bundler/__tests__/hdr.test.ts +24 -18
  53. package/src/bundler/__tests__/hmr-client.test.ts +62 -24
  54. package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
  55. package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
  56. package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
  57. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
  58. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
  59. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
  60. package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
  61. package/src/bundler/analyzer.ts +15 -15
  62. package/src/bundler/budget.ts +404 -404
  63. package/src/bundler/build.test.ts +898 -915
  64. package/src/bundler/build.ts +113 -24
  65. package/src/bundler/client-boundary-transform.ts +977 -977
  66. package/src/bundler/css.ts +65 -44
  67. package/src/bundler/dev.ts +105 -73
  68. package/src/bundler/fast-refresh-preamble.ts +47 -47
  69. package/src/bundler/generation.ts +602 -0
  70. package/src/bundler/index.ts +3 -3
  71. package/src/bundler/manifest-schema.ts +55 -40
  72. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  73. package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
  74. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
  75. package/src/bundler/plugins/block-generated-imports.ts +16 -15
  76. package/src/bundler/plugins/index.ts +83 -83
  77. package/src/bundler/plugins/react-compiler-config.ts +108 -108
  78. package/src/bundler/plugins/react-compiler-lint.ts +253 -253
  79. package/src/bundler/plugins/react-compiler.ts +162 -162
  80. package/src/bundler/prerender.ts +29 -9
  81. package/src/bundler/reverse-import-graph.ts +339 -339
  82. package/src/bundler/safe-build.test.ts +1 -1
  83. package/src/bundler/safe-build.ts +103 -103
  84. package/src/bundler/scenario-matrix.ts +229 -229
  85. package/src/bundler/types.ts +58 -56
  86. package/src/bundler/vendor-cache-types.ts +130 -130
  87. package/src/bundler/vendor-cache.ts +526 -526
  88. package/src/change/snapshot.ts +18 -18
  89. package/src/client/Form.tsx +105 -105
  90. package/src/client/Link.tsx +9 -9
  91. package/src/client/__tests__/props-serialization.test.ts +37 -37
  92. package/src/client/__tests__/use-sse.test.ts +153 -153
  93. package/src/client/globals.ts +1 -1
  94. package/src/client/hooks.ts +362 -362
  95. package/src/client/hydrate.ts +2 -2
  96. package/src/client/index.ts +1 -1
  97. package/src/client/island.ts +79 -79
  98. package/src/client/prefetch-helper.ts +55 -55
  99. package/src/client/props-serialization.ts +233 -233
  100. package/src/client/runtime-entry.ts +598 -598
  101. package/src/client/runtime.ts +1 -1
  102. package/src/client/serialize.ts +50 -50
  103. package/src/client/use-fetch.ts +6 -6
  104. package/src/client/use-head.ts +197 -197
  105. package/src/client/use-sse.ts +378 -378
  106. package/src/client/window-state.ts +101 -101
  107. package/src/components/Image-compat.ts +3 -0
  108. package/src/components/Image.tsx +162 -162
  109. package/src/config/validate.ts +1 -1
  110. package/src/config/watcher.ts +311 -311
  111. package/src/constants.ts +40 -40
  112. package/src/content/collection.ts +9 -9
  113. package/src/content/content-layer.ts +7 -7
  114. package/src/content/data-store.ts +245 -245
  115. package/src/content/frontmatter.ts +189 -189
  116. package/src/content/generate-types.ts +1 -1
  117. package/src/content/index.ts +2 -2
  118. package/src/content/loader-context.ts +171 -171
  119. package/src/content/loaders/api.ts +216 -216
  120. package/src/content/loaders/file.ts +172 -172
  121. package/src/content/loaders/glob.ts +253 -253
  122. package/src/content/loaders/index.ts +34 -34
  123. package/src/content/loaders/types.ts +137 -137
  124. package/src/content/meta-store.ts +209 -209
  125. package/src/content/prebuild.test.ts +571 -571
  126. package/src/content/prebuild.ts +636 -636
  127. package/src/content/schema.ts +20 -20
  128. package/src/content/sidebar.ts +630 -630
  129. package/src/content/slug.ts +110 -110
  130. package/src/content/types.ts +282 -282
  131. package/src/content/watcher.ts +135 -135
  132. package/src/contract/client-safe.test.ts +42 -42
  133. package/src/contract/client-safe.ts +114 -114
  134. package/src/contract/define.ts +11 -11
  135. package/src/contract/index.ts +1 -1
  136. package/src/contract/normalize.test.ts +276 -276
  137. package/src/contract/normalize.ts +410 -410
  138. package/src/contract/registry.test.ts +206 -206
  139. package/src/contract/route-helpers.ts +1 -1
  140. package/src/contract/rpc.ts +443 -443
  141. package/src/contract/types.ts +58 -58
  142. package/src/db/__tests__/db.test.ts +9 -9
  143. package/src/db/index.ts +2 -2
  144. package/src/db/migrations/history-table.ts +345 -345
  145. package/src/db/migrations/index.ts +3 -3
  146. package/src/db/migrations/lock.ts +324 -324
  147. package/src/db/migrations/runner.ts +650 -650
  148. package/src/deploy/cache.ts +140 -140
  149. package/src/deploy/compile/vercel.ts +344 -344
  150. package/src/deploy/index.ts +87 -87
  151. package/src/deploy/inference/brain.ts +268 -268
  152. package/src/deploy/inference/context.ts +82 -82
  153. package/src/deploy/inference/filling-extract.ts +245 -245
  154. package/src/deploy/inference/heuristic.ts +182 -182
  155. package/src/deploy/intent.ts +173 -173
  156. package/src/deploy/plan.ts +178 -178
  157. package/src/design/__tests__/agents-link.test.ts +109 -109
  158. package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
  159. package/src/design/__tests__/lint.test.ts +110 -110
  160. package/src/design/__tests__/parser.test.ts +195 -195
  161. package/src/design/__tests__/tailwind-theme.test.ts +229 -229
  162. package/src/design/agents-link.ts +165 -165
  163. package/src/design/diff.ts +138 -138
  164. package/src/design/extract.ts +285 -285
  165. package/src/design/index.ts +102 -102
  166. package/src/design/lint.ts +209 -209
  167. package/src/design/parser.ts +555 -555
  168. package/src/design/patch.ts +241 -241
  169. package/src/design/scaffold.ts +147 -147
  170. package/src/design/tailwind-theme.ts +441 -441
  171. package/src/design/types.ts +210 -210
  172. package/src/desktop/__tests__/smoke.test.ts +2 -2
  173. package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
  174. package/src/desktop/__tests__/window.test.ts +20 -99
  175. package/src/desktop/__tests__/worker.test.ts +266 -266
  176. package/src/desktop/index.ts +43 -43
  177. package/src/desktop/types.ts +158 -158
  178. package/src/desktop/webview-fallback.ts +17 -4
  179. package/src/desktop/window.ts +17 -8
  180. package/src/desktop/worker.ts +180 -180
  181. package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
  182. package/src/dev-error-overlay/index.ts +30 -30
  183. package/src/dev-error-overlay/overlay-injector.ts +243 -243
  184. package/src/dev-error-overlay/overlay-styles.ts +52 -52
  185. package/src/dev-error-overlay/types.ts +66 -66
  186. package/src/devtools/ai/context-builder.ts +375 -375
  187. package/src/devtools/ai/index.ts +25 -25
  188. package/src/devtools/ai/mcp-connector.ts +25 -25
  189. package/src/devtools/client/catchers/error-catcher.ts +344 -344
  190. package/src/devtools/client/catchers/index.ts +18 -18
  191. package/src/devtools/client/components/index.ts +39 -39
  192. package/src/devtools/client/components/mandu-character.tsx +331 -331
  193. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  194. package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
  195. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  196. package/src/devtools/client/components/panel/network-panel.tsx +291 -291
  197. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  198. package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
  199. package/src/devtools/client/filters/context-filters.ts +282 -282
  200. package/src/devtools/client/filters/index.ts +16 -16
  201. package/src/devtools/client/index.ts +63 -63
  202. package/src/devtools/hook/create-hook.ts +207 -207
  203. package/src/devtools/hook/index.ts +13 -13
  204. package/src/devtools/index.ts +439 -439
  205. package/src/devtools/init.ts +265 -265
  206. package/src/devtools/protocol.ts +237 -237
  207. package/src/devtools/server/index.ts +17 -17
  208. package/src/devtools/types.ts +35 -35
  209. package/src/devtools/worker/index.ts +25 -25
  210. package/src/devtools/worker/redaction-worker.ts +233 -233
  211. package/src/diagnose/__tests__/checks.test.ts +136 -136
  212. package/src/diagnose/checks.ts +185 -185
  213. package/src/diagnose/index.ts +17 -17
  214. package/src/diagnose/run.ts +10 -10
  215. package/src/diagnose/types.ts +53 -53
  216. package/src/email/__tests__/email.test.ts +355 -355
  217. package/src/email/index.ts +282 -282
  218. package/src/email/smtp.ts +64 -64
  219. package/src/error/domains.ts +265 -265
  220. package/src/error/types.ts +6 -6
  221. package/src/errors/extractor.ts +409 -409
  222. package/src/errors/index.ts +19 -19
  223. package/src/filling/__tests__/session-sqlite.test.ts +5 -1
  224. package/src/filling/auth.ts +308 -308
  225. package/src/filling/body-parse.test.ts +60 -60
  226. package/src/filling/cookie-codec.ts +5 -3
  227. package/src/filling/deps.ts +265 -265
  228. package/src/filling/head-method.test.ts +154 -154
  229. package/src/filling/session-sqlite.ts +617 -617
  230. package/src/filling/sse.ts +5 -5
  231. package/src/filling/ws.ts +78 -78
  232. package/src/generator/generate.ts +30 -30
  233. package/src/generator/index.ts +3 -3
  234. package/src/generator/templates.test.ts +48 -48
  235. package/src/generator/templates.ts +219 -219
  236. package/src/guard/__tests__/design-inline-class.test.ts +219 -219
  237. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
  238. package/src/guard/analyzer.ts +360 -360
  239. package/src/guard/auto-correct.ts +1 -1
  240. package/src/guard/check.ts +39 -24
  241. package/src/guard/config-guard.ts +13 -13
  242. package/src/guard/contract-guard.ts +9 -9
  243. package/src/guard/define-rule.ts +243 -243
  244. package/src/guard/design-inline-class.ts +393 -393
  245. package/src/guard/file-type.test.ts +24 -24
  246. package/src/guard/fs-routes-policy.ts +51 -51
  247. package/src/guard/graph.ts +1 -1
  248. package/src/guard/healing.ts +36 -36
  249. package/src/guard/index.ts +11 -11
  250. package/src/guard/presets/atomic.ts +70 -70
  251. package/src/guard/presets/clean.ts +77 -77
  252. package/src/guard/presets/fsd.ts +79 -79
  253. package/src/guard/presets/hexagonal.ts +68 -68
  254. package/src/guard/reporter.ts +442 -442
  255. package/src/guard/rule-presets.ts +379 -379
  256. package/src/guard/semantic-slots.ts +1 -1
  257. package/src/guard/suggestions.ts +358 -358
  258. package/src/guard/tsgolint-bridge.ts +512 -512
  259. package/src/guard/types.ts +348 -348
  260. package/src/guard/watcher.ts +405 -405
  261. package/src/i18n/define.ts +126 -126
  262. package/src/i18n/index.ts +52 -52
  263. package/src/i18n/message-registry.ts +173 -173
  264. package/src/i18n/types.ts +112 -112
  265. package/src/id/__tests__/id.test.ts +3 -3
  266. package/src/id/index.ts +105 -105
  267. package/src/index.ts +9 -48
  268. package/src/internal/client-boundary.ts +266 -266
  269. package/src/internal/index.ts +2 -2
  270. package/src/kitchen/api/agent-devtools-api.ts +779 -779
  271. package/src/kitchen/api/bundle-inspector.ts +179 -0
  272. package/src/kitchen/api/errors-grouping.ts +126 -126
  273. package/src/kitchen/api/file-api.ts +11 -11
  274. package/src/kitchen/kitchen-handler.ts +36 -0
  275. package/src/kitchen/kitchen-ui.ts +456 -0
  276. package/src/logging/index.ts +22 -22
  277. package/src/logging/transports.ts +365 -365
  278. package/src/middleware/bridge.ts +147 -147
  279. package/src/middleware/compose.ts +134 -134
  280. package/src/middleware/compress.ts +62 -62
  281. package/src/middleware/cors.ts +47 -47
  282. package/src/middleware/csrf.ts +328 -328
  283. package/src/middleware/define.ts +132 -132
  284. package/src/middleware/jwt.ts +134 -134
  285. package/src/middleware/logger.ts +58 -58
  286. package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
  287. package/src/middleware/oauth/index.ts +505 -505
  288. package/src/middleware/oauth/providers.ts +115 -115
  289. package/src/middleware/rate-limit/index.ts +522 -522
  290. package/src/middleware/rate-limit/sqlite-store.ts +382 -382
  291. package/src/middleware/scheduler-cron.ts +96 -96
  292. package/src/middleware/secure/__tests__/secure.test.ts +360 -360
  293. package/src/middleware/secure/csp.ts +193 -193
  294. package/src/middleware/session.ts +174 -174
  295. package/src/middleware/timeout.ts +55 -55
  296. package/src/observability/logger-adapter.ts +36 -36
  297. package/src/observability/sqlite-store.ts +254 -254
  298. package/src/openapi/generator.ts +1 -1
  299. package/src/openapi/openapi.test.ts +43 -43
  300. package/src/perf/__tests__/user-marks.test.ts +354 -354
  301. package/src/perf/index.ts +133 -133
  302. package/src/perf/user-marks.ts +1 -1
  303. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
  304. package/src/plugins/__tests__/runner.test.ts +409 -409
  305. package/src/plugins/define.ts +124 -124
  306. package/src/plugins/examples/dep-check-plugin.ts +80 -80
  307. package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
  308. package/src/plugins/examples/sitemap-plugin.ts +65 -65
  309. package/src/plugins/runner.ts +361 -361
  310. package/src/plugins/types.ts +368 -368
  311. package/src/report/index.ts +1 -1
  312. package/src/resource/__tests__/generator.test.ts +7 -7
  313. package/src/resource/__tests__/schema.test.ts +14 -14
  314. package/src/resource/ddl/__tests__/diff.test.ts +639 -639
  315. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  316. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
  317. package/src/resource/ddl/emit.ts +146 -146
  318. package/src/resource/ddl/persistence-types.ts +218 -218
  319. package/src/resource/ddl/type-map.ts +223 -223
  320. package/src/resource/ddl/types.ts +232 -232
  321. package/src/resource/generator-repo.ts +630 -630
  322. package/src/resource/generator-schema.ts +11 -11
  323. package/src/resource/generators/slot.ts +72 -72
  324. package/src/resource/schema.ts +21 -21
  325. package/src/router/client-entry.test.ts +227 -227
  326. package/src/router/client-entry.ts +218 -218
  327. package/src/router/fs-patterns.test.ts +96 -96
  328. package/src/router/fs-routes.test.ts +532 -532
  329. package/src/router/fs-routes.ts +53 -53
  330. package/src/router/fs-scanner.ts +216 -216
  331. package/src/router/fs-types.ts +19 -19
  332. package/src/router/route-source-analyzer.ts +521 -521
  333. package/src/routes/index.ts +74 -74
  334. package/src/routes/metadata-routes.ts +427 -427
  335. package/src/routes/types.ts +341 -341
  336. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  337. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
  338. package/src/runtime/__tests__/hdr-client.test.ts +223 -223
  339. package/src/runtime/__tests__/http-errors.test.ts +117 -117
  340. package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
  341. package/src/runtime/__tests__/not-found.test.ts +152 -152
  342. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  343. package/src/runtime/__tests__/page-render-response.test.ts +517 -517
  344. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  345. package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
  346. package/src/runtime/adapter.ts +47 -47
  347. package/src/runtime/boundary.tsx +252 -252
  348. package/src/runtime/cache.ts +494 -494
  349. package/src/runtime/compose.ts +222 -222
  350. package/src/runtime/devtools-adapter.ts +68 -68
  351. package/src/runtime/escape.ts +34 -34
  352. package/src/runtime/fast-refresh-runtime.ts +322 -322
  353. package/src/runtime/fast-refresh-types.ts +1 -1
  354. package/src/runtime/handler.ts +65 -65
  355. package/src/runtime/handlers.ts +30 -11
  356. package/src/runtime/http-errors.ts +113 -113
  357. package/src/runtime/image-handler.ts +1 -1
  358. package/src/runtime/lifecycle.ts +381 -381
  359. package/src/runtime/logger.test.ts +345 -345
  360. package/src/runtime/middleware.ts +264 -264
  361. package/src/runtime/not-found.ts +93 -93
  362. package/src/runtime/observability-lifecycle.ts +290 -290
  363. package/src/runtime/openapi-endpoint.ts +236 -236
  364. package/src/runtime/page-render-response.ts +287 -274
  365. package/src/runtime/ppr.ts +74 -74
  366. package/src/runtime/rate-limit.ts +1 -1
  367. package/src/runtime/redirect.ts +1 -1
  368. package/src/runtime/registry.ts +171 -171
  369. package/src/runtime/request-middleware.ts +31 -31
  370. package/src/runtime/router.test.ts +4 -4
  371. package/src/runtime/router.ts +10 -10
  372. package/src/runtime/server.ts +39 -20
  373. package/src/runtime/shims.ts +48 -48
  374. package/src/runtime/ssr.ts +43 -17
  375. package/src/runtime/static-files.ts +369 -289
  376. package/src/runtime/streaming-ssr.ts +219 -180
  377. package/src/runtime/trace.ts +144 -144
  378. package/src/scheduler/__tests__/scheduler.test.ts +12 -11
  379. package/src/scheduler/index.ts +11 -5
  380. package/src/scheduler/validate.ts +169 -169
  381. package/src/seo/index.ts +219 -219
  382. package/src/seo/integration/ssr.ts +306 -306
  383. package/src/seo/render/basic.ts +435 -435
  384. package/src/seo/render/index.ts +143 -143
  385. package/src/seo/render/jsonld.ts +539 -539
  386. package/src/seo/render/opengraph.ts +197 -197
  387. package/src/seo/render/robots.ts +116 -116
  388. package/src/seo/render/sitemap.ts +137 -137
  389. package/src/seo/render/twitter.ts +127 -127
  390. package/src/seo/resolve/opengraph.ts +143 -143
  391. package/src/seo/resolve/robots.ts +73 -73
  392. package/src/seo/resolve/title.ts +94 -94
  393. package/src/seo/resolve/twitter.ts +73 -73
  394. package/src/seo/resolve/url.ts +104 -104
  395. package/src/seo/routes/index.ts +290 -290
  396. package/src/seo/types.ts +588 -588
  397. package/src/slot/validator.ts +39 -39
  398. package/src/spec/schema.ts +35 -35
  399. package/src/storage/s3/__tests__/s3.test.ts +479 -479
  400. package/src/storage/s3/index.ts +412 -412
  401. package/src/testing/__tests__/assertions.test.ts +632 -632
  402. package/src/testing/__tests__/reporter.test.ts +454 -454
  403. package/src/testing/assertions.ts +986 -986
  404. package/src/testing/db.ts +157 -157
  405. package/src/testing/index.ts +9 -3
  406. package/src/testing/lcov.ts +192 -0
  407. package/src/testing/mocks.ts +203 -203
  408. package/src/testing/session.ts +190 -190
  409. package/src/types/branded.ts +56 -56
  410. package/src/types/index.ts +1 -1
  411. package/src/utils/safe-io.ts +188 -188
  412. package/src/utils/string-safe.ts +298 -298
  413. package/src/watcher/__tests__/watcher.test.ts +59 -59
  414. package/src/watcher/watcher.ts +61 -61
@@ -1,650 +1,650 @@
1
- /**
2
- * @mandujs/core/db/migrations/runner
3
- *
4
- * Migration runtime for Mandu — the single source of truth for:
5
- *
6
- * 1. Reading migration files from disk (`NNNN_description.sql`).
7
- * 2. Keeping a history of what has been applied in the database's own
8
- * `__mandu_migrations` table.
9
- * 3. Verifying that on-disk files haven't drifted from what we
10
- * applied (checksum tamper detection).
11
- * 4. Atomically applying pending migrations with per-dialect
12
- * serialisation (advisory lock / GET_LOCK / BEGIN IMMEDIATE).
13
- *
14
- * ## Flow
15
- *
16
- * ```
17
- * ensureHistoryTable()
18
- * ↓
19
- * plan() → reads disk, diffs against history, returns pending
20
- * ↓
21
- * apply() → acquires lock, runs each pending file in its own tx,
22
- * inserts history row on success, aborts on first
23
- * failure (previously applied rows persist)
24
- * ↓
25
- * status() → combined snapshot of applied / pending / tampered /
26
- * orphaned
27
- * ```
28
- *
29
- * ## Tamper detection
30
- *
31
- * When a migration file on disk has been modified after it was applied,
32
- * its SHA-256 checksum no longer matches the one stored in
33
- * `__mandu_migrations`. Such rows are surfaced by `status()` as
34
- * `tampered`. `apply()` refuses to advance past a tampered row and
35
- * throws {@link MigrationTamperedError} naming the file + both
36
- * checksums — the operator must either revert the file or use
37
- * `mandu db reset --allow-tamper --force` (Agent E's CLI) to forcibly
38
- * reset history.
39
- *
40
- * ## Transaction semantics
41
- *
42
- * Every migration file is applied inside its own `db.transaction()`
43
- * call. A crash or SQL error during a migration rolls back every
44
- * statement in that file AND omits the history row — the next
45
- * `apply()` retries from exactly that version. Migrations earlier in
46
- * the sequence are not touched.
47
- *
48
- * ## v1 limitations (documented for upstream consumers)
49
- *
50
- * - Statement splitter is a simple "semicolon at end of line" split. A
51
- * single migration file that includes a `;` inside a string literal
52
- * on its own line will mis-split. Works for 99% of hand-written
53
- * migrations. See {@link splitStatements} for the exact rule.
54
- * - No rollback / DOWN migrations.
55
- * - No cross-process distributed lock beyond the dialect primitives
56
- * Bun.SQL exposes.
57
- *
58
- * @module db/migrations/runner
59
- */
60
-
61
- import { promises as fs } from "node:fs";
62
- import { createHash } from "node:crypto";
63
- import path from "node:path";
64
-
65
- import type {
66
- AppliedMigration,
67
- LockStrategy,
68
- MigrationStatus,
69
- PendingMigration,
70
- SqlProvider,
71
- } from "../../resource/ddl/types";
72
- import { withPinnedDbHandle, type Db } from "../index";
73
- import {
74
- DEFAULT_HISTORY_TABLE,
75
- SAFE_HISTORY_TABLE_RE,
76
- historyTableDdl,
77
- insertHistory,
78
- readAllHistory,
79
- type HistoryRow,
80
- } from "./history-table";
81
- import { acquireMigrationLock, type MigrationLock } from "./lock";
82
-
83
- // ─── Public errors ──────────────────────────────────────────────────────────
84
-
85
- /**
86
- * Thrown when a migration file on disk has a different checksum than
87
- * the one stored in `__mandu_migrations`. `apply()` refuses to proceed;
88
- * the operator must resolve the drift.
89
- */
90
- export class MigrationTamperedError extends Error {
91
- readonly name = "MigrationTamperedError";
92
- readonly filename: string;
93
- readonly storedChecksum: string;
94
- readonly currentChecksum: string;
95
-
96
- constructor(filename: string, storedChecksum: string, currentChecksum: string) {
97
- super(
98
- `[@mandujs/core/db/migrations] Migration ${filename} has been modified ` +
99
- `since it was applied. Stored checksum: ${storedChecksum}, current: ${currentChecksum}. ` +
100
- `Revert the file or run 'mandu db reset --allow-tamper --force' to reset history.`,
101
- );
102
- this.filename = filename;
103
- this.storedChecksum = storedChecksum;
104
- this.currentChecksum = currentChecksum;
105
- }
106
- }
107
-
108
- /**
109
- * Thrown when a migration file times out. `applyTimeoutMs` is checked
110
- * after each statement — we don't attempt to kill the underlying
111
- * connection (Bun.SQL doesn't expose that), but we do refuse to insert
112
- * a history row for the timed-out migration.
113
- */
114
- export class MigrationTimeoutError extends Error {
115
- readonly name = "MigrationTimeoutError";
116
- readonly filename: string;
117
- readonly elapsedMs: number;
118
- readonly timeoutMs: number;
119
-
120
- constructor(filename: string, elapsedMs: number, timeoutMs: number) {
121
- super(
122
- `[@mandujs/core/db/migrations] Migration ${filename} exceeded ${timeoutMs}ms ` +
123
- `(elapsed: ${elapsedMs}ms). History not recorded; retry via 'mandu db apply'.`,
124
- );
125
- this.filename = filename;
126
- this.elapsedMs = elapsedMs;
127
- this.timeoutMs = timeoutMs;
128
- }
129
- }
130
-
131
- function assertNoTamperedHistory(
132
- history: HistoryRow[],
133
- diskByVersion: Map<string, PendingMigration>,
134
- ): void {
135
- for (const row of history) {
136
- if (row.success !== 1) continue;
137
- const disk = diskByVersion.get(row.version);
138
- if (!disk) continue; // orphan on the history side — surfaced via status(), not apply()
139
- if (disk.checksum !== row.checksum) {
140
- throw new MigrationTamperedError(
141
- disk.filename,
142
- row.checksum,
143
- disk.checksum,
144
- );
145
- }
146
- }
147
- }
148
-
149
- // ─── Public API ─────────────────────────────────────────────────────────────
150
-
151
- /** Options for {@link createMigrationRunner}. */
152
- export interface MigrationRunnerOptions {
153
- /** Absolute path to the migrations directory. Must exist at call time. */
154
- migrationsDir: string;
155
- /**
156
- * Lock strategy. Defaults derived from `db.provider`:
157
- * - `postgres` → `pg_advisory_lock`
158
- * - `mysql` → `mysql_get_lock`
159
- * - `sqlite` → `sqlite_immediate`
160
- *
161
- * Pass `"none"` in test suites that don't need serialisation.
162
- */
163
- lockStrategy?: LockStrategy;
164
- /** History table name. Default: `"__mandu_migrations"`. */
165
- historyTable?: string;
166
- /**
167
- * Per-migration-file timeout in milliseconds. When the total
168
- * elapsed time for a single migration file exceeds this value, the
169
- * runner aborts the file and throws {@link MigrationTimeoutError}
170
- * WITHOUT recording a history row. Default: `60_000` (60 s).
171
- */
172
- applyTimeoutMs?: number;
173
- }
174
-
175
- /** The runner returned by {@link createMigrationRunner}. */
176
- export interface MigrationRunner {
177
- /** Idempotent creation of the history table. */
178
- ensureHistoryTable(): Promise<void>;
179
- /**
180
- * Return every migration file on disk that has no successful history
181
- * row, sorted by version. Checksums are computed fresh from file
182
- * bytes — never cached.
183
- */
184
- plan(): Promise<PendingMigration[]>;
185
- /**
186
- * Apply all pending migrations. Each file runs in its own
187
- * transaction; a failure in file N leaves files 0..N-1 applied and
188
- * N..∞ pending. The runner holds the migration lock for the duration
189
- * of `apply()` — multiple concurrent callers serialise.
190
- *
191
- * `dryRun: true` reports what WOULD be applied without executing SQL
192
- * and without inserting history rows.
193
- */
194
- apply(options?: { dryRun?: boolean }): Promise<AppliedMigration[]>;
195
- /** Combined snapshot: applied + pending + tampered + orphaned. */
196
- status(): Promise<MigrationStatus>;
197
- /** Idempotent release of any held lock. Does NOT close the Db. */
198
- dispose(): Promise<void>;
199
- }
200
-
201
- /**
202
- * Factory — wraps a `Db` handle with migration-runtime affordances.
203
- * Construction is cheap (no IO); the first operation lazily creates
204
- * the history table if it doesn't exist yet.
205
- */
206
- export function createMigrationRunner(
207
- db: Db,
208
- options: MigrationRunnerOptions,
209
- ): MigrationRunner {
210
- if (!options || typeof options.migrationsDir !== "string" || options.migrationsDir.length === 0) {
211
- throw new TypeError(
212
- "[@mandujs/core/db/migrations] createMigrationRunner: 'migrationsDir' is required.",
213
- );
214
- }
215
-
216
- const historyTable = options.historyTable ?? DEFAULT_HISTORY_TABLE;
217
- if (!SAFE_HISTORY_TABLE_RE.test(historyTable)) {
218
- throw new Error(
219
- `[@mandujs/core/db/migrations] Invalid history table name ${JSON.stringify(historyTable)}. ` +
220
- `Must match ${SAFE_HISTORY_TABLE_RE}.`,
221
- );
222
- }
223
-
224
- const lockStrategy: LockStrategy =
225
- options.lockStrategy ?? defaultLockStrategy(db.provider);
226
-
227
- const applyTimeoutMs =
228
- typeof options.applyTimeoutMs === "number" && options.applyTimeoutMs > 0
229
- ? options.applyTimeoutMs
230
- : 60_000;
231
-
232
- let historyReady = false;
233
- let heldLock: MigrationLock | null = null;
234
- const migrationsDir = options.migrationsDir;
235
-
236
- async function ensureHistoryTable(): Promise<void> {
237
- if (historyReady) return;
238
- const ddl = historyTableDdl(historyTable, db.provider);
239
- await execRaw(db, ddl);
240
- historyReady = true;
241
- }
242
-
243
- async function ensureReady(): Promise<void> {
244
- // Keep the "call ensureHistoryTable() first" explicit in the spec
245
- // but do the right thing implicitly: auto-initialise on first op.
246
- // This matches the ergonomic of Phase 4b's session storage.
247
- if (!historyReady) {
248
- await ensureHistoryTable();
249
- }
250
- }
251
-
252
- async function plan(): Promise<PendingMigration[]> {
253
- await ensureReady();
254
- const [diskFiles, history] = await Promise.all([
255
- readMigrationsFromDisk(migrationsDir),
256
- readAllHistory(db, historyTable),
257
- ]);
258
- const appliedVersions = new Set(
259
- history.filter((h) => h.success === 1).map((h) => h.version),
260
- );
261
- return diskFiles.filter((f) => !appliedVersions.has(f.version));
262
- }
263
-
264
- async function apply(
265
- opts: { dryRun?: boolean } = {},
266
- ): Promise<AppliedMigration[]> {
267
- const diskFiles = await readMigrationsFromDisk(migrationsDir);
268
- const diskByVersion = new Map(diskFiles.map((f) => [f.version, f]));
269
-
270
- if (opts.dryRun === true) {
271
- await ensureReady();
272
- const history = await readAllHistory(db, historyTable);
273
- assertNoTamperedHistory(history, diskByVersion);
274
-
275
- const appliedVersions = new Set(
276
- history.filter((h) => h.success === 1).map((h) => h.version),
277
- );
278
- const pending = diskFiles.filter((f) => !appliedVersions.has(f.version));
279
- return pending.map<AppliedMigration>((p) => ({
280
- version: p.version,
281
- filename: p.filename,
282
- checksum: p.checksum,
283
- appliedAt: new Date(),
284
- executionMs: 0,
285
- success: false, // dry-run is not real — mark as not-yet-applied
286
- }));
287
- }
288
-
289
- const installedBy =
290
- (typeof process !== "undefined" && process.env?.MANDU_MIGRATION_USER) ||
291
- "mandu";
292
-
293
- const applied: AppliedMigration[] = [];
294
-
295
- const runLockedApply = async (): Promise<AppliedMigration[]> => {
296
- heldLock = await acquireMigrationLock(db, lockStrategy);
297
- try {
298
- await ensureReady();
299
- const lockedHistory = await readAllHistory(db, historyTable);
300
- assertNoTamperedHistory(lockedHistory, diskByVersion);
301
- const lockedAppliedVersions = new Set(
302
- lockedHistory.filter((h) => h.success === 1).map((h) => h.version),
303
- );
304
- const lockedPending = diskFiles.filter((f) => !lockedAppliedVersions.has(f.version));
305
-
306
- for (const migration of lockedPending) {
307
- const start = Date.now();
308
-
309
- const statements = splitStatements(migration.sql);
310
- if (statements.length === 0) {
311
- // Empty migration — still record a history row so we don't
312
- // re-run it. execution_ms = 0 reflects reality.
313
- await insertHistory(db, historyTable, {
314
- version: migration.version,
315
- filename: migration.filename,
316
- checksum: migration.checksum,
317
- applied_at: new Date(),
318
- execution_ms: 0,
319
- success: 1,
320
- installed_by: installedBy,
321
- });
322
- applied.push({
323
- version: migration.version,
324
- filename: migration.filename,
325
- checksum: migration.checksum,
326
- appliedAt: new Date(),
327
- executionMs: 0,
328
- success: true,
329
- });
330
- continue;
331
- }
332
-
333
- try {
334
- await db.transaction(async (tx) => {
335
- for (const stmt of statements) {
336
- await execRaw(tx, stmt);
337
- const elapsed = Date.now() - start;
338
- if (elapsed > applyTimeoutMs) {
339
- throw new MigrationTimeoutError(
340
- migration.filename,
341
- elapsed,
342
- applyTimeoutMs,
343
- );
344
- }
345
- }
346
- });
347
- } catch (err) {
348
- if (err instanceof MigrationTimeoutError) throw err;
349
- // Wrap with migration context so downstream callers know
350
- // which file blew up. Preserve the original stack where
351
- // possible via `cause`.
352
- const msg = err instanceof Error ? err.message : String(err);
353
- const wrapped = new Error(
354
- `[@mandujs/core/db/migrations] Failed to apply ${migration.filename}: ${msg}`,
355
- );
356
- // Preserve the original as a `cause` chain for diagnostics.
357
- (wrapped as { cause?: unknown }).cause = err;
358
- throw wrapped;
359
- }
360
-
361
- const executionMs = Date.now() - start;
362
- const appliedAt = new Date();
363
-
364
- // History row is written AFTER the SQL transaction commits.
365
- // If this INSERT itself fails, the migration has run but we
366
- // have no record — the user will see it as pending again.
367
- // Mitigation: the insert is a single tiny statement; in
368
- // practice it either succeeds or the whole connection is
369
- // dead (in which case subsequent apply() calls will also fail
370
- // and the user will debug from the DB side).
371
- await insertHistory(db, historyTable, {
372
- version: migration.version,
373
- filename: migration.filename,
374
- checksum: migration.checksum,
375
- applied_at: appliedAt,
376
- execution_ms: executionMs,
377
- success: 1,
378
- installed_by: installedBy,
379
- });
380
-
381
- applied.push({
382
- version: migration.version,
383
- filename: migration.filename,
384
- checksum: migration.checksum,
385
- appliedAt,
386
- executionMs,
387
- success: true,
388
- });
389
- }
390
- } finally {
391
- if (heldLock) {
392
- await heldLock.release();
393
- heldLock = null;
394
- }
395
- }
396
-
397
- return applied;
398
- };
399
-
400
- if (lockStrategy === "mysql_get_lock") {
401
- return await withPinnedDbHandle(db, runLockedApply);
402
- }
403
- return await runLockedApply();
404
- }
405
-
406
- async function status(): Promise<MigrationStatus> {
407
- await ensureReady();
408
-
409
- const [diskFiles, history] = await Promise.all([
410
- readMigrationsFromDisk(migrationsDir),
411
- readAllHistory(db, historyTable),
412
- ]);
413
-
414
- const diskByVersion = new Map(diskFiles.map((f) => [f.version, f]));
415
-
416
- const applied: AppliedMigration[] = [];
417
- const tampered: MigrationStatus["tampered"] = [];
418
- for (const row of history) {
419
- if (row.success !== 1) continue;
420
- const disk = diskByVersion.get(row.version);
421
- if (disk && disk.checksum !== row.checksum) {
422
- tampered.push({
423
- version: row.version,
424
- filename: disk.filename,
425
- storedChecksum: row.checksum,
426
- currentChecksum: disk.checksum,
427
- });
428
- }
429
- applied.push({
430
- version: row.version,
431
- filename: row.filename,
432
- checksum: row.checksum,
433
- appliedAt: row.applied_at,
434
- executionMs: row.execution_ms,
435
- success: true,
436
- });
437
- }
438
-
439
- const appliedVersions = new Set(
440
- history.filter((h) => h.success === 1).map((h) => h.version),
441
- );
442
- const pending = diskFiles.filter((f) => !appliedVersions.has(f.version));
443
-
444
- // "orphaned" = files that exist on disk, have NO matching history
445
- // row, AND are already in `pending` — by definition they'd show up
446
- // in `pending`. The spec uses `orphaned` for the inverse (rare):
447
- // history rows with no file on disk. We capture the latter so
448
- // operators can spot a deleted file that was already applied.
449
- const diskVersions = new Set(diskFiles.map((f) => f.version));
450
- const orphaned: MigrationStatus["orphaned"] = [];
451
- for (const row of history) {
452
- if (!diskVersions.has(row.version)) {
453
- orphaned.push({ filename: row.filename });
454
- }
455
- }
456
-
457
- return { applied, pending, tampered, orphaned };
458
- }
459
-
460
- async function dispose(): Promise<void> {
461
- if (heldLock) {
462
- await heldLock.release();
463
- heldLock = null;
464
- }
465
- // Explicitly do NOT close the Db — ownership belongs to the caller
466
- // (per the module JSDoc).
467
- }
468
-
469
- return {
470
- ensureHistoryTable,
471
- plan,
472
- apply,
473
- status,
474
- dispose,
475
- };
476
- }
477
-
478
- // ─── Checksum ───────────────────────────────────────────────────────────────
479
-
480
- /**
481
- * Compute the migration checksum — SHA-256 hex, lowercase, with `\r\n`
482
- * normalised to `\n`. This is the ONLY normalisation we apply; all other
483
- * whitespace, comments, BOMs, trailing newlines are preserved as-is so
484
- * hand-edits (even cosmetic ones) are detected.
485
- *
486
- * Rationale: Flyway uses CRC-32 for the same purpose; we upgraded to
487
- * SHA-256 because CRC collides more readily when SQL is minified or
488
- * large. Full 256-bit cryptographic hash is overkill for this use-case,
489
- * but adds zero practical cost (<0.1 ms on any migration < 1 MB).
490
- */
491
- export function computeMigrationChecksum(sql: string): string {
492
- const normalized = sql.replace(/\r\n/g, "\n");
493
- return createHash("sha256").update(normalized, "utf8").digest("hex");
494
- }
495
-
496
- // ─── Filesystem discovery ───────────────────────────────────────────────────
497
-
498
- /**
499
- * Matches `NNNN_description.sql`. Version is captured as group 1.
500
- *
501
- * We require at least 4 digits (zero-padded) followed by an underscore
502
- * and at least one character of description, then `.sql`. Loose enough
503
- * to accept `0001_init.sql` and `20260401_foo.sql` equally; strict
504
- * enough to reject `migration.sql` or `init.sql` (no version prefix).
505
- */
506
- const MIGRATION_FILE_RE = /^(\d{4,})_[^/\\]+\.sql$/i;
507
-
508
- /**
509
- * Read every `NNNN_*.sql` file in `dir`, hash it, and return the result
510
- * sorted by version. Non-matching files produce a single `console.warn`
511
- * each (callers can silence via their logger wrapper).
512
- *
513
- * @throws when two files share the same version prefix.
514
- */
515
- async function readMigrationsFromDisk(dir: string): Promise<PendingMigration[]> {
516
- let entries: string[];
517
- try {
518
- entries = await fs.readdir(dir);
519
- } catch (err) {
520
- const code = (err as { code?: string }).code;
521
- if (code === "ENOENT") {
522
- // Missing migrations dir is not fatal — plan() returns []. Agent
523
- // E's CLI creates the directory on `mandu db plan`.
524
- return [];
525
- }
526
- throw err;
527
- }
528
-
529
- const seen = new Map<string, string>(); // version → filename (for duplicate detection)
530
- const results: PendingMigration[] = [];
531
-
532
- for (const entry of entries) {
533
- if (!entry.toLowerCase().endsWith(".sql")) continue;
534
- const match = MIGRATION_FILE_RE.exec(entry);
535
- if (!match) {
536
- console.warn(
537
- `[@mandujs/core/db/migrations] Ignoring ${entry}: filename does not match NNNN_description.sql pattern.`,
538
- );
539
- continue;
540
- }
541
- // Zero-pad the version to 4+ digits for stable lex ordering. The
542
- // regex already requires 4+; use the captured string verbatim.
543
- const version = match[1]!;
544
- if (seen.has(version)) {
545
- throw new Error(
546
- `[@mandujs/core/db/migrations] Duplicate migration version ${JSON.stringify(version)}: ` +
547
- `${seen.get(version)} and ${entry}.`,
548
- );
549
- }
550
- seen.set(version, entry);
551
-
552
- const fullPath = path.join(dir, entry);
553
- const [raw, stat] = await Promise.all([
554
- fs.readFile(fullPath, "utf8"),
555
- fs.stat(fullPath),
556
- ]);
557
- results.push({
558
- version,
559
- filename: entry,
560
- sql: raw,
561
- checksum: computeMigrationChecksum(raw),
562
- createdAt: stat.mtime,
563
- });
564
- }
565
-
566
- results.sort((a, b) => (a.version < b.version ? -1 : a.version > b.version ? 1 : 0));
567
- return results;
568
- }
569
-
570
- // ─── Statement splitter ─────────────────────────────────────────────────────
571
-
572
- /**
573
- * Split a migration SQL string into individual statements.
574
- *
575
- * v1 rule: split on `;` at the END of a line (or at the very end of the
576
- * file). Empty statements (whitespace only) are dropped. SQL line
577
- * comments (`--`) and multi-line (`/* … *\/`) are preserved inside each
578
- * statement so drivers see the original text.
579
- *
580
- * **Limitation** (documented for users): a `;` inside a SQL string
581
- * literal that happens to be followed by a newline WILL be mis-split.
582
- * In practice this is extremely rare in hand-authored DDL — column
583
- * definitions and constraint expressions don't contain raw semicolons.
584
- * If you hit this, collapse the offending statement onto a single line
585
- * or escape with `--` line-comment markers. A proper tokenising splitter
586
- * lands in v2 (tracked with the migration runtime's other limitations).
587
- */
588
- export function splitStatements(sql: string): string[] {
589
- // Normalise line endings for splitting; `computeMigrationChecksum`
590
- // does the same so the output is consistent across OSes.
591
- const normalised = sql.replace(/\r\n/g, "\n");
592
-
593
- const statements: string[] = [];
594
- let buffer: string[] = [];
595
-
596
- for (const line of normalised.split("\n")) {
597
- buffer.push(line);
598
- const trimmed = line.trimEnd();
599
- if (trimmed.endsWith(";")) {
600
- // Emit the statement up to (and including) this line, then drop
601
- // the trailing `;` so Bun.SQL doesn't double-terminate it.
602
- const joined = buffer.join("\n").trimEnd();
603
- const withoutTrailing = joined.slice(0, -1); // strip ;
604
- const statement = withoutTrailing.trim();
605
- if (statement.length > 0) {
606
- statements.push(statement);
607
- }
608
- buffer = [];
609
- }
610
- }
611
-
612
- // Handle a tail statement without a trailing `;`. Bun.SQL / most
613
- // drivers accept statements without a terminator; we do the same.
614
- const tail = buffer.join("\n").trim();
615
- if (tail.length > 0) {
616
- statements.push(tail);
617
- }
618
-
619
- return statements;
620
- }
621
-
622
- // ─── Defaults ───────────────────────────────────────────────────────────────
623
-
624
- /** Derive the default `LockStrategy` from the detected provider. */
625
- function defaultLockStrategy(provider: SqlProvider): LockStrategy {
626
- switch (provider) {
627
- case "postgres":
628
- return "pg_advisory_lock";
629
- case "mysql":
630
- return "mysql_get_lock";
631
- case "sqlite":
632
- return "sqlite_immediate";
633
- }
634
- }
635
-
636
- // ─── Raw SQL exec (parameter-less) ──────────────────────────────────────────
637
- //
638
- // We reuse the tagged-template surface of `@mandujs/core/db` for raw DDL
639
- // by constructing a zero-placeholder synthetic template array. The DDL
640
- // comes from trusted sources (operator-authored migration files or
641
- // Mandu-emitted history table DDL), so there's no injection surface.
642
-
643
- async function execRaw(db: Db, sql: string): Promise<void> {
644
- const strings = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray;
645
- await db(strings);
646
- }
647
-
648
- // Re-export HistoryRow so consumers doing `import { MigrationRunner } from "./runner"`
649
- // also reach row-level types without a second import site.
650
- export type { HistoryRow };
1
+ /**
2
+ * @mandujs/core/db/migrations/runner
3
+ *
4
+ * Migration runtime for Mandu — the single source of truth for:
5
+ *
6
+ * 1. Reading migration files from disk (`NNNN_description.sql`).
7
+ * 2. Keeping a history of what has been applied in the database's own
8
+ * `__mandu_migrations` table.
9
+ * 3. Verifying that on-disk files haven't drifted from what we
10
+ * applied (checksum tamper detection).
11
+ * 4. Atomically applying pending migrations with per-dialect
12
+ * serialisation (advisory lock / GET_LOCK / BEGIN IMMEDIATE).
13
+ *
14
+ * ## Flow
15
+ *
16
+ * ```
17
+ * ensureHistoryTable()
18
+ * ↓
19
+ * plan() → reads disk, diffs against history, returns pending
20
+ * ↓
21
+ * apply() → acquires lock, runs each pending file in its own tx,
22
+ * inserts history row on success, aborts on first
23
+ * failure (previously applied rows persist)
24
+ * ↓
25
+ * status() → combined snapshot of applied / pending / tampered /
26
+ * orphaned
27
+ * ```
28
+ *
29
+ * ## Tamper detection
30
+ *
31
+ * When a migration file on disk has been modified after it was applied,
32
+ * its SHA-256 checksum no longer matches the one stored in
33
+ * `__mandu_migrations`. Such rows are surfaced by `status()` as
34
+ * `tampered`. `apply()` refuses to advance past a tampered row and
35
+ * throws {@link MigrationTamperedError} naming the file + both
36
+ * checksums — the operator must either revert the file or use
37
+ * `mandu db reset --allow-tamper --force` (Agent E's CLI) to forcibly
38
+ * reset history.
39
+ *
40
+ * ## Transaction semantics
41
+ *
42
+ * Every migration file is applied inside its own `db.transaction()`
43
+ * call. A crash or SQL error during a migration rolls back every
44
+ * statement in that file AND omits the history row — the next
45
+ * `apply()` retries from exactly that version. Migrations earlier in
46
+ * the sequence are not touched.
47
+ *
48
+ * ## v1 limitations (documented for upstream consumers)
49
+ *
50
+ * - Statement splitter is a simple "semicolon at end of line" split. A
51
+ * single migration file that includes a `;` inside a string literal
52
+ * on its own line will mis-split. Works for 99% of hand-written
53
+ * migrations. See {@link splitStatements} for the exact rule.
54
+ * - No rollback / DOWN migrations.
55
+ * - No cross-process distributed lock beyond the dialect primitives
56
+ * Bun.SQL exposes.
57
+ *
58
+ * @module db/migrations/runner
59
+ */
60
+
61
+ import { promises as fs } from "node:fs";
62
+ import { createHash } from "node:crypto";
63
+ import path from "node:path";
64
+
65
+ import type {
66
+ AppliedMigration,
67
+ LockStrategy,
68
+ MigrationStatus,
69
+ PendingMigration,
70
+ SqlProvider,
71
+ } from "../../resource/ddl/types";
72
+ import { withPinnedDbHandle, type Db } from "../index";
73
+ import {
74
+ DEFAULT_HISTORY_TABLE,
75
+ SAFE_HISTORY_TABLE_RE,
76
+ historyTableDdl,
77
+ insertHistory,
78
+ readAllHistory,
79
+ type HistoryRow,
80
+ } from "./history-table";
81
+ import { acquireMigrationLock, type MigrationLock } from "./lock";
82
+
83
+ // ─── Public errors ──────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Thrown when a migration file on disk has a different checksum than
87
+ * the one stored in `__mandu_migrations`. `apply()` refuses to proceed;
88
+ * the operator must resolve the drift.
89
+ */
90
+ export class MigrationTamperedError extends Error {
91
+ readonly name = "MigrationTamperedError";
92
+ readonly filename: string;
93
+ readonly storedChecksum: string;
94
+ readonly currentChecksum: string;
95
+
96
+ constructor(filename: string, storedChecksum: string, currentChecksum: string) {
97
+ super(
98
+ `[@mandujs/core/db/migrations] Migration ${filename} has been modified ` +
99
+ `since it was applied. Stored checksum: ${storedChecksum}, current: ${currentChecksum}. ` +
100
+ `Revert the file or run 'mandu db reset --allow-tamper --force' to reset history.`,
101
+ );
102
+ this.filename = filename;
103
+ this.storedChecksum = storedChecksum;
104
+ this.currentChecksum = currentChecksum;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Thrown when a migration file times out. `applyTimeoutMs` is checked
110
+ * after each statement — we don't attempt to kill the underlying
111
+ * connection (Bun.SQL doesn't expose that), but we do refuse to insert
112
+ * a history row for the timed-out migration.
113
+ */
114
+ export class MigrationTimeoutError extends Error {
115
+ readonly name = "MigrationTimeoutError";
116
+ readonly filename: string;
117
+ readonly elapsedMs: number;
118
+ readonly timeoutMs: number;
119
+
120
+ constructor(filename: string, elapsedMs: number, timeoutMs: number) {
121
+ super(
122
+ `[@mandujs/core/db/migrations] Migration ${filename} exceeded ${timeoutMs}ms ` +
123
+ `(elapsed: ${elapsedMs}ms). History not recorded; retry via 'mandu db apply'.`,
124
+ );
125
+ this.filename = filename;
126
+ this.elapsedMs = elapsedMs;
127
+ this.timeoutMs = timeoutMs;
128
+ }
129
+ }
130
+
131
+ function assertNoTamperedHistory(
132
+ history: HistoryRow[],
133
+ diskByVersion: Map<string, PendingMigration>,
134
+ ): void {
135
+ for (const row of history) {
136
+ if (row.success !== 1) continue;
137
+ const disk = diskByVersion.get(row.version);
138
+ if (!disk) continue; // orphan on the history side — surfaced via status(), not apply()
139
+ if (disk.checksum !== row.checksum) {
140
+ throw new MigrationTamperedError(
141
+ disk.filename,
142
+ row.checksum,
143
+ disk.checksum,
144
+ );
145
+ }
146
+ }
147
+ }
148
+
149
+ // ─── Public API ─────────────────────────────────────────────────────────────
150
+
151
+ /** Options for {@link createMigrationRunner}. */
152
+ export interface MigrationRunnerOptions {
153
+ /** Absolute path to the migrations directory. Must exist at call time. */
154
+ migrationsDir: string;
155
+ /**
156
+ * Lock strategy. Defaults derived from `db.provider`:
157
+ * - `postgres` → `pg_advisory_lock`
158
+ * - `mysql` → `mysql_get_lock`
159
+ * - `sqlite` → `sqlite_immediate`
160
+ *
161
+ * Pass `"none"` in test suites that don't need serialisation.
162
+ */
163
+ lockStrategy?: LockStrategy;
164
+ /** History table name. Default: `"__mandu_migrations"`. */
165
+ historyTable?: string;
166
+ /**
167
+ * Per-migration-file timeout in milliseconds. When the total
168
+ * elapsed time for a single migration file exceeds this value, the
169
+ * runner aborts the file and throws {@link MigrationTimeoutError}
170
+ * WITHOUT recording a history row. Default: `60_000` (60 s).
171
+ */
172
+ applyTimeoutMs?: number;
173
+ }
174
+
175
+ /** The runner returned by {@link createMigrationRunner}. */
176
+ export interface MigrationRunner {
177
+ /** Idempotent creation of the history table. */
178
+ ensureHistoryTable(): Promise<void>;
179
+ /**
180
+ * Return every migration file on disk that has no successful history
181
+ * row, sorted by version. Checksums are computed fresh from file
182
+ * bytes — never cached.
183
+ */
184
+ plan(): Promise<PendingMigration[]>;
185
+ /**
186
+ * Apply all pending migrations. Each file runs in its own
187
+ * transaction; a failure in file N leaves files 0..N-1 applied and
188
+ * N..∞ pending. The runner holds the migration lock for the duration
189
+ * of `apply()` — multiple concurrent callers serialise.
190
+ *
191
+ * `dryRun: true` reports what WOULD be applied without executing SQL
192
+ * and without inserting history rows.
193
+ */
194
+ apply(options?: { dryRun?: boolean }): Promise<AppliedMigration[]>;
195
+ /** Combined snapshot: applied + pending + tampered + orphaned. */
196
+ status(): Promise<MigrationStatus>;
197
+ /** Idempotent release of any held lock. Does NOT close the Db. */
198
+ dispose(): Promise<void>;
199
+ }
200
+
201
+ /**
202
+ * Factory — wraps a `Db` handle with migration-runtime affordances.
203
+ * Construction is cheap (no IO); the first operation lazily creates
204
+ * the history table if it doesn't exist yet.
205
+ */
206
+ export function createMigrationRunner(
207
+ db: Db,
208
+ options: MigrationRunnerOptions,
209
+ ): MigrationRunner {
210
+ if (!options || typeof options.migrationsDir !== "string" || options.migrationsDir.length === 0) {
211
+ throw new TypeError(
212
+ "[@mandujs/core/db/migrations] createMigrationRunner: 'migrationsDir' is required.",
213
+ );
214
+ }
215
+
216
+ const historyTable = options.historyTable ?? DEFAULT_HISTORY_TABLE;
217
+ if (!SAFE_HISTORY_TABLE_RE.test(historyTable)) {
218
+ throw new Error(
219
+ `[@mandujs/core/db/migrations] Invalid history table name ${JSON.stringify(historyTable)}. ` +
220
+ `Must match ${SAFE_HISTORY_TABLE_RE}.`,
221
+ );
222
+ }
223
+
224
+ const lockStrategy: LockStrategy =
225
+ options.lockStrategy ?? defaultLockStrategy(db.provider);
226
+
227
+ const applyTimeoutMs =
228
+ typeof options.applyTimeoutMs === "number" && options.applyTimeoutMs > 0
229
+ ? options.applyTimeoutMs
230
+ : 60_000;
231
+
232
+ let historyReady = false;
233
+ let heldLock: MigrationLock | null = null;
234
+ const migrationsDir = options.migrationsDir;
235
+
236
+ async function ensureHistoryTable(): Promise<void> {
237
+ if (historyReady) return;
238
+ const ddl = historyTableDdl(historyTable, db.provider);
239
+ await execRaw(db, ddl);
240
+ historyReady = true;
241
+ }
242
+
243
+ async function ensureReady(): Promise<void> {
244
+ // Keep the "call ensureHistoryTable() first" explicit in the spec
245
+ // but do the right thing implicitly: auto-initialise on first op.
246
+ // This matches the ergonomic of Phase 4b's session storage.
247
+ if (!historyReady) {
248
+ await ensureHistoryTable();
249
+ }
250
+ }
251
+
252
+ async function plan(): Promise<PendingMigration[]> {
253
+ await ensureReady();
254
+ const [diskFiles, history] = await Promise.all([
255
+ readMigrationsFromDisk(migrationsDir),
256
+ readAllHistory(db, historyTable),
257
+ ]);
258
+ const appliedVersions = new Set(
259
+ history.filter((h) => h.success === 1).map((h) => h.version),
260
+ );
261
+ return diskFiles.filter((f) => !appliedVersions.has(f.version));
262
+ }
263
+
264
+ async function apply(
265
+ opts: { dryRun?: boolean } = {},
266
+ ): Promise<AppliedMigration[]> {
267
+ const diskFiles = await readMigrationsFromDisk(migrationsDir);
268
+ const diskByVersion = new Map(diskFiles.map((f) => [f.version, f]));
269
+
270
+ if (opts.dryRun === true) {
271
+ await ensureReady();
272
+ const history = await readAllHistory(db, historyTable);
273
+ assertNoTamperedHistory(history, diskByVersion);
274
+
275
+ const appliedVersions = new Set(
276
+ history.filter((h) => h.success === 1).map((h) => h.version),
277
+ );
278
+ const pending = diskFiles.filter((f) => !appliedVersions.has(f.version));
279
+ return pending.map<AppliedMigration>((p) => ({
280
+ version: p.version,
281
+ filename: p.filename,
282
+ checksum: p.checksum,
283
+ appliedAt: new Date(),
284
+ executionMs: 0,
285
+ success: false, // dry-run is not real — mark as not-yet-applied
286
+ }));
287
+ }
288
+
289
+ const installedBy =
290
+ (typeof process !== "undefined" && process.env?.MANDU_MIGRATION_USER) ||
291
+ "mandu";
292
+
293
+ const applied: AppliedMigration[] = [];
294
+
295
+ const runLockedApply = async (): Promise<AppliedMigration[]> => {
296
+ heldLock = await acquireMigrationLock(db, lockStrategy);
297
+ try {
298
+ await ensureReady();
299
+ const lockedHistory = await readAllHistory(db, historyTable);
300
+ assertNoTamperedHistory(lockedHistory, diskByVersion);
301
+ const lockedAppliedVersions = new Set(
302
+ lockedHistory.filter((h) => h.success === 1).map((h) => h.version),
303
+ );
304
+ const lockedPending = diskFiles.filter((f) => !lockedAppliedVersions.has(f.version));
305
+
306
+ for (const migration of lockedPending) {
307
+ const start = Date.now();
308
+
309
+ const statements = splitStatements(migration.sql);
310
+ if (statements.length === 0) {
311
+ // Empty migration — still record a history row so we don't
312
+ // re-run it. execution_ms = 0 reflects reality.
313
+ await insertHistory(db, historyTable, {
314
+ version: migration.version,
315
+ filename: migration.filename,
316
+ checksum: migration.checksum,
317
+ applied_at: new Date(),
318
+ execution_ms: 0,
319
+ success: 1,
320
+ installed_by: installedBy,
321
+ });
322
+ applied.push({
323
+ version: migration.version,
324
+ filename: migration.filename,
325
+ checksum: migration.checksum,
326
+ appliedAt: new Date(),
327
+ executionMs: 0,
328
+ success: true,
329
+ });
330
+ continue;
331
+ }
332
+
333
+ try {
334
+ await db.transaction(async (tx) => {
335
+ for (const stmt of statements) {
336
+ await execRaw(tx, stmt);
337
+ const elapsed = Date.now() - start;
338
+ if (elapsed > applyTimeoutMs) {
339
+ throw new MigrationTimeoutError(
340
+ migration.filename,
341
+ elapsed,
342
+ applyTimeoutMs,
343
+ );
344
+ }
345
+ }
346
+ });
347
+ } catch (err) {
348
+ if (err instanceof MigrationTimeoutError) throw err;
349
+ // Wrap with migration context so downstream callers know
350
+ // which file blew up. Preserve the original stack where
351
+ // possible via `cause`.
352
+ const msg = err instanceof Error ? err.message : String(err);
353
+ const wrapped = new Error(
354
+ `[@mandujs/core/db/migrations] Failed to apply ${migration.filename}: ${msg}`,
355
+ );
356
+ // Preserve the original as a `cause` chain for diagnostics.
357
+ (wrapped as { cause?: unknown }).cause = err;
358
+ throw wrapped;
359
+ }
360
+
361
+ const executionMs = Date.now() - start;
362
+ const appliedAt = new Date();
363
+
364
+ // History row is written AFTER the SQL transaction commits.
365
+ // If this INSERT itself fails, the migration has run but we
366
+ // have no record — the user will see it as pending again.
367
+ // Mitigation: the insert is a single tiny statement; in
368
+ // practice it either succeeds or the whole connection is
369
+ // dead (in which case subsequent apply() calls will also fail
370
+ // and the user will debug from the DB side).
371
+ await insertHistory(db, historyTable, {
372
+ version: migration.version,
373
+ filename: migration.filename,
374
+ checksum: migration.checksum,
375
+ applied_at: appliedAt,
376
+ execution_ms: executionMs,
377
+ success: 1,
378
+ installed_by: installedBy,
379
+ });
380
+
381
+ applied.push({
382
+ version: migration.version,
383
+ filename: migration.filename,
384
+ checksum: migration.checksum,
385
+ appliedAt,
386
+ executionMs,
387
+ success: true,
388
+ });
389
+ }
390
+ } finally {
391
+ if (heldLock) {
392
+ await heldLock.release();
393
+ heldLock = null;
394
+ }
395
+ }
396
+
397
+ return applied;
398
+ };
399
+
400
+ if (lockStrategy === "mysql_get_lock") {
401
+ return await withPinnedDbHandle(db, runLockedApply);
402
+ }
403
+ return await runLockedApply();
404
+ }
405
+
406
+ async function status(): Promise<MigrationStatus> {
407
+ await ensureReady();
408
+
409
+ const [diskFiles, history] = await Promise.all([
410
+ readMigrationsFromDisk(migrationsDir),
411
+ readAllHistory(db, historyTable),
412
+ ]);
413
+
414
+ const diskByVersion = new Map(diskFiles.map((f) => [f.version, f]));
415
+
416
+ const applied: AppliedMigration[] = [];
417
+ const tampered: MigrationStatus["tampered"] = [];
418
+ for (const row of history) {
419
+ if (row.success !== 1) continue;
420
+ const disk = diskByVersion.get(row.version);
421
+ if (disk && disk.checksum !== row.checksum) {
422
+ tampered.push({
423
+ version: row.version,
424
+ filename: disk.filename,
425
+ storedChecksum: row.checksum,
426
+ currentChecksum: disk.checksum,
427
+ });
428
+ }
429
+ applied.push({
430
+ version: row.version,
431
+ filename: row.filename,
432
+ checksum: row.checksum,
433
+ appliedAt: row.applied_at,
434
+ executionMs: row.execution_ms,
435
+ success: true,
436
+ });
437
+ }
438
+
439
+ const appliedVersions = new Set(
440
+ history.filter((h) => h.success === 1).map((h) => h.version),
441
+ );
442
+ const pending = diskFiles.filter((f) => !appliedVersions.has(f.version));
443
+
444
+ // "orphaned" = files that exist on disk, have NO matching history
445
+ // row, AND are already in `pending` — by definition they'd show up
446
+ // in `pending`. The spec uses `orphaned` for the inverse (rare):
447
+ // history rows with no file on disk. We capture the latter so
448
+ // operators can spot a deleted file that was already applied.
449
+ const diskVersions = new Set(diskFiles.map((f) => f.version));
450
+ const orphaned: MigrationStatus["orphaned"] = [];
451
+ for (const row of history) {
452
+ if (!diskVersions.has(row.version)) {
453
+ orphaned.push({ filename: row.filename });
454
+ }
455
+ }
456
+
457
+ return { applied, pending, tampered, orphaned };
458
+ }
459
+
460
+ async function dispose(): Promise<void> {
461
+ if (heldLock) {
462
+ await heldLock.release();
463
+ heldLock = null;
464
+ }
465
+ // Explicitly do NOT close the Db — ownership belongs to the caller
466
+ // (per the module JSDoc).
467
+ }
468
+
469
+ return {
470
+ ensureHistoryTable,
471
+ plan,
472
+ apply,
473
+ status,
474
+ dispose,
475
+ };
476
+ }
477
+
478
+ // ─── Checksum ───────────────────────────────────────────────────────────────
479
+
480
+ /**
481
+ * Compute the migration checksum — SHA-256 hex, lowercase, with `\r\n`
482
+ * normalised to `\n`. This is the ONLY normalisation we apply; all other
483
+ * whitespace, comments, BOMs, trailing newlines are preserved as-is so
484
+ * hand-edits (even cosmetic ones) are detected.
485
+ *
486
+ * Rationale: Flyway uses CRC-32 for the same purpose; we upgraded to
487
+ * SHA-256 because CRC collides more readily when SQL is minified or
488
+ * large. Full 256-bit cryptographic hash is overkill for this use-case,
489
+ * but adds zero practical cost (<0.1 ms on any migration < 1 MB).
490
+ */
491
+ export function computeMigrationChecksum(sql: string): string {
492
+ const normalized = sql.replace(/\r\n/g, "\n");
493
+ return createHash("sha256").update(normalized, "utf8").digest("hex");
494
+ }
495
+
496
+ // ─── Filesystem discovery ───────────────────────────────────────────────────
497
+
498
+ /**
499
+ * Matches `NNNN_description.sql`. Version is captured as group 1.
500
+ *
501
+ * We require at least 4 digits (zero-padded) followed by an underscore
502
+ * and at least one character of description, then `.sql`. Loose enough
503
+ * to accept `0001_init.sql` and `20260401_foo.sql` equally; strict
504
+ * enough to reject `migration.sql` or `init.sql` (no version prefix).
505
+ */
506
+ const MIGRATION_FILE_RE = /^(\d{4,})_[^/\\]+\.sql$/i;
507
+
508
+ /**
509
+ * Read every `NNNN_*.sql` file in `dir`, hash it, and return the result
510
+ * sorted by version. Non-matching files produce a single `console.warn`
511
+ * each (callers can silence via their logger wrapper).
512
+ *
513
+ * @throws when two files share the same version prefix.
514
+ */
515
+ async function readMigrationsFromDisk(dir: string): Promise<PendingMigration[]> {
516
+ let entries: string[];
517
+ try {
518
+ entries = await fs.readdir(dir);
519
+ } catch (err) {
520
+ const code = (err as { code?: string }).code;
521
+ if (code === "ENOENT") {
522
+ // Missing migrations dir is not fatal — plan() returns []. Agent
523
+ // E's CLI creates the directory on `mandu db plan`.
524
+ return [];
525
+ }
526
+ throw err;
527
+ }
528
+
529
+ const seen = new Map<string, string>(); // version → filename (for duplicate detection)
530
+ const results: PendingMigration[] = [];
531
+
532
+ for (const entry of entries) {
533
+ if (!entry.toLowerCase().endsWith(".sql")) continue;
534
+ const match = MIGRATION_FILE_RE.exec(entry);
535
+ if (!match) {
536
+ console.warn(
537
+ `[@mandujs/core/db/migrations] Ignoring ${entry}: filename does not match NNNN_description.sql pattern.`,
538
+ );
539
+ continue;
540
+ }
541
+ // Zero-pad the version to 4+ digits for stable lex ordering. The
542
+ // regex already requires 4+; use the captured string verbatim.
543
+ const version = match[1]!;
544
+ if (seen.has(version)) {
545
+ throw new Error(
546
+ `[@mandujs/core/db/migrations] Duplicate migration version ${JSON.stringify(version)}: ` +
547
+ `${seen.get(version)} and ${entry}.`,
548
+ );
549
+ }
550
+ seen.set(version, entry);
551
+
552
+ const fullPath = path.join(dir, entry);
553
+ const [raw, stat] = await Promise.all([
554
+ fs.readFile(fullPath, "utf8"),
555
+ fs.stat(fullPath),
556
+ ]);
557
+ results.push({
558
+ version,
559
+ filename: entry,
560
+ sql: raw,
561
+ checksum: computeMigrationChecksum(raw),
562
+ createdAt: stat.mtime,
563
+ });
564
+ }
565
+
566
+ results.sort((a, b) => (a.version < b.version ? -1 : a.version > b.version ? 1 : 0));
567
+ return results;
568
+ }
569
+
570
+ // ─── Statement splitter ─────────────────────────────────────────────────────
571
+
572
+ /**
573
+ * Split a migration SQL string into individual statements.
574
+ *
575
+ * v1 rule: split on `;` at the END of a line (or at the very end of the
576
+ * file). Empty statements (whitespace only) are dropped. SQL line
577
+ * comments (`--`) and multi-line (`/* … *\/`) are preserved inside each
578
+ * statement so drivers see the original text.
579
+ *
580
+ * **Limitation** (documented for users): a `;` inside a SQL string
581
+ * literal that happens to be followed by a newline WILL be mis-split.
582
+ * In practice this is extremely rare in hand-authored DDL — column
583
+ * definitions and constraint expressions don't contain raw semicolons.
584
+ * If you hit this, collapse the offending statement onto a single line
585
+ * or escape with `--` line-comment markers. A proper tokenising splitter
586
+ * lands in v2 (tracked with the migration runtime's other limitations).
587
+ */
588
+ export function splitStatements(sql: string): string[] {
589
+ // Normalise line endings for splitting; `computeMigrationChecksum`
590
+ // does the same so the output is consistent across OSes.
591
+ const normalised = sql.replace(/\r\n/g, "\n");
592
+
593
+ const statements: string[] = [];
594
+ let buffer: string[] = [];
595
+
596
+ for (const line of normalised.split("\n")) {
597
+ buffer.push(line);
598
+ const trimmed = line.trimEnd();
599
+ if (trimmed.endsWith(";")) {
600
+ // Emit the statement up to (and including) this line, then drop
601
+ // the trailing `;` so Bun.SQL doesn't double-terminate it.
602
+ const joined = buffer.join("\n").trimEnd();
603
+ const withoutTrailing = joined.slice(0, -1); // strip ;
604
+ const statement = withoutTrailing.trim();
605
+ if (statement.length > 0) {
606
+ statements.push(statement);
607
+ }
608
+ buffer = [];
609
+ }
610
+ }
611
+
612
+ // Handle a tail statement without a trailing `;`. Bun.SQL / most
613
+ // drivers accept statements without a terminator; we do the same.
614
+ const tail = buffer.join("\n").trim();
615
+ if (tail.length > 0) {
616
+ statements.push(tail);
617
+ }
618
+
619
+ return statements;
620
+ }
621
+
622
+ // ─── Defaults ───────────────────────────────────────────────────────────────
623
+
624
+ /** Derive the default `LockStrategy` from the detected provider. */
625
+ function defaultLockStrategy(provider: SqlProvider): LockStrategy {
626
+ switch (provider) {
627
+ case "postgres":
628
+ return "pg_advisory_lock";
629
+ case "mysql":
630
+ return "mysql_get_lock";
631
+ case "sqlite":
632
+ return "sqlite_immediate";
633
+ }
634
+ }
635
+
636
+ // ─── Raw SQL exec (parameter-less) ──────────────────────────────────────────
637
+ //
638
+ // We reuse the tagged-template surface of `@mandujs/core/db` for raw DDL
639
+ // by constructing a zero-placeholder synthetic template array. The DDL
640
+ // comes from trusted sources (operator-authored migration files or
641
+ // Mandu-emitted history table DDL), so there's no injection surface.
642
+
643
+ async function execRaw(db: Db, sql: string): Promise<void> {
644
+ const strings = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray;
645
+ await db(strings);
646
+ }
647
+
648
+ // Re-export HistoryRow so consumers doing `import { MigrationRunner } from "./runner"`
649
+ // also reach row-level types without a second import site.
650
+ export type { HistoryRow };