@mandujs/core 0.41.2 → 0.43.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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/resource/ddl/diff.ts
CHANGED
|
@@ -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
|
+
}
|