@mandujs/core 0.53.3 → 0.54.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 +1 -1
- 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 +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- 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 +120 -120
- 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/bundler/__tests__/build-runner.ts +113 -113
- 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 +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- 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/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- 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 +155 -155
- 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/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- 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__/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 +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- 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.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- 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/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/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- 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 +182 -182
- 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__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- 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/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/overlay.tsx +368 -368
- 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 +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- 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/client/persistence.ts +335 -335
- 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/server/source-context.ts +450 -450
- 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/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- 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/result.ts +60 -60
- 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 +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- 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/index.ts +3 -3
- 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/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/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- 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/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- 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/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 +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- 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/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- 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__/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__/not-found.test.ts +152 -152
- 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/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- 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/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- 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/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/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/watcher.ts +18 -18
package/src/resource/ddl/emit.ts
CHANGED
|
@@ -1,559 +1,559 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 4c — DDL emission engine.
|
|
3
|
-
*
|
|
4
|
-
* Pure, deterministic translation from `DdlResource` / `Change` objects
|
|
5
|
-
* into dialect-correct SQL strings for Postgres, MySQL, and SQLite.
|
|
6
|
-
*
|
|
7
|
-
* Design invariants (Agents B/C/D/E/G rely on these):
|
|
8
|
-
* 1. NO I/O. Every function is synchronous and takes / returns strings
|
|
9
|
-
* or plain objects. No filesystem, no database connection.
|
|
10
|
-
* 2. NO dependency on `Bun.SQL` or any driver. We only emit SQL text —
|
|
11
|
-
* Agent C's runner handles execution.
|
|
12
|
-
* 3. Every identifier — table names, column names, index names — MUST
|
|
13
|
-
* flow through `quoteIdent`. Direct string interpolation of
|
|
14
|
-
* identifiers into SQL is a security audit failure (Agent G).
|
|
15
|
-
* 4. Value literals in `DEFAULT` clauses flow through `resolveDefault`
|
|
16
|
-
* which handles quote escaping. `kind: "sql"` is the explicit
|
|
17
|
-
* escape hatch — caller's responsibility.
|
|
18
|
-
* 5. Unsupported changes (v1 scope — `alter-column-type`) emit a
|
|
19
|
-
* `-- TODO:` comment block + a no-op `SELECT 1;` so the generated
|
|
20
|
-
* migration still parses and Agent C's runner can record an
|
|
21
|
-
* "applied but manual" row.
|
|
22
|
-
*
|
|
23
|
-
* Determinism:
|
|
24
|
-
* - `emitCreateTable` emits columns in `DdlFieldDef` array order — the
|
|
25
|
-
* snapshot layer (Agent B) is responsible for stabilizing that order
|
|
26
|
-
* (see §"Deterministic ordering" in docs/bun/phase-4c-team-plan.md).
|
|
27
|
-
* - `emitChanges` emits in the order received. Agent B guarantees
|
|
28
|
-
* deterministic change order; emit does not re-sort.
|
|
29
|
-
*
|
|
30
|
-
* References:
|
|
31
|
-
* - docs/bun/phase-4c-team-plan.md §2 (types) + §3 Agent A
|
|
32
|
-
* - docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
|
|
33
|
-
* - DNA/drizzle-orm/drizzle-kit/src/sqlgenerator.ts (reference emitter)
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import {
|
|
37
|
-
nowExpr,
|
|
38
|
-
resolveColumnType,
|
|
39
|
-
resolveDefault,
|
|
40
|
-
} from "./type-map";
|
|
41
|
-
import type {
|
|
42
|
-
Change,
|
|
43
|
-
DdlFieldDef,
|
|
44
|
-
DdlIndex,
|
|
45
|
-
DdlResource,
|
|
46
|
-
SqlProvider,
|
|
47
|
-
} from "./types";
|
|
48
|
-
|
|
49
|
-
// =====================================================================
|
|
50
|
-
// Identifier quoting — security-critical (audited by Agent G).
|
|
51
|
-
// =====================================================================
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Maximum length for an identifier before we refuse to emit.
|
|
55
|
-
*
|
|
56
|
-
* Postgres allows 63 bytes (NAMEDATALEN - 1), MySQL 64. We pick the
|
|
57
|
-
* tighter bound so identity rules are uniform across dialects and
|
|
58
|
-
* cross-dialect migration files don't surprise a user switching providers.
|
|
59
|
-
*/
|
|
60
|
-
const MAX_IDENT_LENGTH = 63;
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Dialect-correct identifier quoter.
|
|
64
|
-
*
|
|
65
|
-
* - Postgres / SQLite: wraps in ANSI double quotes. Rejects names
|
|
66
|
-
* containing `"` — there is no portable escape (`""` works on PG but
|
|
67
|
-
* complicates downstream tooling and is not idiomatic for generated
|
|
68
|
-
* code).
|
|
69
|
-
* - MySQL: wraps in backticks. Rejects names containing `` ` ``.
|
|
70
|
-
*
|
|
71
|
-
* All providers additionally reject:
|
|
72
|
-
* - empty string
|
|
73
|
-
* - names longer than 63 chars (PG limit — stricter than MySQL's 64)
|
|
74
|
-
* - NUL bytes (`\0`) — a driver-level crash on several Bun.SQL backends
|
|
75
|
-
*
|
|
76
|
-
* @throws {Error} with a clear message when any rule is violated.
|
|
77
|
-
*/
|
|
78
|
-
export function quoteIdent(name: string, provider: SqlProvider): string {
|
|
79
|
-
if (typeof name !== "string") {
|
|
80
|
-
throw new Error(`identifier must be a string, got ${typeof name}`);
|
|
81
|
-
}
|
|
82
|
-
if (name.length === 0) {
|
|
83
|
-
throw new Error("identifier must not be empty");
|
|
84
|
-
}
|
|
85
|
-
if (name.length > MAX_IDENT_LENGTH) {
|
|
86
|
-
throw new Error(
|
|
87
|
-
`identifier too long (${name.length} > ${MAX_IDENT_LENGTH}): ${name.slice(0, 32)}...`,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
if (name.includes("\0")) {
|
|
91
|
-
throw new Error("identifier contains NUL byte");
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (provider === "mysql") {
|
|
95
|
-
if (name.includes("`")) {
|
|
96
|
-
throw new Error(`identifier contains unquotable character: ${JSON.stringify(name)}`);
|
|
97
|
-
}
|
|
98
|
-
return `\`${name}\``;
|
|
99
|
-
}
|
|
100
|
-
// Postgres, SQLite
|
|
101
|
-
if (name.includes('"')) {
|
|
102
|
-
throw new Error(`identifier contains unquotable character: ${JSON.stringify(name)}`);
|
|
103
|
-
}
|
|
104
|
-
return `"${name}"`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// =====================================================================
|
|
108
|
-
// Column + index emission helpers.
|
|
109
|
-
// =====================================================================
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Emit a single column definition line (no leading whitespace, no trailing
|
|
113
|
-
* comma). Used inside `CREATE TABLE` parens and by `emitChange("add-column")`.
|
|
114
|
-
*
|
|
115
|
-
* Column order of modifiers (chosen to match Drizzle / Atlas output for
|
|
116
|
-
* familiarity):
|
|
117
|
-
* <name> <type> [PRIMARY KEY] [NOT NULL] [UNIQUE] [DEFAULT <expr>]
|
|
118
|
-
*
|
|
119
|
-
* Rationale:
|
|
120
|
-
* - `PRIMARY KEY` before `NOT NULL` mirrors Postgres/MySQL docs.
|
|
121
|
-
* - `DEFAULT` last because some drivers (SQLite) parse `NOT NULL DEFAULT`
|
|
122
|
-
* more reliably than the reverse.
|
|
123
|
-
*/
|
|
124
|
-
function emitColumnDef(field: DdlFieldDef, provider: SqlProvider): string {
|
|
125
|
-
const ident = quoteIdent(field.name, provider);
|
|
126
|
-
const type = resolveColumnType(field, provider);
|
|
127
|
-
const parts: string[] = [ident, type];
|
|
128
|
-
|
|
129
|
-
if (field.primary) parts.push("PRIMARY KEY");
|
|
130
|
-
// PRIMARY KEY implies NOT NULL everywhere; skip the explicit marker.
|
|
131
|
-
if (!field.nullable && !field.primary) parts.push("NOT NULL");
|
|
132
|
-
if (field.unique && !field.primary) parts.push("UNIQUE");
|
|
133
|
-
|
|
134
|
-
if (field.default) {
|
|
135
|
-
parts.push(`DEFAULT ${resolveDefault(field.default, provider)}`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return parts.join(" ");
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Stable single-column index name. Used when a field is flagged
|
|
143
|
-
* `indexed: true` but no explicit DdlIndex entry exists for it.
|
|
144
|
-
*
|
|
145
|
-
* Format: `idx_<table>_<column>` — shortened to fit within
|
|
146
|
-
* MAX_IDENT_LENGTH when either name is long.
|
|
147
|
-
*/
|
|
148
|
-
function autoIndexName(table: string, column: string): string {
|
|
149
|
-
const raw = `idx_${table}_${column}`;
|
|
150
|
-
if (raw.length <= MAX_IDENT_LENGTH) return raw;
|
|
151
|
-
// Truncate by taking prefix of table + suffix of column — preserves some
|
|
152
|
-
// readability while guaranteeing length compliance. Deterministic.
|
|
153
|
-
const budget = MAX_IDENT_LENGTH - "idx_".length - 1;
|
|
154
|
-
const halfBudget = Math.floor(budget / 2);
|
|
155
|
-
return `idx_${table.slice(0, halfBudget)}_${column.slice(-halfBudget)}`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Emit a `CREATE INDEX` statement for either:
|
|
160
|
-
* - an automatic single-column index on an `indexed: true` field, or
|
|
161
|
-
* - an explicit multi-column `DdlIndex`.
|
|
162
|
-
*/
|
|
163
|
-
function emitCreateIndex(
|
|
164
|
-
table: string,
|
|
165
|
-
name: string,
|
|
166
|
-
columns: readonly string[],
|
|
167
|
-
unique: boolean,
|
|
168
|
-
provider: SqlProvider,
|
|
169
|
-
): string {
|
|
170
|
-
const uniqueKw = unique ? "UNIQUE " : "";
|
|
171
|
-
const tbl = quoteIdent(table, provider);
|
|
172
|
-
const idx = quoteIdent(name, provider);
|
|
173
|
-
const cols = columns.map((c) => quoteIdent(c, provider)).join(", ");
|
|
174
|
-
return `CREATE ${uniqueKw}INDEX ${idx} ON ${tbl} (${cols});`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// =====================================================================
|
|
178
|
-
// Public API — resource-level emitters.
|
|
179
|
-
// =====================================================================
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Emit the full initial-migration SQL for a single resource:
|
|
183
|
-
* one `CREATE TABLE` followed by zero or more `CREATE INDEX` statements.
|
|
184
|
-
*
|
|
185
|
-
* Output format:
|
|
186
|
-
* - No leading / trailing blank lines (caller composes).
|
|
187
|
-
* - `CREATE TABLE` spans multiple lines, one column per line, two-space
|
|
188
|
-
* indent — matches Drizzle-kit's output for diff readability.
|
|
189
|
-
* - Each statement ends with `;`. Statements separated by `\n`.
|
|
190
|
-
*
|
|
191
|
-
* Determinism: column order matches `resource.fields` array order, index
|
|
192
|
-
* order matches: all `indexed: true` fields (in field array order) first,
|
|
193
|
-
* then explicit `resource.indexes` (in array order).
|
|
194
|
-
*/
|
|
195
|
-
export function emitCreateTable(resource: DdlResource, provider: SqlProvider): string {
|
|
196
|
-
if (!Array.isArray(resource.fields) || resource.fields.length === 0) {
|
|
197
|
-
throw new Error(`resource "${resource.name}" has no fields — cannot emit CREATE TABLE`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const tableIdent = quoteIdent(resource.name, provider); // also validates
|
|
201
|
-
|
|
202
|
-
const columnLines = resource.fields.map((f) => ` ${emitColumnDef(f, provider)}`);
|
|
203
|
-
const createTable = [
|
|
204
|
-
`CREATE TABLE ${tableIdent} (`,
|
|
205
|
-
columnLines.join(",\n"),
|
|
206
|
-
`);`,
|
|
207
|
-
].join("\n");
|
|
208
|
-
|
|
209
|
-
const indexStatements: string[] = [];
|
|
210
|
-
|
|
211
|
-
// Auto-index from `field.indexed` (single column, non-unique).
|
|
212
|
-
for (const field of resource.fields) {
|
|
213
|
-
if (!field.indexed) continue;
|
|
214
|
-
// Skip if field is already unique (UNIQUE column constraint creates
|
|
215
|
-
// an implicit index on PG/MySQL; on SQLite a UNIQUE column also has
|
|
216
|
-
// an implicit index).
|
|
217
|
-
if (field.unique || field.primary) continue;
|
|
218
|
-
indexStatements.push(
|
|
219
|
-
emitCreateIndex(
|
|
220
|
-
resource.name,
|
|
221
|
-
autoIndexName(resource.name, field.name),
|
|
222
|
-
[field.name],
|
|
223
|
-
false,
|
|
224
|
-
provider,
|
|
225
|
-
),
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Explicit multi-column indexes.
|
|
230
|
-
for (const index of resource.indexes ?? []) {
|
|
231
|
-
validateIndex(resource, index);
|
|
232
|
-
indexStatements.push(
|
|
233
|
-
emitCreateIndex(resource.name, index.name, index.fields, index.unique, provider),
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return indexStatements.length > 0
|
|
238
|
-
? `${createTable}\n${indexStatements.join("\n")}`
|
|
239
|
-
: createTable;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Emit a `DROP TABLE IF EXISTS` statement. `IF EXISTS` is present on all
|
|
244
|
-
* three dialects and makes migrations idempotent on re-run.
|
|
245
|
-
*/
|
|
246
|
-
export function emitDropTable(resourceName: string, provider: SqlProvider): string {
|
|
247
|
-
return `DROP TABLE IF EXISTS ${quoteIdent(resourceName, provider)};`;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Emit the full initial schema for many resources. Each resource becomes a
|
|
252
|
-
* `CREATE TABLE` (+ its indexes); blocks are separated by one blank line
|
|
253
|
-
* for readability.
|
|
254
|
-
*
|
|
255
|
-
* Deterministic in resource array order — the snapshot layer is
|
|
256
|
-
* responsible for stable ordering.
|
|
257
|
-
*/
|
|
258
|
-
export function emitSchema(resources: DdlResource[], provider: SqlProvider): string {
|
|
259
|
-
if (resources.length === 0) return "";
|
|
260
|
-
return resources.map((r) => emitCreateTable(r, provider)).join("\n\n");
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Emit a sequence of `Change` objects as a single migration SQL string.
|
|
265
|
-
* Each change becomes one or more statements, separated by newlines.
|
|
266
|
-
*
|
|
267
|
-
* Empty input returns empty string (not whitespace) — makes it safe to
|
|
268
|
-
* concatenate with a header or leave out when unused.
|
|
269
|
-
*/
|
|
270
|
-
export function emitChanges(changes: readonly Change[], provider: SqlProvider): string {
|
|
271
|
-
if (changes.length === 0) return "";
|
|
272
|
-
return changes.map((c) => emitChange(c, provider)).join("\n");
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// =====================================================================
|
|
276
|
-
// Change dispatch — one branch per `Change.kind`.
|
|
277
|
-
// =====================================================================
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Dispatch a single `Change` to the corresponding SQL statement(s).
|
|
281
|
-
*
|
|
282
|
-
* Unknown `kind` throws (defensive against future `Change` variants added
|
|
283
|
-
* to `types.ts` without a matching emitter update).
|
|
284
|
-
*/
|
|
285
|
-
export function emitChange(change: Change, provider: SqlProvider): string {
|
|
286
|
-
switch (change.kind) {
|
|
287
|
-
case "create-table":
|
|
288
|
-
return emitCreateTable(change.resource, provider);
|
|
289
|
-
case "drop-table":
|
|
290
|
-
return emitDropTable(change.resourceName, provider);
|
|
291
|
-
case "add-column":
|
|
292
|
-
return emitAddColumn(change.resourceName, change.field, provider);
|
|
293
|
-
case "drop-column":
|
|
294
|
-
return emitDropColumn(change.resourceName, change.fieldName, provider);
|
|
295
|
-
case "alter-column-type":
|
|
296
|
-
return emitAlterColumnTypeStub(
|
|
297
|
-
change.resourceName,
|
|
298
|
-
change.fieldName,
|
|
299
|
-
change.fromType,
|
|
300
|
-
change.toType,
|
|
301
|
-
);
|
|
302
|
-
case "alter-column-nullable":
|
|
303
|
-
return emitAlterColumnNullable(
|
|
304
|
-
change.resourceName,
|
|
305
|
-
change.fieldName,
|
|
306
|
-
change.nullable,
|
|
307
|
-
provider,
|
|
308
|
-
);
|
|
309
|
-
case "alter-column-default":
|
|
310
|
-
return emitAlterColumnDefault(
|
|
311
|
-
change.resourceName,
|
|
312
|
-
change.fieldName,
|
|
313
|
-
change.default,
|
|
314
|
-
provider,
|
|
315
|
-
);
|
|
316
|
-
case "add-index":
|
|
317
|
-
return emitAddIndex(change.resourceName, change.index, provider);
|
|
318
|
-
case "drop-index":
|
|
319
|
-
return emitDropIndex(change.resourceName, change.indexName, provider);
|
|
320
|
-
case "rename-table":
|
|
321
|
-
return emitRenameTable(change.oldName, change.newName, provider);
|
|
322
|
-
case "rename-column":
|
|
323
|
-
return emitRenameColumn(
|
|
324
|
-
change.resourceName,
|
|
325
|
-
change.oldName,
|
|
326
|
-
change.newName,
|
|
327
|
-
provider,
|
|
328
|
-
);
|
|
329
|
-
default: {
|
|
330
|
-
// `never` check — keeps this function exhaustive with types.ts.
|
|
331
|
-
const _exhaustive: never = change;
|
|
332
|
-
throw new Error(`unknown Change.kind: ${JSON.stringify(_exhaustive)}`);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// =====================================================================
|
|
338
|
-
// Change emitters (internal — called only via emitChange dispatch).
|
|
339
|
-
// =====================================================================
|
|
340
|
-
|
|
341
|
-
function emitAddColumn(
|
|
342
|
-
resourceName: string,
|
|
343
|
-
field: DdlFieldDef,
|
|
344
|
-
provider: SqlProvider,
|
|
345
|
-
): string {
|
|
346
|
-
const table = quoteIdent(resourceName, provider);
|
|
347
|
-
const columnDef = emitColumnDef(field, provider);
|
|
348
|
-
const addColumn = `ALTER TABLE ${table} ADD COLUMN ${columnDef};`;
|
|
349
|
-
if (!field.indexed || field.unique || field.primary) return addColumn;
|
|
350
|
-
return [
|
|
351
|
-
addColumn,
|
|
352
|
-
emitCreateIndex(
|
|
353
|
-
resourceName,
|
|
354
|
-
autoIndexName(resourceName, field.name),
|
|
355
|
-
[field.name],
|
|
356
|
-
false,
|
|
357
|
-
provider,
|
|
358
|
-
),
|
|
359
|
-
].join("\n");
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* DROP COLUMN.
|
|
364
|
-
*
|
|
365
|
-
* Note on SQLite: DROP COLUMN landed in SQLite 3.35 (March 2021). Bun's
|
|
366
|
-
* bundled SQLite is modern (3.46+ as of Bun 1.3), so we emit the standard
|
|
367
|
-
* `ALTER TABLE ... DROP COLUMN ...` and assume the target engine is >=3.35.
|
|
368
|
-
* Downstream environments pinning an older SQLite will need to manually
|
|
369
|
-
* use the `recreate table` dance — Agent F's matrix tests cover 3.46.
|
|
370
|
-
*/
|
|
371
|
-
function emitDropColumn(
|
|
372
|
-
resourceName: string,
|
|
373
|
-
fieldName: string,
|
|
374
|
-
provider: SqlProvider,
|
|
375
|
-
): string {
|
|
376
|
-
const table = quoteIdent(resourceName, provider);
|
|
377
|
-
const col = quoteIdent(fieldName, provider);
|
|
378
|
-
return `ALTER TABLE ${table} DROP COLUMN ${col};`;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Emit a stub `-- TODO:` block for v1-unsupported `alter-column-type`.
|
|
383
|
-
*
|
|
384
|
-
* Output: a multi-line SQL comment block naming the resource + field +
|
|
385
|
-
* fromType → toType, followed by the literal TODO message and a no-op
|
|
386
|
-
* `SELECT 1;` so the migration runner (Agent C) parses and advances past
|
|
387
|
-
* this statement.
|
|
388
|
-
*/
|
|
389
|
-
function emitAlterColumnTypeStub(
|
|
390
|
-
resourceName: string,
|
|
391
|
-
fieldName: string,
|
|
392
|
-
fromType: string,
|
|
393
|
-
toType: string,
|
|
394
|
-
): string {
|
|
395
|
-
return [
|
|
396
|
-
`-- ================================================================`,
|
|
397
|
-
`-- Column type change detected: ${resourceName}.${fieldName}`,
|
|
398
|
-
`-- from: ${fromType}`,
|
|
399
|
-
`-- to: ${toType}`,
|
|
400
|
-
`-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.`,
|
|
401
|
-
`-- Please write the migration manually, then re-run \`mandu db apply\`.`,
|
|
402
|
-
`-- ================================================================`,
|
|
403
|
-
`SELECT 1;`,
|
|
404
|
-
].join("\n");
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function emitAlterColumnNullable(
|
|
408
|
-
resourceName: string,
|
|
409
|
-
fieldName: string,
|
|
410
|
-
nullable: boolean,
|
|
411
|
-
provider: SqlProvider,
|
|
412
|
-
): string {
|
|
413
|
-
const table = quoteIdent(resourceName, provider);
|
|
414
|
-
const col = quoteIdent(fieldName, provider);
|
|
415
|
-
if (provider === "postgres") {
|
|
416
|
-
// PG supports direct SET/DROP NOT NULL on existing columns.
|
|
417
|
-
return nullable
|
|
418
|
-
? `ALTER TABLE ${table} ALTER COLUMN ${col} DROP NOT NULL;`
|
|
419
|
-
: `ALTER TABLE ${table} ALTER COLUMN ${col} SET NOT NULL;`;
|
|
420
|
-
}
|
|
421
|
-
if (provider === "sqlite") {
|
|
422
|
-
// SQLite cannot toggle NOT NULL on an existing column without a full
|
|
423
|
-
// table recreate. Emit a stub so the user handles it manually.
|
|
424
|
-
return [
|
|
425
|
-
`-- ================================================================`,
|
|
426
|
-
`-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
|
|
427
|
-
`-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
|
|
428
|
-
`-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
|
|
429
|
-
`-- ================================================================`,
|
|
430
|
-
`SELECT 1;`,
|
|
431
|
-
].join("\n");
|
|
432
|
-
}
|
|
433
|
-
// MySQL requires the full column spec; we cannot reconstruct it here.
|
|
434
|
-
// Emit a stub — Agent B's diff emits `alter-column-nullable` only when
|
|
435
|
-
// everything else matches, so this is narrow-scope.
|
|
436
|
-
return [
|
|
437
|
-
`-- ================================================================`,
|
|
438
|
-
`-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
|
|
439
|
-
`-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
|
|
440
|
-
`-- cannot emit this automatically. Please edit this migration to use`,
|
|
441
|
-
`-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
|
|
442
|
-
`-- ================================================================`,
|
|
443
|
-
`SELECT 1;`,
|
|
444
|
-
].join("\n");
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function emitAlterColumnDefault(
|
|
448
|
-
resourceName: string,
|
|
449
|
-
fieldName: string,
|
|
450
|
-
def: DdlFieldDef["default"] | undefined,
|
|
451
|
-
provider: SqlProvider,
|
|
452
|
-
): string {
|
|
453
|
-
const table = quoteIdent(resourceName, provider);
|
|
454
|
-
const col = quoteIdent(fieldName, provider);
|
|
455
|
-
if (provider === "postgres") {
|
|
456
|
-
return def
|
|
457
|
-
? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
|
|
458
|
-
: `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
|
|
459
|
-
}
|
|
460
|
-
if (provider === "sqlite") {
|
|
461
|
-
// Same constraint as nullability — SQLite needs a table recreate.
|
|
462
|
-
return [
|
|
463
|
-
`-- ================================================================`,
|
|
464
|
-
`-- Default change: ${resourceName}.${fieldName}`,
|
|
465
|
-
`-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
|
|
466
|
-
`-- ================================================================`,
|
|
467
|
-
`SELECT 1;`,
|
|
468
|
-
].join("\n");
|
|
469
|
-
}
|
|
470
|
-
// MySQL — ALTER COLUMN ... SET DEFAULT / DROP DEFAULT is actually supported.
|
|
471
|
-
return def
|
|
472
|
-
? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
|
|
473
|
-
: `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
function emitAddIndex(
|
|
477
|
-
resourceName: string,
|
|
478
|
-
index: DdlIndex,
|
|
479
|
-
provider: SqlProvider,
|
|
480
|
-
): string {
|
|
481
|
-
if (!index.fields || index.fields.length === 0) {
|
|
482
|
-
throw new Error(`DdlIndex "${index.name}" has no fields`);
|
|
483
|
-
}
|
|
484
|
-
return emitCreateIndex(resourceName, index.name, index.fields, index.unique, provider);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function emitDropIndex(
|
|
488
|
-
resourceName: string,
|
|
489
|
-
indexName: string,
|
|
490
|
-
provider: SqlProvider,
|
|
491
|
-
): string {
|
|
492
|
-
const idx = quoteIdent(indexName, provider);
|
|
493
|
-
if (provider === "mysql") {
|
|
494
|
-
// MySQL requires the table name — DROP INDEX ... ON table.
|
|
495
|
-
const table = quoteIdent(resourceName, provider);
|
|
496
|
-
return `DROP INDEX ${idx} ON ${table};`;
|
|
497
|
-
}
|
|
498
|
-
// Postgres + SQLite use standalone DROP INDEX (index names are unique
|
|
499
|
-
// per-schema in PG, per-database in SQLite).
|
|
500
|
-
return `DROP INDEX ${idx};`;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function emitRenameTable(
|
|
504
|
-
oldName: string,
|
|
505
|
-
newName: string,
|
|
506
|
-
provider: SqlProvider,
|
|
507
|
-
): string {
|
|
508
|
-
const from = quoteIdent(oldName, provider);
|
|
509
|
-
const to = quoteIdent(newName, provider);
|
|
510
|
-
return `ALTER TABLE ${from} RENAME TO ${to};`;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function emitRenameColumn(
|
|
514
|
-
resourceName: string,
|
|
515
|
-
oldName: string,
|
|
516
|
-
newName: string,
|
|
517
|
-
provider: SqlProvider,
|
|
518
|
-
): string {
|
|
519
|
-
const table = quoteIdent(resourceName, provider);
|
|
520
|
-
const from = quoteIdent(oldName, provider);
|
|
521
|
-
const to = quoteIdent(newName, provider);
|
|
522
|
-
// All three dialects use `ALTER TABLE ... RENAME COLUMN ... TO ...` in
|
|
523
|
-
// their modern versions (PG >=9.2, MySQL >=8.0.3, SQLite >=3.25).
|
|
524
|
-
return `ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to};`;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// =====================================================================
|
|
528
|
-
// Validation helpers.
|
|
529
|
-
// =====================================================================
|
|
530
|
-
|
|
531
|
-
function validateIndex(resource: DdlResource, index: DdlIndex): void {
|
|
532
|
-
if (!index.name) {
|
|
533
|
-
throw new Error(`resource "${resource.name}" has an unnamed index`);
|
|
534
|
-
}
|
|
535
|
-
if (!index.fields || index.fields.length === 0) {
|
|
536
|
-
throw new Error(`index "${index.name}" on "${resource.name}" has no fields`);
|
|
537
|
-
}
|
|
538
|
-
const fieldNames = new Set(resource.fields.map((f) => f.name));
|
|
539
|
-
for (const f of index.fields) {
|
|
540
|
-
if (!fieldNames.has(f)) {
|
|
541
|
-
throw new Error(
|
|
542
|
-
`index "${index.name}" on "${resource.name}" references unknown field "${f}"`,
|
|
543
|
-
);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// Internal exports for white-box tests ONLY. Not part of the public API
|
|
549
|
-
// (consumers should never rely on these names being stable).
|
|
550
|
-
export const _internal = {
|
|
551
|
-
emitColumnDef,
|
|
552
|
-
autoIndexName,
|
|
553
|
-
validateIndex,
|
|
554
|
-
MAX_IDENT_LENGTH,
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
// Re-export the type-map functions so consumers can import from a single
|
|
558
|
-
// entry point. No logic added — just pass-through.
|
|
559
|
-
export { nowExpr, resolveColumnType, resolveDefault } from "./type-map";
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4c — DDL emission engine.
|
|
3
|
+
*
|
|
4
|
+
* Pure, deterministic translation from `DdlResource` / `Change` objects
|
|
5
|
+
* into dialect-correct SQL strings for Postgres, MySQL, and SQLite.
|
|
6
|
+
*
|
|
7
|
+
* Design invariants (Agents B/C/D/E/G rely on these):
|
|
8
|
+
* 1. NO I/O. Every function is synchronous and takes / returns strings
|
|
9
|
+
* or plain objects. No filesystem, no database connection.
|
|
10
|
+
* 2. NO dependency on `Bun.SQL` or any driver. We only emit SQL text —
|
|
11
|
+
* Agent C's runner handles execution.
|
|
12
|
+
* 3. Every identifier — table names, column names, index names — MUST
|
|
13
|
+
* flow through `quoteIdent`. Direct string interpolation of
|
|
14
|
+
* identifiers into SQL is a security audit failure (Agent G).
|
|
15
|
+
* 4. Value literals in `DEFAULT` clauses flow through `resolveDefault`
|
|
16
|
+
* which handles quote escaping. `kind: "sql"` is the explicit
|
|
17
|
+
* escape hatch — caller's responsibility.
|
|
18
|
+
* 5. Unsupported changes (v1 scope — `alter-column-type`) emit a
|
|
19
|
+
* `-- TODO:` comment block + a no-op `SELECT 1;` so the generated
|
|
20
|
+
* migration still parses and Agent C's runner can record an
|
|
21
|
+
* "applied but manual" row.
|
|
22
|
+
*
|
|
23
|
+
* Determinism:
|
|
24
|
+
* - `emitCreateTable` emits columns in `DdlFieldDef` array order — the
|
|
25
|
+
* snapshot layer (Agent B) is responsible for stabilizing that order
|
|
26
|
+
* (see §"Deterministic ordering" in docs/bun/phase-4c-team-plan.md).
|
|
27
|
+
* - `emitChanges` emits in the order received. Agent B guarantees
|
|
28
|
+
* deterministic change order; emit does not re-sort.
|
|
29
|
+
*
|
|
30
|
+
* References:
|
|
31
|
+
* - docs/bun/phase-4c-team-plan.md §2 (types) + §3 Agent A
|
|
32
|
+
* - docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
|
|
33
|
+
* - DNA/drizzle-orm/drizzle-kit/src/sqlgenerator.ts (reference emitter)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
nowExpr,
|
|
38
|
+
resolveColumnType,
|
|
39
|
+
resolveDefault,
|
|
40
|
+
} from "./type-map";
|
|
41
|
+
import type {
|
|
42
|
+
Change,
|
|
43
|
+
DdlFieldDef,
|
|
44
|
+
DdlIndex,
|
|
45
|
+
DdlResource,
|
|
46
|
+
SqlProvider,
|
|
47
|
+
} from "./types";
|
|
48
|
+
|
|
49
|
+
// =====================================================================
|
|
50
|
+
// Identifier quoting — security-critical (audited by Agent G).
|
|
51
|
+
// =====================================================================
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Maximum length for an identifier before we refuse to emit.
|
|
55
|
+
*
|
|
56
|
+
* Postgres allows 63 bytes (NAMEDATALEN - 1), MySQL 64. We pick the
|
|
57
|
+
* tighter bound so identity rules are uniform across dialects and
|
|
58
|
+
* cross-dialect migration files don't surprise a user switching providers.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_IDENT_LENGTH = 63;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Dialect-correct identifier quoter.
|
|
64
|
+
*
|
|
65
|
+
* - Postgres / SQLite: wraps in ANSI double quotes. Rejects names
|
|
66
|
+
* containing `"` — there is no portable escape (`""` works on PG but
|
|
67
|
+
* complicates downstream tooling and is not idiomatic for generated
|
|
68
|
+
* code).
|
|
69
|
+
* - MySQL: wraps in backticks. Rejects names containing `` ` ``.
|
|
70
|
+
*
|
|
71
|
+
* All providers additionally reject:
|
|
72
|
+
* - empty string
|
|
73
|
+
* - names longer than 63 chars (PG limit — stricter than MySQL's 64)
|
|
74
|
+
* - NUL bytes (`\0`) — a driver-level crash on several Bun.SQL backends
|
|
75
|
+
*
|
|
76
|
+
* @throws {Error} with a clear message when any rule is violated.
|
|
77
|
+
*/
|
|
78
|
+
export function quoteIdent(name: string, provider: SqlProvider): string {
|
|
79
|
+
if (typeof name !== "string") {
|
|
80
|
+
throw new Error(`identifier must be a string, got ${typeof name}`);
|
|
81
|
+
}
|
|
82
|
+
if (name.length === 0) {
|
|
83
|
+
throw new Error("identifier must not be empty");
|
|
84
|
+
}
|
|
85
|
+
if (name.length > MAX_IDENT_LENGTH) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`identifier too long (${name.length} > ${MAX_IDENT_LENGTH}): ${name.slice(0, 32)}...`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (name.includes("\0")) {
|
|
91
|
+
throw new Error("identifier contains NUL byte");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (provider === "mysql") {
|
|
95
|
+
if (name.includes("`")) {
|
|
96
|
+
throw new Error(`identifier contains unquotable character: ${JSON.stringify(name)}`);
|
|
97
|
+
}
|
|
98
|
+
return `\`${name}\``;
|
|
99
|
+
}
|
|
100
|
+
// Postgres, SQLite
|
|
101
|
+
if (name.includes('"')) {
|
|
102
|
+
throw new Error(`identifier contains unquotable character: ${JSON.stringify(name)}`);
|
|
103
|
+
}
|
|
104
|
+
return `"${name}"`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// =====================================================================
|
|
108
|
+
// Column + index emission helpers.
|
|
109
|
+
// =====================================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Emit a single column definition line (no leading whitespace, no trailing
|
|
113
|
+
* comma). Used inside `CREATE TABLE` parens and by `emitChange("add-column")`.
|
|
114
|
+
*
|
|
115
|
+
* Column order of modifiers (chosen to match Drizzle / Atlas output for
|
|
116
|
+
* familiarity):
|
|
117
|
+
* <name> <type> [PRIMARY KEY] [NOT NULL] [UNIQUE] [DEFAULT <expr>]
|
|
118
|
+
*
|
|
119
|
+
* Rationale:
|
|
120
|
+
* - `PRIMARY KEY` before `NOT NULL` mirrors Postgres/MySQL docs.
|
|
121
|
+
* - `DEFAULT` last because some drivers (SQLite) parse `NOT NULL DEFAULT`
|
|
122
|
+
* more reliably than the reverse.
|
|
123
|
+
*/
|
|
124
|
+
function emitColumnDef(field: DdlFieldDef, provider: SqlProvider): string {
|
|
125
|
+
const ident = quoteIdent(field.name, provider);
|
|
126
|
+
const type = resolveColumnType(field, provider);
|
|
127
|
+
const parts: string[] = [ident, type];
|
|
128
|
+
|
|
129
|
+
if (field.primary) parts.push("PRIMARY KEY");
|
|
130
|
+
// PRIMARY KEY implies NOT NULL everywhere; skip the explicit marker.
|
|
131
|
+
if (!field.nullable && !field.primary) parts.push("NOT NULL");
|
|
132
|
+
if (field.unique && !field.primary) parts.push("UNIQUE");
|
|
133
|
+
|
|
134
|
+
if (field.default) {
|
|
135
|
+
parts.push(`DEFAULT ${resolveDefault(field.default, provider)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return parts.join(" ");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Stable single-column index name. Used when a field is flagged
|
|
143
|
+
* `indexed: true` but no explicit DdlIndex entry exists for it.
|
|
144
|
+
*
|
|
145
|
+
* Format: `idx_<table>_<column>` — shortened to fit within
|
|
146
|
+
* MAX_IDENT_LENGTH when either name is long.
|
|
147
|
+
*/
|
|
148
|
+
function autoIndexName(table: string, column: string): string {
|
|
149
|
+
const raw = `idx_${table}_${column}`;
|
|
150
|
+
if (raw.length <= MAX_IDENT_LENGTH) return raw;
|
|
151
|
+
// Truncate by taking prefix of table + suffix of column — preserves some
|
|
152
|
+
// readability while guaranteeing length compliance. Deterministic.
|
|
153
|
+
const budget = MAX_IDENT_LENGTH - "idx_".length - 1;
|
|
154
|
+
const halfBudget = Math.floor(budget / 2);
|
|
155
|
+
return `idx_${table.slice(0, halfBudget)}_${column.slice(-halfBudget)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Emit a `CREATE INDEX` statement for either:
|
|
160
|
+
* - an automatic single-column index on an `indexed: true` field, or
|
|
161
|
+
* - an explicit multi-column `DdlIndex`.
|
|
162
|
+
*/
|
|
163
|
+
function emitCreateIndex(
|
|
164
|
+
table: string,
|
|
165
|
+
name: string,
|
|
166
|
+
columns: readonly string[],
|
|
167
|
+
unique: boolean,
|
|
168
|
+
provider: SqlProvider,
|
|
169
|
+
): string {
|
|
170
|
+
const uniqueKw = unique ? "UNIQUE " : "";
|
|
171
|
+
const tbl = quoteIdent(table, provider);
|
|
172
|
+
const idx = quoteIdent(name, provider);
|
|
173
|
+
const cols = columns.map((c) => quoteIdent(c, provider)).join(", ");
|
|
174
|
+
return `CREATE ${uniqueKw}INDEX ${idx} ON ${tbl} (${cols});`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// =====================================================================
|
|
178
|
+
// Public API — resource-level emitters.
|
|
179
|
+
// =====================================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Emit the full initial-migration SQL for a single resource:
|
|
183
|
+
* one `CREATE TABLE` followed by zero or more `CREATE INDEX` statements.
|
|
184
|
+
*
|
|
185
|
+
* Output format:
|
|
186
|
+
* - No leading / trailing blank lines (caller composes).
|
|
187
|
+
* - `CREATE TABLE` spans multiple lines, one column per line, two-space
|
|
188
|
+
* indent — matches Drizzle-kit's output for diff readability.
|
|
189
|
+
* - Each statement ends with `;`. Statements separated by `\n`.
|
|
190
|
+
*
|
|
191
|
+
* Determinism: column order matches `resource.fields` array order, index
|
|
192
|
+
* order matches: all `indexed: true` fields (in field array order) first,
|
|
193
|
+
* then explicit `resource.indexes` (in array order).
|
|
194
|
+
*/
|
|
195
|
+
export function emitCreateTable(resource: DdlResource, provider: SqlProvider): string {
|
|
196
|
+
if (!Array.isArray(resource.fields) || resource.fields.length === 0) {
|
|
197
|
+
throw new Error(`resource "${resource.name}" has no fields — cannot emit CREATE TABLE`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const tableIdent = quoteIdent(resource.name, provider); // also validates
|
|
201
|
+
|
|
202
|
+
const columnLines = resource.fields.map((f) => ` ${emitColumnDef(f, provider)}`);
|
|
203
|
+
const createTable = [
|
|
204
|
+
`CREATE TABLE ${tableIdent} (`,
|
|
205
|
+
columnLines.join(",\n"),
|
|
206
|
+
`);`,
|
|
207
|
+
].join("\n");
|
|
208
|
+
|
|
209
|
+
const indexStatements: string[] = [];
|
|
210
|
+
|
|
211
|
+
// Auto-index from `field.indexed` (single column, non-unique).
|
|
212
|
+
for (const field of resource.fields) {
|
|
213
|
+
if (!field.indexed) continue;
|
|
214
|
+
// Skip if field is already unique (UNIQUE column constraint creates
|
|
215
|
+
// an implicit index on PG/MySQL; on SQLite a UNIQUE column also has
|
|
216
|
+
// an implicit index).
|
|
217
|
+
if (field.unique || field.primary) continue;
|
|
218
|
+
indexStatements.push(
|
|
219
|
+
emitCreateIndex(
|
|
220
|
+
resource.name,
|
|
221
|
+
autoIndexName(resource.name, field.name),
|
|
222
|
+
[field.name],
|
|
223
|
+
false,
|
|
224
|
+
provider,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Explicit multi-column indexes.
|
|
230
|
+
for (const index of resource.indexes ?? []) {
|
|
231
|
+
validateIndex(resource, index);
|
|
232
|
+
indexStatements.push(
|
|
233
|
+
emitCreateIndex(resource.name, index.name, index.fields, index.unique, provider),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return indexStatements.length > 0
|
|
238
|
+
? `${createTable}\n${indexStatements.join("\n")}`
|
|
239
|
+
: createTable;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Emit a `DROP TABLE IF EXISTS` statement. `IF EXISTS` is present on all
|
|
244
|
+
* three dialects and makes migrations idempotent on re-run.
|
|
245
|
+
*/
|
|
246
|
+
export function emitDropTable(resourceName: string, provider: SqlProvider): string {
|
|
247
|
+
return `DROP TABLE IF EXISTS ${quoteIdent(resourceName, provider)};`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Emit the full initial schema for many resources. Each resource becomes a
|
|
252
|
+
* `CREATE TABLE` (+ its indexes); blocks are separated by one blank line
|
|
253
|
+
* for readability.
|
|
254
|
+
*
|
|
255
|
+
* Deterministic in resource array order — the snapshot layer is
|
|
256
|
+
* responsible for stable ordering.
|
|
257
|
+
*/
|
|
258
|
+
export function emitSchema(resources: DdlResource[], provider: SqlProvider): string {
|
|
259
|
+
if (resources.length === 0) return "";
|
|
260
|
+
return resources.map((r) => emitCreateTable(r, provider)).join("\n\n");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Emit a sequence of `Change` objects as a single migration SQL string.
|
|
265
|
+
* Each change becomes one or more statements, separated by newlines.
|
|
266
|
+
*
|
|
267
|
+
* Empty input returns empty string (not whitespace) — makes it safe to
|
|
268
|
+
* concatenate with a header or leave out when unused.
|
|
269
|
+
*/
|
|
270
|
+
export function emitChanges(changes: readonly Change[], provider: SqlProvider): string {
|
|
271
|
+
if (changes.length === 0) return "";
|
|
272
|
+
return changes.map((c) => emitChange(c, provider)).join("\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// =====================================================================
|
|
276
|
+
// Change dispatch — one branch per `Change.kind`.
|
|
277
|
+
// =====================================================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Dispatch a single `Change` to the corresponding SQL statement(s).
|
|
281
|
+
*
|
|
282
|
+
* Unknown `kind` throws (defensive against future `Change` variants added
|
|
283
|
+
* to `types.ts` without a matching emitter update).
|
|
284
|
+
*/
|
|
285
|
+
export function emitChange(change: Change, provider: SqlProvider): string {
|
|
286
|
+
switch (change.kind) {
|
|
287
|
+
case "create-table":
|
|
288
|
+
return emitCreateTable(change.resource, provider);
|
|
289
|
+
case "drop-table":
|
|
290
|
+
return emitDropTable(change.resourceName, provider);
|
|
291
|
+
case "add-column":
|
|
292
|
+
return emitAddColumn(change.resourceName, change.field, provider);
|
|
293
|
+
case "drop-column":
|
|
294
|
+
return emitDropColumn(change.resourceName, change.fieldName, provider);
|
|
295
|
+
case "alter-column-type":
|
|
296
|
+
return emitAlterColumnTypeStub(
|
|
297
|
+
change.resourceName,
|
|
298
|
+
change.fieldName,
|
|
299
|
+
change.fromType,
|
|
300
|
+
change.toType,
|
|
301
|
+
);
|
|
302
|
+
case "alter-column-nullable":
|
|
303
|
+
return emitAlterColumnNullable(
|
|
304
|
+
change.resourceName,
|
|
305
|
+
change.fieldName,
|
|
306
|
+
change.nullable,
|
|
307
|
+
provider,
|
|
308
|
+
);
|
|
309
|
+
case "alter-column-default":
|
|
310
|
+
return emitAlterColumnDefault(
|
|
311
|
+
change.resourceName,
|
|
312
|
+
change.fieldName,
|
|
313
|
+
change.default,
|
|
314
|
+
provider,
|
|
315
|
+
);
|
|
316
|
+
case "add-index":
|
|
317
|
+
return emitAddIndex(change.resourceName, change.index, provider);
|
|
318
|
+
case "drop-index":
|
|
319
|
+
return emitDropIndex(change.resourceName, change.indexName, provider);
|
|
320
|
+
case "rename-table":
|
|
321
|
+
return emitRenameTable(change.oldName, change.newName, provider);
|
|
322
|
+
case "rename-column":
|
|
323
|
+
return emitRenameColumn(
|
|
324
|
+
change.resourceName,
|
|
325
|
+
change.oldName,
|
|
326
|
+
change.newName,
|
|
327
|
+
provider,
|
|
328
|
+
);
|
|
329
|
+
default: {
|
|
330
|
+
// `never` check — keeps this function exhaustive with types.ts.
|
|
331
|
+
const _exhaustive: never = change;
|
|
332
|
+
throw new Error(`unknown Change.kind: ${JSON.stringify(_exhaustive)}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// =====================================================================
|
|
338
|
+
// Change emitters (internal — called only via emitChange dispatch).
|
|
339
|
+
// =====================================================================
|
|
340
|
+
|
|
341
|
+
function emitAddColumn(
|
|
342
|
+
resourceName: string,
|
|
343
|
+
field: DdlFieldDef,
|
|
344
|
+
provider: SqlProvider,
|
|
345
|
+
): string {
|
|
346
|
+
const table = quoteIdent(resourceName, provider);
|
|
347
|
+
const columnDef = emitColumnDef(field, provider);
|
|
348
|
+
const addColumn = `ALTER TABLE ${table} ADD COLUMN ${columnDef};`;
|
|
349
|
+
if (!field.indexed || field.unique || field.primary) return addColumn;
|
|
350
|
+
return [
|
|
351
|
+
addColumn,
|
|
352
|
+
emitCreateIndex(
|
|
353
|
+
resourceName,
|
|
354
|
+
autoIndexName(resourceName, field.name),
|
|
355
|
+
[field.name],
|
|
356
|
+
false,
|
|
357
|
+
provider,
|
|
358
|
+
),
|
|
359
|
+
].join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* DROP COLUMN.
|
|
364
|
+
*
|
|
365
|
+
* Note on SQLite: DROP COLUMN landed in SQLite 3.35 (March 2021). Bun's
|
|
366
|
+
* bundled SQLite is modern (3.46+ as of Bun 1.3), so we emit the standard
|
|
367
|
+
* `ALTER TABLE ... DROP COLUMN ...` and assume the target engine is >=3.35.
|
|
368
|
+
* Downstream environments pinning an older SQLite will need to manually
|
|
369
|
+
* use the `recreate table` dance — Agent F's matrix tests cover 3.46.
|
|
370
|
+
*/
|
|
371
|
+
function emitDropColumn(
|
|
372
|
+
resourceName: string,
|
|
373
|
+
fieldName: string,
|
|
374
|
+
provider: SqlProvider,
|
|
375
|
+
): string {
|
|
376
|
+
const table = quoteIdent(resourceName, provider);
|
|
377
|
+
const col = quoteIdent(fieldName, provider);
|
|
378
|
+
return `ALTER TABLE ${table} DROP COLUMN ${col};`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Emit a stub `-- TODO:` block for v1-unsupported `alter-column-type`.
|
|
383
|
+
*
|
|
384
|
+
* Output: a multi-line SQL comment block naming the resource + field +
|
|
385
|
+
* fromType → toType, followed by the literal TODO message and a no-op
|
|
386
|
+
* `SELECT 1;` so the migration runner (Agent C) parses and advances past
|
|
387
|
+
* this statement.
|
|
388
|
+
*/
|
|
389
|
+
function emitAlterColumnTypeStub(
|
|
390
|
+
resourceName: string,
|
|
391
|
+
fieldName: string,
|
|
392
|
+
fromType: string,
|
|
393
|
+
toType: string,
|
|
394
|
+
): string {
|
|
395
|
+
return [
|
|
396
|
+
`-- ================================================================`,
|
|
397
|
+
`-- Column type change detected: ${resourceName}.${fieldName}`,
|
|
398
|
+
`-- from: ${fromType}`,
|
|
399
|
+
`-- to: ${toType}`,
|
|
400
|
+
`-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.`,
|
|
401
|
+
`-- Please write the migration manually, then re-run \`mandu db apply\`.`,
|
|
402
|
+
`-- ================================================================`,
|
|
403
|
+
`SELECT 1;`,
|
|
404
|
+
].join("\n");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function emitAlterColumnNullable(
|
|
408
|
+
resourceName: string,
|
|
409
|
+
fieldName: string,
|
|
410
|
+
nullable: boolean,
|
|
411
|
+
provider: SqlProvider,
|
|
412
|
+
): string {
|
|
413
|
+
const table = quoteIdent(resourceName, provider);
|
|
414
|
+
const col = quoteIdent(fieldName, provider);
|
|
415
|
+
if (provider === "postgres") {
|
|
416
|
+
// PG supports direct SET/DROP NOT NULL on existing columns.
|
|
417
|
+
return nullable
|
|
418
|
+
? `ALTER TABLE ${table} ALTER COLUMN ${col} DROP NOT NULL;`
|
|
419
|
+
: `ALTER TABLE ${table} ALTER COLUMN ${col} SET NOT NULL;`;
|
|
420
|
+
}
|
|
421
|
+
if (provider === "sqlite") {
|
|
422
|
+
// SQLite cannot toggle NOT NULL on an existing column without a full
|
|
423
|
+
// table recreate. Emit a stub so the user handles it manually.
|
|
424
|
+
return [
|
|
425
|
+
`-- ================================================================`,
|
|
426
|
+
`-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
|
|
427
|
+
`-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
|
|
428
|
+
`-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
|
|
429
|
+
`-- ================================================================`,
|
|
430
|
+
`SELECT 1;`,
|
|
431
|
+
].join("\n");
|
|
432
|
+
}
|
|
433
|
+
// MySQL requires the full column spec; we cannot reconstruct it here.
|
|
434
|
+
// Emit a stub — Agent B's diff emits `alter-column-nullable` only when
|
|
435
|
+
// everything else matches, so this is narrow-scope.
|
|
436
|
+
return [
|
|
437
|
+
`-- ================================================================`,
|
|
438
|
+
`-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
|
|
439
|
+
`-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
|
|
440
|
+
`-- cannot emit this automatically. Please edit this migration to use`,
|
|
441
|
+
`-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
|
|
442
|
+
`-- ================================================================`,
|
|
443
|
+
`SELECT 1;`,
|
|
444
|
+
].join("\n");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function emitAlterColumnDefault(
|
|
448
|
+
resourceName: string,
|
|
449
|
+
fieldName: string,
|
|
450
|
+
def: DdlFieldDef["default"] | undefined,
|
|
451
|
+
provider: SqlProvider,
|
|
452
|
+
): string {
|
|
453
|
+
const table = quoteIdent(resourceName, provider);
|
|
454
|
+
const col = quoteIdent(fieldName, provider);
|
|
455
|
+
if (provider === "postgres") {
|
|
456
|
+
return def
|
|
457
|
+
? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
|
|
458
|
+
: `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
|
|
459
|
+
}
|
|
460
|
+
if (provider === "sqlite") {
|
|
461
|
+
// Same constraint as nullability — SQLite needs a table recreate.
|
|
462
|
+
return [
|
|
463
|
+
`-- ================================================================`,
|
|
464
|
+
`-- Default change: ${resourceName}.${fieldName}`,
|
|
465
|
+
`-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
|
|
466
|
+
`-- ================================================================`,
|
|
467
|
+
`SELECT 1;`,
|
|
468
|
+
].join("\n");
|
|
469
|
+
}
|
|
470
|
+
// MySQL — ALTER COLUMN ... SET DEFAULT / DROP DEFAULT is actually supported.
|
|
471
|
+
return def
|
|
472
|
+
? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
|
|
473
|
+
: `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function emitAddIndex(
|
|
477
|
+
resourceName: string,
|
|
478
|
+
index: DdlIndex,
|
|
479
|
+
provider: SqlProvider,
|
|
480
|
+
): string {
|
|
481
|
+
if (!index.fields || index.fields.length === 0) {
|
|
482
|
+
throw new Error(`DdlIndex "${index.name}" has no fields`);
|
|
483
|
+
}
|
|
484
|
+
return emitCreateIndex(resourceName, index.name, index.fields, index.unique, provider);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function emitDropIndex(
|
|
488
|
+
resourceName: string,
|
|
489
|
+
indexName: string,
|
|
490
|
+
provider: SqlProvider,
|
|
491
|
+
): string {
|
|
492
|
+
const idx = quoteIdent(indexName, provider);
|
|
493
|
+
if (provider === "mysql") {
|
|
494
|
+
// MySQL requires the table name — DROP INDEX ... ON table.
|
|
495
|
+
const table = quoteIdent(resourceName, provider);
|
|
496
|
+
return `DROP INDEX ${idx} ON ${table};`;
|
|
497
|
+
}
|
|
498
|
+
// Postgres + SQLite use standalone DROP INDEX (index names are unique
|
|
499
|
+
// per-schema in PG, per-database in SQLite).
|
|
500
|
+
return `DROP INDEX ${idx};`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function emitRenameTable(
|
|
504
|
+
oldName: string,
|
|
505
|
+
newName: string,
|
|
506
|
+
provider: SqlProvider,
|
|
507
|
+
): string {
|
|
508
|
+
const from = quoteIdent(oldName, provider);
|
|
509
|
+
const to = quoteIdent(newName, provider);
|
|
510
|
+
return `ALTER TABLE ${from} RENAME TO ${to};`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function emitRenameColumn(
|
|
514
|
+
resourceName: string,
|
|
515
|
+
oldName: string,
|
|
516
|
+
newName: string,
|
|
517
|
+
provider: SqlProvider,
|
|
518
|
+
): string {
|
|
519
|
+
const table = quoteIdent(resourceName, provider);
|
|
520
|
+
const from = quoteIdent(oldName, provider);
|
|
521
|
+
const to = quoteIdent(newName, provider);
|
|
522
|
+
// All three dialects use `ALTER TABLE ... RENAME COLUMN ... TO ...` in
|
|
523
|
+
// their modern versions (PG >=9.2, MySQL >=8.0.3, SQLite >=3.25).
|
|
524
|
+
return `ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to};`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// =====================================================================
|
|
528
|
+
// Validation helpers.
|
|
529
|
+
// =====================================================================
|
|
530
|
+
|
|
531
|
+
function validateIndex(resource: DdlResource, index: DdlIndex): void {
|
|
532
|
+
if (!index.name) {
|
|
533
|
+
throw new Error(`resource "${resource.name}" has an unnamed index`);
|
|
534
|
+
}
|
|
535
|
+
if (!index.fields || index.fields.length === 0) {
|
|
536
|
+
throw new Error(`index "${index.name}" on "${resource.name}" has no fields`);
|
|
537
|
+
}
|
|
538
|
+
const fieldNames = new Set(resource.fields.map((f) => f.name));
|
|
539
|
+
for (const f of index.fields) {
|
|
540
|
+
if (!fieldNames.has(f)) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`index "${index.name}" on "${resource.name}" references unknown field "${f}"`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Internal exports for white-box tests ONLY. Not part of the public API
|
|
549
|
+
// (consumers should never rely on these names being stable).
|
|
550
|
+
export const _internal = {
|
|
551
|
+
emitColumnDef,
|
|
552
|
+
autoIndexName,
|
|
553
|
+
validateIndex,
|
|
554
|
+
MAX_IDENT_LENGTH,
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// Re-export the type-map functions so consumers can import from a single
|
|
558
|
+
// entry point. No logic added — just pass-through.
|
|
559
|
+
export { nowExpr, resolveColumnType, resolveDefault } from "./type-map";
|