@mandujs/core 0.20.10 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -0,0 +1,548 @@
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
+ return `ALTER TABLE ${table} ADD COLUMN ${columnDef};`;
349
+ }
350
+
351
+ /**
352
+ * DROP COLUMN.
353
+ *
354
+ * Note on SQLite: DROP COLUMN landed in SQLite 3.35 (March 2021). Bun's
355
+ * bundled SQLite is modern (3.46+ as of Bun 1.3), so we emit the standard
356
+ * `ALTER TABLE ... DROP COLUMN ...` and assume the target engine is >=3.35.
357
+ * Downstream environments pinning an older SQLite will need to manually
358
+ * use the `recreate table` dance — Agent F's matrix tests cover 3.46.
359
+ */
360
+ function emitDropColumn(
361
+ resourceName: string,
362
+ fieldName: string,
363
+ provider: SqlProvider,
364
+ ): string {
365
+ const table = quoteIdent(resourceName, provider);
366
+ const col = quoteIdent(fieldName, provider);
367
+ return `ALTER TABLE ${table} DROP COLUMN ${col};`;
368
+ }
369
+
370
+ /**
371
+ * Emit a stub `-- TODO:` block for v1-unsupported `alter-column-type`.
372
+ *
373
+ * Output: a multi-line SQL comment block naming the resource + field +
374
+ * fromType → toType, followed by the literal TODO message and a no-op
375
+ * `SELECT 1;` so the migration runner (Agent C) parses and advances past
376
+ * this statement.
377
+ */
378
+ function emitAlterColumnTypeStub(
379
+ resourceName: string,
380
+ fieldName: string,
381
+ fromType: string,
382
+ toType: string,
383
+ ): string {
384
+ return [
385
+ `-- ================================================================`,
386
+ `-- Column type change detected: ${resourceName}.${fieldName}`,
387
+ `-- from: ${fromType}`,
388
+ `-- to: ${toType}`,
389
+ `-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.`,
390
+ `-- Please write the migration manually, then re-run \`mandu db apply\`.`,
391
+ `-- ================================================================`,
392
+ `SELECT 1;`,
393
+ ].join("\n");
394
+ }
395
+
396
+ function emitAlterColumnNullable(
397
+ resourceName: string,
398
+ fieldName: string,
399
+ nullable: boolean,
400
+ provider: SqlProvider,
401
+ ): string {
402
+ const table = quoteIdent(resourceName, provider);
403
+ const col = quoteIdent(fieldName, provider);
404
+ if (provider === "postgres") {
405
+ // PG supports direct SET/DROP NOT NULL on existing columns.
406
+ return nullable
407
+ ? `ALTER TABLE ${table} ALTER COLUMN ${col} DROP NOT NULL;`
408
+ : `ALTER TABLE ${table} ALTER COLUMN ${col} SET NOT NULL;`;
409
+ }
410
+ if (provider === "sqlite") {
411
+ // SQLite cannot toggle NOT NULL on an existing column without a full
412
+ // table recreate. Emit a stub so the user handles it manually.
413
+ return [
414
+ `-- ================================================================`,
415
+ `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
416
+ `-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
417
+ `-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
418
+ `-- ================================================================`,
419
+ `SELECT 1;`,
420
+ ].join("\n");
421
+ }
422
+ // MySQL requires the full column spec; we cannot reconstruct it here.
423
+ // Emit a stub — Agent B's diff emits `alter-column-nullable` only when
424
+ // everything else matches, so this is narrow-scope.
425
+ return [
426
+ `-- ================================================================`,
427
+ `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
428
+ `-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
429
+ `-- cannot emit this automatically. Please edit this migration to use`,
430
+ `-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
431
+ `-- ================================================================`,
432
+ `SELECT 1;`,
433
+ ].join("\n");
434
+ }
435
+
436
+ function emitAlterColumnDefault(
437
+ resourceName: string,
438
+ fieldName: string,
439
+ def: DdlFieldDef["default"] | undefined,
440
+ provider: SqlProvider,
441
+ ): string {
442
+ const table = quoteIdent(resourceName, provider);
443
+ const col = quoteIdent(fieldName, provider);
444
+ if (provider === "postgres") {
445
+ return def
446
+ ? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
447
+ : `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
448
+ }
449
+ if (provider === "sqlite") {
450
+ // Same constraint as nullability — SQLite needs a table recreate.
451
+ return [
452
+ `-- ================================================================`,
453
+ `-- Default change: ${resourceName}.${fieldName}`,
454
+ `-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
455
+ `-- ================================================================`,
456
+ `SELECT 1;`,
457
+ ].join("\n");
458
+ }
459
+ // MySQL — ALTER COLUMN ... SET DEFAULT / DROP DEFAULT is actually supported.
460
+ return def
461
+ ? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
462
+ : `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
463
+ }
464
+
465
+ function emitAddIndex(
466
+ resourceName: string,
467
+ index: DdlIndex,
468
+ provider: SqlProvider,
469
+ ): string {
470
+ if (!index.fields || index.fields.length === 0) {
471
+ throw new Error(`DdlIndex "${index.name}" has no fields`);
472
+ }
473
+ return emitCreateIndex(resourceName, index.name, index.fields, index.unique, provider);
474
+ }
475
+
476
+ function emitDropIndex(
477
+ resourceName: string,
478
+ indexName: string,
479
+ provider: SqlProvider,
480
+ ): string {
481
+ const idx = quoteIdent(indexName, provider);
482
+ if (provider === "mysql") {
483
+ // MySQL requires the table name — DROP INDEX ... ON table.
484
+ const table = quoteIdent(resourceName, provider);
485
+ return `DROP INDEX ${idx} ON ${table};`;
486
+ }
487
+ // Postgres + SQLite use standalone DROP INDEX (index names are unique
488
+ // per-schema in PG, per-database in SQLite).
489
+ return `DROP INDEX ${idx};`;
490
+ }
491
+
492
+ function emitRenameTable(
493
+ oldName: string,
494
+ newName: string,
495
+ provider: SqlProvider,
496
+ ): string {
497
+ const from = quoteIdent(oldName, provider);
498
+ const to = quoteIdent(newName, provider);
499
+ return `ALTER TABLE ${from} RENAME TO ${to};`;
500
+ }
501
+
502
+ function emitRenameColumn(
503
+ resourceName: string,
504
+ oldName: string,
505
+ newName: string,
506
+ provider: SqlProvider,
507
+ ): string {
508
+ const table = quoteIdent(resourceName, provider);
509
+ const from = quoteIdent(oldName, provider);
510
+ const to = quoteIdent(newName, provider);
511
+ // All three dialects use `ALTER TABLE ... RENAME COLUMN ... TO ...` in
512
+ // their modern versions (PG >=9.2, MySQL >=8.0.3, SQLite >=3.25).
513
+ return `ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to};`;
514
+ }
515
+
516
+ // =====================================================================
517
+ // Validation helpers.
518
+ // =====================================================================
519
+
520
+ function validateIndex(resource: DdlResource, index: DdlIndex): void {
521
+ if (!index.name) {
522
+ throw new Error(`resource "${resource.name}" has an unnamed index`);
523
+ }
524
+ if (!index.fields || index.fields.length === 0) {
525
+ throw new Error(`index "${index.name}" on "${resource.name}" has no fields`);
526
+ }
527
+ const fieldNames = new Set(resource.fields.map((f) => f.name));
528
+ for (const f of index.fields) {
529
+ if (!fieldNames.has(f)) {
530
+ throw new Error(
531
+ `index "${index.name}" on "${resource.name}" references unknown field "${f}"`,
532
+ );
533
+ }
534
+ }
535
+ }
536
+
537
+ // Internal exports for white-box tests ONLY. Not part of the public API
538
+ // (consumers should never rely on these names being stable).
539
+ export const _internal = {
540
+ emitColumnDef,
541
+ autoIndexName,
542
+ validateIndex,
543
+ MAX_IDENT_LENGTH,
544
+ };
545
+
546
+ // Re-export the type-map functions so consumers can import from a single
547
+ // entry point. No logic added — just pass-through.
548
+ export { nowExpr, resolveColumnType, resolveDefault } from "./type-map";