@mandujs/core 0.54.32 → 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.
- package/README.md +654 -654
- package/package.json +106 -210
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +15 -15
- package/src/a11y/types.ts +125 -125
- package/src/agent/__tests__/apply.test.ts +224 -0
- package/src/agent/__tests__/context.test.ts +97 -98
- package/src/agent/apply.ts +1031 -0
- package/src/agent/context.ts +552 -552
- package/src/agent/index.ts +4 -3
- package/src/agent/plan.ts +161 -243
- package/src/agent/repair.ts +239 -162
- package/src/agent/sync.ts +197 -198
- package/src/agent/types.ts +242 -94
- package/src/agent/verify.ts +55 -55
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +15 -5
- package/src/auth/__tests__/reset.test.ts +2 -2
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +14 -2
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
- package/src/bundler/__tests__/build-runner.ts +166 -166
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +49 -20
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
- package/src/bundler/__tests__/generation.test.ts +447 -0
- package/src/bundler/__tests__/hdr.test.ts +24 -18
- package/src/bundler/__tests__/hmr-client.test.ts +62 -24
- package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
- package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +898 -915
- package/src/bundler/build.ts +113 -24
- package/src/bundler/client-boundary-transform.ts +977 -977
- package/src/bundler/css.ts +65 -44
- package/src/bundler/dev.ts +105 -73
- package/src/bundler/fast-refresh-preamble.ts +47 -47
- package/src/bundler/generation.ts +602 -0
- package/src/bundler/index.ts +3 -3
- package/src/bundler/manifest-schema.ts +55 -40
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +16 -15
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/prerender.ts +29 -9
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +1 -1
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +58 -56
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/props-serialization.test.ts +37 -37
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/island.ts +79 -79
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/props-serialization.ts +233 -233
- package/src/client/runtime-entry.ts +598 -598
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -50
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image-compat.ts +3 -0
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +9 -9
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/generate-types.ts +1 -1
- package/src/content/index.ts +2 -2
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +9 -9
- package/src/db/index.ts +2 -2
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +82 -82
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/smoke.test.ts +2 -2
- package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
- package/src/desktop/__tests__/window.test.ts +20 -99
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/webview-fallback.ts +17 -4
- package/src/desktop/window.ts +17 -8
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/diagnose/__tests__/checks.test.ts +136 -136
- package/src/diagnose/checks.ts +185 -185
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +10 -10
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +5 -1
- package/src/filling/auth.ts +308 -308
- package/src/filling/body-parse.test.ts +60 -60
- package/src/filling/cookie-codec.ts +5 -3
- package/src/filling/deps.ts +265 -265
- package/src/filling/head-method.test.ts +154 -154
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.test.ts +48 -48
- package/src/generator/templates.ts +219 -219
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +39 -24
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/index.ts +11 -11
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/__tests__/id.test.ts +3 -3
- package/src/id/index.ts +105 -105
- package/src/index.ts +9 -48
- package/src/internal/client-boundary.ts +266 -266
- package/src/internal/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -779
- package/src/kitchen/api/bundle-inspector.ts +179 -0
- package/src/kitchen/api/errors-grouping.ts +126 -126
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/kitchen/kitchen-handler.ts +36 -0
- package/src/kitchen/kitchen-ui.ts +456 -0
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/perf/user-marks.ts +1 -1
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +7 -7
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +227 -227
- package/src/router/client-entry.ts +218 -218
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.test.ts +532 -532
- package/src/router/fs-routes.ts +53 -53
- package/src/router/fs-scanner.ts +216 -216
- package/src/router/fs-types.ts +19 -19
- package/src/router/route-source-analyzer.ts +521 -521
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +517 -517
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/fast-refresh-types.ts +1 -1
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +30 -11
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/page-render-response.ts +287 -274
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/rate-limit.ts +1 -1
- package/src/runtime/redirect.ts +1 -1
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -10
- package/src/runtime/server.ts +127 -211
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +43 -17
- package/src/runtime/static-files.ts +369 -289
- package/src/runtime/streaming-ssr.ts +219 -180
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/__tests__/scheduler.test.ts +12 -11
- package/src/scheduler/index.ts +11 -5
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/spec/schema.ts +35 -35
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/index.ts +9 -3
- package/src/testing/lcov.ts +192 -0
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- 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 };
|