@mandujs/core 0.42.0 → 0.43.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 (89) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/metrics.ts +334 -334
  64. package/src/observability/tracing.ts +694 -694
  65. package/src/openapi/generator.ts +1 -1
  66. package/src/perf/user-marks.ts +553 -553
  67. package/src/plugins/registry.ts +387 -387
  68. package/src/resource/ddl/diff.ts +392 -392
  69. package/src/resource/ddl/snapshot.ts +448 -447
  70. package/src/resource/generator-schema.ts +477 -476
  71. package/src/resource/parser.ts +4 -2
  72. package/src/resource/schema.ts +1 -1
  73. package/src/router/fs-patterns.ts +422 -422
  74. package/src/runtime/fast-refresh-types.ts +126 -128
  75. package/src/runtime/handlers.ts +300 -0
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/index.ts +4 -0
  78. package/src/runtime/router.test.ts +476 -476
  79. package/src/runtime/security.ts +155 -155
  80. package/src/runtime/server.ts +36 -19
  81. package/src/runtime/session-key.ts +328 -328
  82. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  83. package/src/seo/resolve/index.ts +353 -353
  84. package/src/spec/load.ts +1 -1
  85. package/src/testing/reporter.ts +676 -676
  86. package/src/testing/server.ts +196 -196
  87. package/src/testing/snapshot.ts +444 -444
  88. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  89. package/src/utils/bun.ts +8 -8
@@ -1,392 +1,392 @@
1
- /**
2
- * Phase 4c — Schema diff engine.
3
- *
4
- * Given an old `Snapshot` (or `null` for "no schema yet") and a next
5
- * `Snapshot`, produce a deterministic ordered list of `Change` entries.
6
- * The output feeds Agent A's `emitChange(change, provider)` to produce
7
- * the migration SQL body.
8
- *
9
- * # Rename policy
10
- *
11
- * This engine NEVER emits `rename-table` or `rename-column` — Appendix D.1
12
- * of the RFC and the team plan §7 are explicit: rename auto-detection is
13
- * dangerous (cost of a false positive = silent data loss across an
14
- * ADD+DROP pair). The CLI (Agent E) prompts the user to reinterpret
15
- * consecutive drop+add pairs as renames; only AFTER user confirmation
16
- * are `rename-*` Change entries inserted (with `origin: "user-confirmed"`).
17
- *
18
- * # Determinism
19
- *
20
- * Two identical inputs MUST produce byte-identical `Change[]` across
21
- * runs. We achieve this with:
22
- * 1. Alphabetical sort at every partition step.
23
- * 2. Exhaustive ordering rules below — no "depends on iteration order".
24
- *
25
- * # Emit order (cross-table)
26
- *
27
- * 1. All `drop-index` sort by (resourceName, indexName)
28
- * 2. All `drop-table` sort by resourceName
29
- * 3. All `create-table` sort by resourceName (indexes included inline)
30
- * 4. Per-kept-table, in resourceName order:
31
- * a. drop-column
32
- * b. alter-column-type
33
- * c. alter-column-nullable
34
- * d. alter-column-default
35
- * e. add-column
36
- * 5. All `add-index` sort by (resourceName, indexName)
37
- *
38
- * # Rationale for this order
39
- *
40
- * Drop-index before drop-table: PG and MySQL allow `DROP TABLE` to
41
- * implicitly drop dependent indexes, but being explicit makes the
42
- * migration reversible when a reviewer edits the plan. Drop-column
43
- * before alter: dropping a column before altering its siblings avoids
44
- * "column not found" errors when the same table's shape is fluid.
45
- * Add-index last: the column(s) the index targets must already exist,
46
- * and a freshly-created table may also introduce new indexes — those
47
- * are emitted as part of `create-table` so this phase covers only
48
- * indexes added to tables that existed on both sides.
49
- *
50
- * # v1 non-goals
51
- *
52
- * - Rename auto-detection (always drop+add — see above)
53
- * - FK / CHECK / ENUM / composite PK diffing (not in scope)
54
- * - Cross-provider diff (thrown — a caller bug)
55
- *
56
- * References:
57
- * docs/bun/phase-4c-team-plan.md §3 Agent B
58
- * docs/rfcs/0001-db-resource-layer.md §7 (risk: rename false-positive)
59
- */
60
-
61
- import type { ParsedResource } from "../parser";
62
- import type {
63
- Change,
64
- DdlDefault,
65
- DdlFieldDef,
66
- DdlIndex,
67
- DdlResource,
68
- Snapshot,
69
- } from "./types";
70
- import { snapshotFromResources } from "./snapshot";
71
-
72
- // ============================================
73
- // Public API
74
- // ============================================
75
-
76
- /**
77
- * Compute the ordered list of `Change` entries from `old` → `next`.
78
- *
79
- * `old === null` means "no schema has been applied yet" — every resource
80
- * in `next` becomes a `create-table`.
81
- *
82
- * `next === null` is NOT a valid input — it would mean "delete everything",
83
- * which is dangerous enough that we force the caller to build an empty
84
- * Snapshot (`{ version: 1, provider, resources: [], generatedAt }`) to
85
- * express it explicitly. This surfaces in tests (`diff.test.ts` TC-3).
86
- *
87
- * @throws TypeError if the two snapshots declare different providers —
88
- * a cross-provider diff is meaningless and always a caller bug.
89
- */
90
- export function diffSnapshots(old: Snapshot | null, next: Snapshot): Change[] {
91
- if (next === null || next === undefined) {
92
- throw new TypeError(
93
- "diffSnapshots: `next` must be a Snapshot (use an empty-resources snapshot to express 'drop all')"
94
- );
95
- }
96
- if (old !== null && old.provider !== next.provider) {
97
- throw new TypeError(
98
- `Cross-provider diff: old is "${old.provider}", next is "${next.provider}". Snapshots from different SQL providers cannot be diffed — migrating across providers requires a manual dump+load, not a diff.`
99
- );
100
- }
101
-
102
- // Bucket every change by kind so we can sort within each kind and then
103
- // assemble in the canonical cross-table order at the end.
104
- const dropIndexes: Change[] = [];
105
- const dropTables: Change[] = [];
106
- const createTables: Change[] = [];
107
- // Per-kept-table changes — keyed by resourceName then ordered sub-lists
108
- // to maintain the within-table order invariant.
109
- const keptTables = new Map<
110
- string,
111
- {
112
- dropColumns: Change[];
113
- alterColumnTypes: Change[];
114
- alterColumnNullables: Change[];
115
- alterColumnDefaults: Change[];
116
- addColumns: Change[];
117
- }
118
- >();
119
- const addIndexes: Change[] = [];
120
-
121
- const oldByName = new Map((old?.resources ?? []).map((r) => [r.name, r]));
122
- const newByName = new Map(next.resources.map((r) => [r.name, r]));
123
-
124
- // Resources that disappear entirely → drop indexes, drop table.
125
- for (const [name, resource] of oldByName) {
126
- if (newByName.has(name)) continue;
127
- for (const idx of resource.indexes) {
128
- dropIndexes.push({ kind: "drop-index", resourceName: name, indexName: idx.name });
129
- }
130
- dropTables.push({ kind: "drop-table", resourceName: name });
131
- }
132
-
133
- // Resources that are new → create-table (indexes inline — Agent A's
134
- // `emitCreateTable` is responsible for emitting CREATE INDEX alongside).
135
- for (const [name, resource] of newByName) {
136
- if (oldByName.has(name)) continue;
137
- createTables.push({ kind: "create-table", resource });
138
- }
139
-
140
- // Resources on both sides → drill into fields + indexes.
141
- for (const [name, newResource] of newByName) {
142
- const oldResource = oldByName.get(name);
143
- if (!oldResource) continue;
144
-
145
- const bucket = ensureKeptBucket(keptTables, name);
146
- diffFields(oldResource, newResource, bucket);
147
-
148
- const { dropped, added } = diffIndexes(oldResource, newResource);
149
- for (const idx of dropped) {
150
- dropIndexes.push({ kind: "drop-index", resourceName: name, indexName: idx.name });
151
- }
152
- for (const idx of added) {
153
- addIndexes.push({ kind: "add-index", resourceName: name, index: idx });
154
- }
155
- }
156
-
157
- // Sort each kind lexicographically. Sort is `stable` in Bun/Node (V8) so
158
- // ties fall to original insertion order; we never rely on that — every
159
- // bucket enters pre-sorted by the iteration structure above, and the
160
- // sort comparators below make the final result fully total.
161
- sortChanges(dropIndexes, (c) => indexKey(c));
162
- sortChanges(dropTables, (c) => resourceKey(c));
163
- sortChanges(createTables, (c) => resourceKey(c));
164
- sortChanges(addIndexes, (c) => indexKey(c));
165
-
166
- // Assemble in canonical order.
167
- const out: Change[] = [];
168
- out.push(...dropIndexes);
169
- out.push(...dropTables);
170
- out.push(...createTables);
171
-
172
- // Per-kept-table inner changes — iterate in alphabetical table order.
173
- const keptTableNames = [...keptTables.keys()].sort();
174
- for (const name of keptTableNames) {
175
- const bucket = keptTables.get(name)!;
176
- sortChanges(bucket.dropColumns, fieldNameKey);
177
- sortChanges(bucket.alterColumnTypes, fieldNameKey);
178
- sortChanges(bucket.alterColumnNullables, fieldNameKey);
179
- sortChanges(bucket.alterColumnDefaults, fieldNameKey);
180
- sortChanges(bucket.addColumns, addColumnFieldNameKey);
181
- out.push(...bucket.dropColumns);
182
- out.push(...bucket.alterColumnTypes);
183
- out.push(...bucket.alterColumnNullables);
184
- out.push(...bucket.alterColumnDefaults);
185
- out.push(...bucket.addColumns);
186
- }
187
-
188
- out.push(...addIndexes);
189
- return out;
190
- }
191
-
192
- /**
193
- * Convenience: compute `next` snapshot from `ParsedResource[]` and diff it
194
- * against an already-applied snapshot. Returns both so callers can write
195
- * the next snapshot back to disk after `mandu db apply` succeeds.
196
- */
197
- export function diffResources(
198
- resources: readonly ParsedResource[],
199
- applied: Snapshot | null
200
- ): { snapshot: Snapshot; changes: Change[] } {
201
- const snapshot = snapshotFromResources(resources);
202
- const changes = diffSnapshots(applied, snapshot);
203
- return { snapshot, changes };
204
- }
205
-
206
- // ============================================
207
- // Internals — per-resource field diff
208
- // ============================================
209
-
210
- function diffFields(
211
- oldResource: DdlResource,
212
- newResource: DdlResource,
213
- bucket: {
214
- dropColumns: Change[];
215
- alterColumnTypes: Change[];
216
- alterColumnNullables: Change[];
217
- alterColumnDefaults: Change[];
218
- addColumns: Change[];
219
- }
220
- ): void {
221
- const oldFields = new Map(oldResource.fields.map((f) => [f.name, f]));
222
- const newFields = new Map(newResource.fields.map((f) => [f.name, f]));
223
- const resourceName = newResource.name;
224
-
225
- // Fields removed in `next`.
226
- for (const [name] of oldFields) {
227
- if (newFields.has(name)) continue;
228
- bucket.dropColumns.push({ kind: "drop-column", resourceName, fieldName: name });
229
- }
230
-
231
- // Fields added in `next`.
232
- for (const [name, field] of newFields) {
233
- if (oldFields.has(name)) continue;
234
- bucket.addColumns.push({ kind: "add-column", resourceName, field });
235
- }
236
-
237
- // Fields present on both sides — inspect for type / nullable / default
238
- // divergence. Diff engine is intentionally coarse here: one Change per
239
- // dimension so the CLI/emit can surface each reason separately.
240
- for (const [name, newField] of newFields) {
241
- const oldField = oldFields.get(name);
242
- if (!oldField) continue;
243
-
244
- if (oldField.type !== newField.type) {
245
- bucket.alterColumnTypes.push({
246
- kind: "alter-column-type",
247
- resourceName,
248
- fieldName: name,
249
- fromType: oldField.type,
250
- toType: newField.type,
251
- stub: true,
252
- });
253
- }
254
- if (oldField.nullable !== newField.nullable) {
255
- bucket.alterColumnNullables.push({
256
- kind: "alter-column-nullable",
257
- resourceName,
258
- fieldName: name,
259
- nullable: newField.nullable,
260
- });
261
- }
262
- if (!defaultsEqual(oldField.default, newField.default)) {
263
- const change: Change = {
264
- kind: "alter-column-default",
265
- resourceName,
266
- fieldName: name,
267
- };
268
- if (newField.default !== undefined) {
269
- change.default = newField.default;
270
- }
271
- bucket.alterColumnDefaults.push(change);
272
- }
273
- }
274
- }
275
-
276
- function ensureKeptBucket(
277
- keptTables: Map<
278
- string,
279
- {
280
- dropColumns: Change[];
281
- alterColumnTypes: Change[];
282
- alterColumnNullables: Change[];
283
- alterColumnDefaults: Change[];
284
- addColumns: Change[];
285
- }
286
- >,
287
- name: string
288
- ) {
289
- let bucket = keptTables.get(name);
290
- if (!bucket) {
291
- bucket = {
292
- dropColumns: [],
293
- alterColumnTypes: [],
294
- alterColumnNullables: [],
295
- alterColumnDefaults: [],
296
- addColumns: [],
297
- };
298
- keptTables.set(name, bucket);
299
- }
300
- return bucket;
301
- }
302
-
303
- // ============================================
304
- // Internals — index diff
305
- // ============================================
306
-
307
- function diffIndexes(
308
- oldResource: DdlResource,
309
- newResource: DdlResource
310
- ): { dropped: DdlIndex[]; added: DdlIndex[] } {
311
- const oldByName = new Map(oldResource.indexes.map((i) => [i.name, i]));
312
- const newByName = new Map(newResource.indexes.map((i) => [i.name, i]));
313
- const dropped: DdlIndex[] = [];
314
- const added: DdlIndex[] = [];
315
- for (const [name, idx] of oldByName) {
316
- if (!newByName.has(name)) dropped.push(idx);
317
- }
318
- for (const [name, idx] of newByName) {
319
- const prior = oldByName.get(name);
320
- if (!prior) {
321
- added.push(idx);
322
- continue;
323
- }
324
- // Same name, different shape → drop + add (v1; see `rename-*` policy).
325
- if (!indexShapeEqual(prior, idx)) {
326
- dropped.push(prior);
327
- added.push(idx);
328
- }
329
- }
330
- return { dropped, added };
331
- }
332
-
333
- function indexShapeEqual(a: DdlIndex, b: DdlIndex): boolean {
334
- if (a.unique !== b.unique) return false;
335
- if (a.fields.length !== b.fields.length) return false;
336
- for (let i = 0; i < a.fields.length; i++) {
337
- if (a.fields[i] !== b.fields[i]) return false;
338
- }
339
- return true;
340
- }
341
-
342
- // ============================================
343
- // Internals — default equality
344
- // ============================================
345
-
346
- /**
347
- * Defaults compare "by value". We stringify the discriminated union and
348
- * compare the strings — cheap, correct, and side-steps JavaScript's
349
- * structural-equality blind spot. Two `undefined`s compare equal.
350
- */
351
- function defaultsEqual(a: DdlDefault | undefined, b: DdlDefault | undefined): boolean {
352
- if (a === undefined && b === undefined) return true;
353
- if (a === undefined || b === undefined) return false;
354
- return JSON.stringify(a) === JSON.stringify(b);
355
- }
356
-
357
- // ============================================
358
- // Internals — sort helpers
359
- // ============================================
360
-
361
- function sortChanges(list: Change[], key: (c: Change) => string): void {
362
- list.sort((a, b) => {
363
- const ka = key(a);
364
- const kb = key(b);
365
- return ka < kb ? -1 : ka > kb ? 1 : 0;
366
- });
367
- }
368
-
369
- function indexKey(c: Change): string {
370
- // Covers drop-index + add-index.
371
- if (c.kind === "drop-index") return `${c.resourceName}\u0000${c.indexName}`;
372
- if (c.kind === "add-index") return `${c.resourceName}\u0000${c.index.name}`;
373
- return "";
374
- }
375
-
376
- function resourceKey(c: Change): string {
377
- if (c.kind === "drop-table") return c.resourceName;
378
- if (c.kind === "create-table") return c.resource.name;
379
- return "";
380
- }
381
-
382
- function fieldNameKey(c: Change): string {
383
- // Covers drop-column / alter-column-*.
384
- if (c.kind === "drop-column" || c.kind === "alter-column-type" || c.kind === "alter-column-nullable" || c.kind === "alter-column-default") {
385
- return c.fieldName;
386
- }
387
- return "";
388
- }
389
-
390
- function addColumnFieldNameKey(c: Change): string {
391
- return c.kind === "add-column" ? c.field.name : "";
392
- }
1
+ /**
2
+ * Phase 4c — Schema diff engine.
3
+ *
4
+ * Given an old `Snapshot` (or `null` for "no schema yet") and a next
5
+ * `Snapshot`, produce a deterministic ordered list of `Change` entries.
6
+ * The output feeds Agent A's `emitChange(change, provider)` to produce
7
+ * the migration SQL body.
8
+ *
9
+ * # Rename policy
10
+ *
11
+ * This engine NEVER emits `rename-table` or `rename-column` — Appendix D.1
12
+ * of the RFC and the team plan §7 are explicit: rename auto-detection is
13
+ * dangerous (cost of a false positive = silent data loss across an
14
+ * ADD+DROP pair). The CLI (Agent E) prompts the user to reinterpret
15
+ * consecutive drop+add pairs as renames; only AFTER user confirmation
16
+ * are `rename-*` Change entries inserted (with `origin: "user-confirmed"`).
17
+ *
18
+ * # Determinism
19
+ *
20
+ * Two identical inputs MUST produce byte-identical `Change[]` across
21
+ * runs. We achieve this with:
22
+ * 1. Alphabetical sort at every partition step.
23
+ * 2. Exhaustive ordering rules below — no "depends on iteration order".
24
+ *
25
+ * # Emit order (cross-table)
26
+ *
27
+ * 1. All `drop-index` sort by (resourceName, indexName)
28
+ * 2. All `drop-table` sort by resourceName
29
+ * 3. All `create-table` sort by resourceName (indexes included inline)
30
+ * 4. Per-kept-table, in resourceName order:
31
+ * a. drop-column
32
+ * b. alter-column-type
33
+ * c. alter-column-nullable
34
+ * d. alter-column-default
35
+ * e. add-column
36
+ * 5. All `add-index` sort by (resourceName, indexName)
37
+ *
38
+ * # Rationale for this order
39
+ *
40
+ * Drop-index before drop-table: PG and MySQL allow `DROP TABLE` to
41
+ * implicitly drop dependent indexes, but being explicit makes the
42
+ * migration reversible when a reviewer edits the plan. Drop-column
43
+ * before alter: dropping a column before altering its siblings avoids
44
+ * "column not found" errors when the same table's shape is fluid.
45
+ * Add-index last: the column(s) the index targets must already exist,
46
+ * and a freshly-created table may also introduce new indexes — those
47
+ * are emitted as part of `create-table` so this phase covers only
48
+ * indexes added to tables that existed on both sides.
49
+ *
50
+ * # v1 non-goals
51
+ *
52
+ * - Rename auto-detection (always drop+add — see above)
53
+ * - FK / CHECK / ENUM / composite PK diffing (not in scope)
54
+ * - Cross-provider diff (thrown — a caller bug)
55
+ *
56
+ * References:
57
+ * docs/bun/phase-4c-team-plan.md §3 Agent B
58
+ * docs/rfcs/0001-db-resource-layer.md §7 (risk: rename false-positive)
59
+ */
60
+
61
+ import type { ParsedResource } from "../parser";
62
+ import type {
63
+ Change,
64
+ DdlDefault,
65
+ DdlFieldDef,
66
+ DdlIndex,
67
+ DdlResource,
68
+ Snapshot,
69
+ } from "./types";
70
+ import { snapshotFromResources } from "./snapshot";
71
+
72
+ // ============================================
73
+ // Public API
74
+ // ============================================
75
+
76
+ /**
77
+ * Compute the ordered list of `Change` entries from `old` → `next`.
78
+ *
79
+ * `old === null` means "no schema has been applied yet" — every resource
80
+ * in `next` becomes a `create-table`.
81
+ *
82
+ * `next === null` is NOT a valid input — it would mean "delete everything",
83
+ * which is dangerous enough that we force the caller to build an empty
84
+ * Snapshot (`{ version: 1, provider, resources: [], generatedAt }`) to
85
+ * express it explicitly. This surfaces in tests (`diff.test.ts` TC-3).
86
+ *
87
+ * @throws TypeError if the two snapshots declare different providers —
88
+ * a cross-provider diff is meaningless and always a caller bug.
89
+ */
90
+ export function diffSnapshots(old: Snapshot | null, next: Snapshot): Change[] {
91
+ if (next === null || next === undefined) {
92
+ throw new TypeError(
93
+ "diffSnapshots: `next` must be a Snapshot (use an empty-resources snapshot to express 'drop all')"
94
+ );
95
+ }
96
+ if (old !== null && old.provider !== next.provider) {
97
+ throw new TypeError(
98
+ `Cross-provider diff: old is "${old.provider}", next is "${next.provider}". Snapshots from different SQL providers cannot be diffed — migrating across providers requires a manual dump+load, not a diff.`
99
+ );
100
+ }
101
+
102
+ // Bucket every change by kind so we can sort within each kind and then
103
+ // assemble in the canonical cross-table order at the end.
104
+ const dropIndexes: Change[] = [];
105
+ const dropTables: Change[] = [];
106
+ const createTables: Change[] = [];
107
+ // Per-kept-table changes — keyed by resourceName then ordered sub-lists
108
+ // to maintain the within-table order invariant.
109
+ const keptTables = new Map<
110
+ string,
111
+ {
112
+ dropColumns: Change[];
113
+ alterColumnTypes: Change[];
114
+ alterColumnNullables: Change[];
115
+ alterColumnDefaults: Change[];
116
+ addColumns: Change[];
117
+ }
118
+ >();
119
+ const addIndexes: Change[] = [];
120
+
121
+ const oldByName = new Map((old?.resources ?? []).map((r) => [r.name, r]));
122
+ const newByName = new Map(next.resources.map((r) => [r.name, r]));
123
+
124
+ // Resources that disappear entirely → drop indexes, drop table.
125
+ for (const [name, resource] of oldByName) {
126
+ if (newByName.has(name)) continue;
127
+ for (const idx of resource.indexes) {
128
+ dropIndexes.push({ kind: "drop-index", resourceName: name, indexName: idx.name });
129
+ }
130
+ dropTables.push({ kind: "drop-table", resourceName: name });
131
+ }
132
+
133
+ // Resources that are new → create-table (indexes inline — Agent A's
134
+ // `emitCreateTable` is responsible for emitting CREATE INDEX alongside).
135
+ for (const [name, resource] of newByName) {
136
+ if (oldByName.has(name)) continue;
137
+ createTables.push({ kind: "create-table", resource });
138
+ }
139
+
140
+ // Resources on both sides → drill into fields + indexes.
141
+ for (const [name, newResource] of newByName) {
142
+ const oldResource = oldByName.get(name);
143
+ if (!oldResource) continue;
144
+
145
+ const bucket = ensureKeptBucket(keptTables, name);
146
+ diffFields(oldResource, newResource, bucket);
147
+
148
+ const { dropped, added } = diffIndexes(oldResource, newResource);
149
+ for (const idx of dropped) {
150
+ dropIndexes.push({ kind: "drop-index", resourceName: name, indexName: idx.name });
151
+ }
152
+ for (const idx of added) {
153
+ addIndexes.push({ kind: "add-index", resourceName: name, index: idx });
154
+ }
155
+ }
156
+
157
+ // Sort each kind lexicographically. Sort is `stable` in Bun/Node (V8) so
158
+ // ties fall to original insertion order; we never rely on that — every
159
+ // bucket enters pre-sorted by the iteration structure above, and the
160
+ // sort comparators below make the final result fully total.
161
+ sortChanges(dropIndexes, (c) => indexKey(c));
162
+ sortChanges(dropTables, (c) => resourceKey(c));
163
+ sortChanges(createTables, (c) => resourceKey(c));
164
+ sortChanges(addIndexes, (c) => indexKey(c));
165
+
166
+ // Assemble in canonical order.
167
+ const out: Change[] = [];
168
+ out.push(...dropIndexes);
169
+ out.push(...dropTables);
170
+ out.push(...createTables);
171
+
172
+ // Per-kept-table inner changes — iterate in alphabetical table order.
173
+ const keptTableNames = [...keptTables.keys()].sort();
174
+ for (const name of keptTableNames) {
175
+ const bucket = keptTables.get(name)!;
176
+ sortChanges(bucket.dropColumns, fieldNameKey);
177
+ sortChanges(bucket.alterColumnTypes, fieldNameKey);
178
+ sortChanges(bucket.alterColumnNullables, fieldNameKey);
179
+ sortChanges(bucket.alterColumnDefaults, fieldNameKey);
180
+ sortChanges(bucket.addColumns, addColumnFieldNameKey);
181
+ out.push(...bucket.dropColumns);
182
+ out.push(...bucket.alterColumnTypes);
183
+ out.push(...bucket.alterColumnNullables);
184
+ out.push(...bucket.alterColumnDefaults);
185
+ out.push(...bucket.addColumns);
186
+ }
187
+
188
+ out.push(...addIndexes);
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * Convenience: compute `next` snapshot from `ParsedResource[]` and diff it
194
+ * against an already-applied snapshot. Returns both so callers can write
195
+ * the next snapshot back to disk after `mandu db apply` succeeds.
196
+ */
197
+ export function diffResources(
198
+ resources: readonly ParsedResource[],
199
+ applied: Snapshot | null
200
+ ): { snapshot: Snapshot; changes: Change[] } {
201
+ const snapshot = snapshotFromResources(resources);
202
+ const changes = diffSnapshots(applied, snapshot);
203
+ return { snapshot, changes };
204
+ }
205
+
206
+ // ============================================
207
+ // Internals — per-resource field diff
208
+ // ============================================
209
+
210
+ function diffFields(
211
+ oldResource: DdlResource,
212
+ newResource: DdlResource,
213
+ bucket: {
214
+ dropColumns: Change[];
215
+ alterColumnTypes: Change[];
216
+ alterColumnNullables: Change[];
217
+ alterColumnDefaults: Change[];
218
+ addColumns: Change[];
219
+ }
220
+ ): void {
221
+ const oldFields = new Map(oldResource.fields.map((f) => [f.name, f]));
222
+ const newFields = new Map(newResource.fields.map((f) => [f.name, f]));
223
+ const resourceName = newResource.name;
224
+
225
+ // Fields removed in `next`.
226
+ for (const [name] of oldFields) {
227
+ if (newFields.has(name)) continue;
228
+ bucket.dropColumns.push({ kind: "drop-column", resourceName, fieldName: name });
229
+ }
230
+
231
+ // Fields added in `next`.
232
+ for (const [name, field] of newFields) {
233
+ if (oldFields.has(name)) continue;
234
+ bucket.addColumns.push({ kind: "add-column", resourceName, field });
235
+ }
236
+
237
+ // Fields present on both sides — inspect for type / nullable / default
238
+ // divergence. Diff engine is intentionally coarse here: one Change per
239
+ // dimension so the CLI/emit can surface each reason separately.
240
+ for (const [name, newField] of newFields) {
241
+ const oldField = oldFields.get(name);
242
+ if (!oldField) continue;
243
+
244
+ if (oldField.type !== newField.type) {
245
+ bucket.alterColumnTypes.push({
246
+ kind: "alter-column-type",
247
+ resourceName,
248
+ fieldName: name,
249
+ fromType: oldField.type,
250
+ toType: newField.type,
251
+ stub: true,
252
+ });
253
+ }
254
+ if (oldField.nullable !== newField.nullable) {
255
+ bucket.alterColumnNullables.push({
256
+ kind: "alter-column-nullable",
257
+ resourceName,
258
+ fieldName: name,
259
+ nullable: newField.nullable,
260
+ });
261
+ }
262
+ if (!defaultsEqual(oldField.default, newField.default)) {
263
+ const change: Change = {
264
+ kind: "alter-column-default",
265
+ resourceName,
266
+ fieldName: name,
267
+ };
268
+ if (newField.default !== undefined) {
269
+ change.default = newField.default;
270
+ }
271
+ bucket.alterColumnDefaults.push(change);
272
+ }
273
+ }
274
+ }
275
+
276
+ function ensureKeptBucket(
277
+ keptTables: Map<
278
+ string,
279
+ {
280
+ dropColumns: Change[];
281
+ alterColumnTypes: Change[];
282
+ alterColumnNullables: Change[];
283
+ alterColumnDefaults: Change[];
284
+ addColumns: Change[];
285
+ }
286
+ >,
287
+ name: string
288
+ ) {
289
+ let bucket = keptTables.get(name);
290
+ if (!bucket) {
291
+ bucket = {
292
+ dropColumns: [],
293
+ alterColumnTypes: [],
294
+ alterColumnNullables: [],
295
+ alterColumnDefaults: [],
296
+ addColumns: [],
297
+ };
298
+ keptTables.set(name, bucket);
299
+ }
300
+ return bucket;
301
+ }
302
+
303
+ // ============================================
304
+ // Internals — index diff
305
+ // ============================================
306
+
307
+ function diffIndexes(
308
+ oldResource: DdlResource,
309
+ newResource: DdlResource
310
+ ): { dropped: DdlIndex[]; added: DdlIndex[] } {
311
+ const oldByName = new Map(oldResource.indexes.map((i) => [i.name, i]));
312
+ const newByName = new Map(newResource.indexes.map((i) => [i.name, i]));
313
+ const dropped: DdlIndex[] = [];
314
+ const added: DdlIndex[] = [];
315
+ for (const [name, idx] of oldByName) {
316
+ if (!newByName.has(name)) dropped.push(idx);
317
+ }
318
+ for (const [name, idx] of newByName) {
319
+ const prior = oldByName.get(name);
320
+ if (!prior) {
321
+ added.push(idx);
322
+ continue;
323
+ }
324
+ // Same name, different shape → drop + add (v1; see `rename-*` policy).
325
+ if (!indexShapeEqual(prior, idx)) {
326
+ dropped.push(prior);
327
+ added.push(idx);
328
+ }
329
+ }
330
+ return { dropped, added };
331
+ }
332
+
333
+ function indexShapeEqual(a: DdlIndex, b: DdlIndex): boolean {
334
+ if (a.unique !== b.unique) return false;
335
+ if (a.fields.length !== b.fields.length) return false;
336
+ for (let i = 0; i < a.fields.length; i++) {
337
+ if (a.fields[i] !== b.fields[i]) return false;
338
+ }
339
+ return true;
340
+ }
341
+
342
+ // ============================================
343
+ // Internals — default equality
344
+ // ============================================
345
+
346
+ /**
347
+ * Defaults compare "by value". We stringify the discriminated union and
348
+ * compare the strings — cheap, correct, and side-steps JavaScript's
349
+ * structural-equality blind spot. Two `undefined`s compare equal.
350
+ */
351
+ function defaultsEqual(a: DdlDefault | undefined, b: DdlDefault | undefined): boolean {
352
+ if (a === undefined && b === undefined) return true;
353
+ if (a === undefined || b === undefined) return false;
354
+ return JSON.stringify(a) === JSON.stringify(b);
355
+ }
356
+
357
+ // ============================================
358
+ // Internals — sort helpers
359
+ // ============================================
360
+
361
+ function sortChanges(list: Change[], key: (c: Change) => string): void {
362
+ list.sort((a, b) => {
363
+ const ka = key(a);
364
+ const kb = key(b);
365
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
366
+ });
367
+ }
368
+
369
+ function indexKey(c: Change): string {
370
+ // Covers drop-index + add-index.
371
+ if (c.kind === "drop-index") return `${c.resourceName}\u0000${c.indexName}`;
372
+ if (c.kind === "add-index") return `${c.resourceName}\u0000${c.index.name}`;
373
+ return "";
374
+ }
375
+
376
+ function resourceKey(c: Change): string {
377
+ if (c.kind === "drop-table") return c.resourceName;
378
+ if (c.kind === "create-table") return c.resource.name;
379
+ return "";
380
+ }
381
+
382
+ function fieldNameKey(c: Change): string {
383
+ // Covers drop-column / alter-column-*.
384
+ if (c.kind === "drop-column" || c.kind === "alter-column-type" || c.kind === "alter-column-nullable" || c.kind === "alter-column-default") {
385
+ return c.fieldName;
386
+ }
387
+ return "";
388
+ }
389
+
390
+ function addColumnFieldNameKey(c: Change): string {
391
+ return c.kind === "add-column" ? c.field.name : "";
392
+ }