@mandujs/core 0.21.0 → 0.22.1

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