@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
|
@@ -1,447 +1,448 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 4c — Schema snapshot normalization + serialization.
|
|
3
|
-
*
|
|
4
|
-
* Pure functions, no I/O beyond the Bun-native SHA-256 hasher. Given the
|
|
5
|
-
* same `ParsedResource[]`, these functions MUST produce byte-identical
|
|
6
|
-
* `Snapshot` objects and byte-identical serialized JSON — this guarantee
|
|
7
|
-
* is what makes the snapshot file usable as a git-checked-in artifact and
|
|
8
|
-
* what makes checksum-based tamper detection (Agent C) meaningful.
|
|
9
|
-
*
|
|
10
|
-
* Pipeline:
|
|
11
|
-
*
|
|
12
|
-
* ParsedResource[] --snapshotFromResources--> Snapshot
|
|
13
|
-
* |
|
|
14
|
-
* serializeSnapshot | parseSnapshot
|
|
15
|
-
* v
|
|
16
|
-
* JSON string (committed to .mandu/schema/applied.json)
|
|
17
|
-
*
|
|
18
|
-
* Nothing here touches the filesystem — the CLI (Agent E) and generator
|
|
19
|
-
* (Agent D) are responsible for where the snapshot lives on disk.
|
|
20
|
-
*
|
|
21
|
-
* Normalization rules (documented in detail on each helper below):
|
|
22
|
-
* 1. Only resources with a well-formed `options.persistence` are included.
|
|
23
|
-
* 2. All resources must target the same `SqlProvider`; mixing throws.
|
|
24
|
-
* 3. Table name: `options.persistence.tableName` > auto-pluralized
|
|
25
|
-
* `definition.name`. `options.autoPlural === false` keeps it singular.
|
|
26
|
-
* 4. Column name: `fieldOverrides[key].columnName` > `camelCase → snake_case`.
|
|
27
|
-
* 5. Primary key: `options.persistence.primaryKey` (string|[string]) >
|
|
28
|
-
* field with `primary: true` > error. Composite PK is v2+.
|
|
29
|
-
* 6. `DdlDefault` derived from `field.default` with the string-magic
|
|
30
|
-
* "now" / "current_timestamp" shortcut. See `normalizeDefault`.
|
|
31
|
-
*
|
|
32
|
-
* References:
|
|
33
|
-
* docs/bun/phase-4c-team-plan.md §3 Agent B
|
|
34
|
-
* docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
|
|
35
|
-
* docs/rfcs/0001-db-resource-layer.md §4 D5 (opt-in `persistence` field)
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import type { ParsedResource } from "../parser";
|
|
39
|
-
import type { ResourceField, ResourceOptions } from "../schema";
|
|
40
|
-
import type {
|
|
41
|
-
DdlDefault,
|
|
42
|
-
DdlFieldDef,
|
|
43
|
-
DdlFieldType,
|
|
44
|
-
DdlIndex,
|
|
45
|
-
DdlResource,
|
|
46
|
-
Snapshot,
|
|
47
|
-
SqlProvider,
|
|
48
|
-
} from "./types";
|
|
49
|
-
import { asPersistence, type ExtendedResourcePersistence, type FieldOverride } from "./persistence-types";
|
|
50
|
-
|
|
51
|
-
// ============================================
|
|
52
|
-
// Public API
|
|
53
|
-
// ============================================
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Normalize `ParsedResource[]` into a provider-tagged `Snapshot`.
|
|
57
|
-
*
|
|
58
|
-
* Resources whose `options.persistence` is missing/empty are silently
|
|
59
|
-
* dropped — the resource generator emits contract/types/slot/client but
|
|
60
|
-
* no DDL for such resources.
|
|
61
|
-
*
|
|
62
|
-
* Insertion order of fields (the order keys appear in the source
|
|
63
|
-
* `definition.fields` object) is preserved; this is the author's intent
|
|
64
|
-
* and the emit order for `CREATE TABLE`.
|
|
65
|
-
*
|
|
66
|
-
* Top-level `snapshot.resources` is sorted alphabetically by `name` —
|
|
67
|
-
* this is what makes `serializeSnapshot` output stable across runs even
|
|
68
|
-
* if the caller scans files in a different order.
|
|
69
|
-
*
|
|
70
|
-
* @throws TypeError on:
|
|
71
|
-
* - conflicting providers across persistent resources
|
|
72
|
-
* - zero primary-key fields on a persistent resource
|
|
73
|
-
* - more than one primary-key field (composite PK is v2)
|
|
74
|
-
* - duplicate table name after pluralization
|
|
75
|
-
* - invalid `field.default` value (functions, symbols, objects)
|
|
76
|
-
* - structurally broken `options.persistence` (see `asPersistence`)
|
|
77
|
-
*/
|
|
78
|
-
export function snapshotFromResources(resources: readonly ParsedResource[]): Snapshot {
|
|
79
|
-
const ddlResources: DdlResource[] = [];
|
|
80
|
-
let provider: SqlProvider | undefined;
|
|
81
|
-
const seenTableNames = new Map<string, string>(); // tableName -> first resource that claimed it
|
|
82
|
-
|
|
83
|
-
for (const parsed of resources) {
|
|
84
|
-
// `options.persistence` is not declared on the public ResourceOptions —
|
|
85
|
-
// it's an opt-in additive field (see persistence-types.ts for rationale).
|
|
86
|
-
// Read it via an unknown cast and narrow with `asPersistence`.
|
|
87
|
-
const rawPersistence = (parsed.definition.options as Record<string, unknown> | undefined)?.persistence;
|
|
88
|
-
const persistence = asPersistence(rawPersistence);
|
|
89
|
-
if (!persistence) continue; // non-persistent resource — skip
|
|
90
|
-
|
|
91
|
-
if (provider === undefined) {
|
|
92
|
-
provider = persistence.provider;
|
|
93
|
-
} else if (provider !== persistence.provider) {
|
|
94
|
-
throw new TypeError(
|
|
95
|
-
`Mixed SQL providers in resource set: resource "${parsed.resourceName}" declares provider "${persistence.provider}" but the snapshot is already building for "${provider}". All persistent resources in a project must share one provider.`
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const ddlResource = normalizeResource(parsed, persistence);
|
|
100
|
-
|
|
101
|
-
const prior = seenTableNames.get(ddlResource.name);
|
|
102
|
-
if (prior !== undefined) {
|
|
103
|
-
throw new TypeError(
|
|
104
|
-
`Duplicate table name "${ddlResource.name}" — resources "${prior}" and "${parsed.resourceName}" both map to it. Use options.persistence.tableName to disambiguate.`
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
seenTableNames.set(ddlResource.name, parsed.resourceName);
|
|
108
|
-
|
|
109
|
-
ddlResources.push(ddlResource);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
ddlResources.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
version: 1,
|
|
116
|
-
provider: provider ?? "postgres", // empty set → default to postgres; it's meaningless for an empty resource list
|
|
117
|
-
resources: ddlResources,
|
|
118
|
-
generatedAt: new Date().toISOString(),
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Deterministic JSON serialization — 2-space indent, sorted object keys,
|
|
124
|
-
* stable array order. Byte-for-byte stable for the same `Snapshot`,
|
|
125
|
-
* modulo `generatedAt` which the caller controls.
|
|
126
|
-
*
|
|
127
|
-
* We intentionally sort object keys rather than trusting the property
|
|
128
|
-
* insertion order of the callers' objects — this guards against subtle
|
|
129
|
-
* non-determinism when `Snapshot` is built by code that constructs
|
|
130
|
-
* objects in different orders across refactors.
|
|
131
|
-
*
|
|
132
|
-
* Arrays are NOT sorted — their order is semantically load-bearing
|
|
133
|
-
* (field emit order, sorted resource order).
|
|
134
|
-
*/
|
|
135
|
-
export function serializeSnapshot(s: Snapshot): string {
|
|
136
|
-
return stringifyWithSortedKeys(s, 2);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Parse a snapshot JSON string.
|
|
141
|
-
*
|
|
142
|
-
* @throws TypeError on invalid JSON, missing required fields, or
|
|
143
|
-
* `version` that this build does not understand.
|
|
144
|
-
*/
|
|
145
|
-
export function parseSnapshot(raw: string): Snapshot {
|
|
146
|
-
let parsed: unknown;
|
|
147
|
-
try {
|
|
148
|
-
parsed = JSON.parse(raw);
|
|
149
|
-
} catch (err) {
|
|
150
|
-
throw new TypeError(
|
|
151
|
-
`Invalid snapshot JSON: ${err instanceof Error ? err.message : String(err)}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
hasher.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
if (options?.
|
|
256
|
-
return
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
return declared
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
// - the
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
.replace(/([
|
|
340
|
-
.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (raw ===
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
return { kind: "
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* that
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (!
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
*
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (value
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4c — Schema snapshot normalization + serialization.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions, no I/O beyond the Bun-native SHA-256 hasher. Given the
|
|
5
|
+
* same `ParsedResource[]`, these functions MUST produce byte-identical
|
|
6
|
+
* `Snapshot` objects and byte-identical serialized JSON — this guarantee
|
|
7
|
+
* is what makes the snapshot file usable as a git-checked-in artifact and
|
|
8
|
+
* what makes checksum-based tamper detection (Agent C) meaningful.
|
|
9
|
+
*
|
|
10
|
+
* Pipeline:
|
|
11
|
+
*
|
|
12
|
+
* ParsedResource[] --snapshotFromResources--> Snapshot
|
|
13
|
+
* |
|
|
14
|
+
* serializeSnapshot | parseSnapshot
|
|
15
|
+
* v
|
|
16
|
+
* JSON string (committed to .mandu/schema/applied.json)
|
|
17
|
+
*
|
|
18
|
+
* Nothing here touches the filesystem — the CLI (Agent E) and generator
|
|
19
|
+
* (Agent D) are responsible for where the snapshot lives on disk.
|
|
20
|
+
*
|
|
21
|
+
* Normalization rules (documented in detail on each helper below):
|
|
22
|
+
* 1. Only resources with a well-formed `options.persistence` are included.
|
|
23
|
+
* 2. All resources must target the same `SqlProvider`; mixing throws.
|
|
24
|
+
* 3. Table name: `options.persistence.tableName` > auto-pluralized
|
|
25
|
+
* `definition.name`. `options.autoPlural === false` keeps it singular.
|
|
26
|
+
* 4. Column name: `fieldOverrides[key].columnName` > `camelCase → snake_case`.
|
|
27
|
+
* 5. Primary key: `options.persistence.primaryKey` (string|[string]) >
|
|
28
|
+
* field with `primary: true` > error. Composite PK is v2+.
|
|
29
|
+
* 6. `DdlDefault` derived from `field.default` with the string-magic
|
|
30
|
+
* "now" / "current_timestamp" shortcut. See `normalizeDefault`.
|
|
31
|
+
*
|
|
32
|
+
* References:
|
|
33
|
+
* docs/bun/phase-4c-team-plan.md §3 Agent B
|
|
34
|
+
* docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
|
|
35
|
+
* docs/rfcs/0001-db-resource-layer.md §4 D5 (opt-in `persistence` field)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { ParsedResource } from "../parser";
|
|
39
|
+
import type { ResourceField, ResourceOptions } from "../schema";
|
|
40
|
+
import type {
|
|
41
|
+
DdlDefault,
|
|
42
|
+
DdlFieldDef,
|
|
43
|
+
DdlFieldType,
|
|
44
|
+
DdlIndex,
|
|
45
|
+
DdlResource,
|
|
46
|
+
Snapshot,
|
|
47
|
+
SqlProvider,
|
|
48
|
+
} from "./types";
|
|
49
|
+
import { asPersistence, type ExtendedResourcePersistence, type FieldOverride } from "./persistence-types";
|
|
50
|
+
|
|
51
|
+
// ============================================
|
|
52
|
+
// Public API
|
|
53
|
+
// ============================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize `ParsedResource[]` into a provider-tagged `Snapshot`.
|
|
57
|
+
*
|
|
58
|
+
* Resources whose `options.persistence` is missing/empty are silently
|
|
59
|
+
* dropped — the resource generator emits contract/types/slot/client but
|
|
60
|
+
* no DDL for such resources.
|
|
61
|
+
*
|
|
62
|
+
* Insertion order of fields (the order keys appear in the source
|
|
63
|
+
* `definition.fields` object) is preserved; this is the author's intent
|
|
64
|
+
* and the emit order for `CREATE TABLE`.
|
|
65
|
+
*
|
|
66
|
+
* Top-level `snapshot.resources` is sorted alphabetically by `name` —
|
|
67
|
+
* this is what makes `serializeSnapshot` output stable across runs even
|
|
68
|
+
* if the caller scans files in a different order.
|
|
69
|
+
*
|
|
70
|
+
* @throws TypeError on:
|
|
71
|
+
* - conflicting providers across persistent resources
|
|
72
|
+
* - zero primary-key fields on a persistent resource
|
|
73
|
+
* - more than one primary-key field (composite PK is v2)
|
|
74
|
+
* - duplicate table name after pluralization
|
|
75
|
+
* - invalid `field.default` value (functions, symbols, objects)
|
|
76
|
+
* - structurally broken `options.persistence` (see `asPersistence`)
|
|
77
|
+
*/
|
|
78
|
+
export function snapshotFromResources(resources: readonly ParsedResource[]): Snapshot {
|
|
79
|
+
const ddlResources: DdlResource[] = [];
|
|
80
|
+
let provider: SqlProvider | undefined;
|
|
81
|
+
const seenTableNames = new Map<string, string>(); // tableName -> first resource that claimed it
|
|
82
|
+
|
|
83
|
+
for (const parsed of resources) {
|
|
84
|
+
// `options.persistence` is not declared on the public ResourceOptions —
|
|
85
|
+
// it's an opt-in additive field (see persistence-types.ts for rationale).
|
|
86
|
+
// Read it via an unknown cast and narrow with `asPersistence`.
|
|
87
|
+
const rawPersistence = (parsed.definition.options as Record<string, unknown> | undefined)?.persistence;
|
|
88
|
+
const persistence = asPersistence(rawPersistence);
|
|
89
|
+
if (!persistence) continue; // non-persistent resource — skip
|
|
90
|
+
|
|
91
|
+
if (provider === undefined) {
|
|
92
|
+
provider = persistence.provider;
|
|
93
|
+
} else if (provider !== persistence.provider) {
|
|
94
|
+
throw new TypeError(
|
|
95
|
+
`Mixed SQL providers in resource set: resource "${parsed.resourceName}" declares provider "${persistence.provider}" but the snapshot is already building for "${provider}". All persistent resources in a project must share one provider.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ddlResource = normalizeResource(parsed, persistence);
|
|
100
|
+
|
|
101
|
+
const prior = seenTableNames.get(ddlResource.name);
|
|
102
|
+
if (prior !== undefined) {
|
|
103
|
+
throw new TypeError(
|
|
104
|
+
`Duplicate table name "${ddlResource.name}" — resources "${prior}" and "${parsed.resourceName}" both map to it. Use options.persistence.tableName to disambiguate.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
seenTableNames.set(ddlResource.name, parsed.resourceName);
|
|
108
|
+
|
|
109
|
+
ddlResources.push(ddlResource);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
ddlResources.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
version: 1,
|
|
116
|
+
provider: provider ?? "postgres", // empty set → default to postgres; it's meaningless for an empty resource list
|
|
117
|
+
resources: ddlResources,
|
|
118
|
+
generatedAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Deterministic JSON serialization — 2-space indent, sorted object keys,
|
|
124
|
+
* stable array order. Byte-for-byte stable for the same `Snapshot`,
|
|
125
|
+
* modulo `generatedAt` which the caller controls.
|
|
126
|
+
*
|
|
127
|
+
* We intentionally sort object keys rather than trusting the property
|
|
128
|
+
* insertion order of the callers' objects — this guards against subtle
|
|
129
|
+
* non-determinism when `Snapshot` is built by code that constructs
|
|
130
|
+
* objects in different orders across refactors.
|
|
131
|
+
*
|
|
132
|
+
* Arrays are NOT sorted — their order is semantically load-bearing
|
|
133
|
+
* (field emit order, sorted resource order).
|
|
134
|
+
*/
|
|
135
|
+
export function serializeSnapshot(s: Snapshot): string {
|
|
136
|
+
return stringifyWithSortedKeys(s, 2);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse a snapshot JSON string.
|
|
141
|
+
*
|
|
142
|
+
* @throws TypeError on invalid JSON, missing required fields, or
|
|
143
|
+
* `version` that this build does not understand.
|
|
144
|
+
*/
|
|
145
|
+
export function parseSnapshot(raw: string): Snapshot {
|
|
146
|
+
let parsed: unknown;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(raw);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw new TypeError(
|
|
151
|
+
`Invalid snapshot JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
152
|
+
{ cause: err }
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
156
|
+
throw new TypeError(`Snapshot must be a JSON object, got ${typeof parsed}`);
|
|
157
|
+
}
|
|
158
|
+
const obj = parsed as Record<string, unknown>;
|
|
159
|
+
if (obj.version !== 1) {
|
|
160
|
+
throw new TypeError(
|
|
161
|
+
`Unsupported snapshot version ${JSON.stringify(obj.version)}. This build understands version 1 only.`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (obj.provider !== "postgres" && obj.provider !== "mysql" && obj.provider !== "sqlite") {
|
|
165
|
+
throw new TypeError(`Snapshot has invalid provider ${JSON.stringify(obj.provider)}`);
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(obj.resources)) {
|
|
168
|
+
throw new TypeError(`Snapshot.resources must be an array`);
|
|
169
|
+
}
|
|
170
|
+
if (typeof obj.generatedAt !== "string") {
|
|
171
|
+
throw new TypeError(`Snapshot.generatedAt must be an ISO string`);
|
|
172
|
+
}
|
|
173
|
+
// Deeper structural validation of resources is deferred to the diff
|
|
174
|
+
// engine; tampering with fields of a stored snapshot would be caught
|
|
175
|
+
// by the migration runtime's checksum before diff ever runs.
|
|
176
|
+
return parsed as Snapshot;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* SHA-256 of the canonical serialization of a snapshot. Useful for quick
|
|
181
|
+
* change detection ("has the committed schema drifted from the applied
|
|
182
|
+
* snapshot?").
|
|
183
|
+
*
|
|
184
|
+
* Note: `generatedAt` is part of the canonical serialization, so two
|
|
185
|
+
* snapshots with the same resources but different generation times will
|
|
186
|
+
* hash differently. Callers that want a time-stable hash should zero out
|
|
187
|
+
* `generatedAt` before calling.
|
|
188
|
+
*/
|
|
189
|
+
export function hashSnapshot(s: Snapshot): string {
|
|
190
|
+
const canonical = serializeSnapshot(s);
|
|
191
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
192
|
+
hasher.update(canonical);
|
|
193
|
+
return hasher.digest("hex");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================
|
|
197
|
+
// Internals — resource normalization
|
|
198
|
+
// ============================================
|
|
199
|
+
|
|
200
|
+
function normalizeResource(
|
|
201
|
+
parsed: ParsedResource,
|
|
202
|
+
persistence: ExtendedResourcePersistence
|
|
203
|
+
): DdlResource {
|
|
204
|
+
const { definition } = parsed;
|
|
205
|
+
const tableName = resolveTableName(definition.name, definition.options, persistence);
|
|
206
|
+
const overrides = persistence.fieldOverrides ?? {};
|
|
207
|
+
|
|
208
|
+
// Respect insertion order — Object.entries preserves the author's key order.
|
|
209
|
+
const fieldEntries = Object.entries(definition.fields);
|
|
210
|
+
if (fieldEntries.length === 0) {
|
|
211
|
+
// parser.ts already validates this, but guard defensively.
|
|
212
|
+
throw new TypeError(`Resource "${parsed.resourceName}" has no fields`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const declaredPk = resolveDeclaredPrimaryKey(persistence.primaryKey);
|
|
216
|
+
const fields: DdlFieldDef[] = [];
|
|
217
|
+
const pkFieldKeys: string[] = [];
|
|
218
|
+
// Author field key (e.g. "passwordHash") → resolved column name (e.g. "password_hash").
|
|
219
|
+
// Used by index normalization below to resolve declared index fields.
|
|
220
|
+
const keyToColumn = new Map<string, string>();
|
|
221
|
+
|
|
222
|
+
for (const [fieldKey, field] of fieldEntries) {
|
|
223
|
+
const override = overrides[fieldKey];
|
|
224
|
+
const ddlField = normalizeField(fieldKey, field, override, declaredPk, parsed.resourceName);
|
|
225
|
+
if (ddlField.primary) pkFieldKeys.push(fieldKey);
|
|
226
|
+
fields.push(ddlField);
|
|
227
|
+
keyToColumn.set(fieldKey, ddlField.name);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (pkFieldKeys.length === 0) {
|
|
231
|
+
throw new TypeError(
|
|
232
|
+
`Resource "${parsed.resourceName}" must have exactly one primary key field. Mark a field with \`primary: true\` (via fieldOverrides) or declare \`options.persistence.primaryKey\`.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (pkFieldKeys.length > 1) {
|
|
236
|
+
throw new TypeError(
|
|
237
|
+
`Resource "${parsed.resourceName}" has ${pkFieldKeys.length} primary key fields (${pkFieldKeys.join(", ")}). Composite primary keys are not supported in v1.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const indexes = normalizeIndexes(persistence.indexes, keyToColumn, parsed.resourceName);
|
|
242
|
+
|
|
243
|
+
return { name: tableName, fields, indexes };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveTableName(
|
|
247
|
+
resourceName: string,
|
|
248
|
+
options: ResourceOptions | undefined,
|
|
249
|
+
persistence: ExtendedResourcePersistence
|
|
250
|
+
): string {
|
|
251
|
+
if (persistence.tableName) return persistence.tableName;
|
|
252
|
+
// `options.pluralName` from the existing schema takes precedence over auto-plural,
|
|
253
|
+
// but `tableName` trumps both — this preserves backward compat for users who
|
|
254
|
+
// had a `pluralName` before Phase 4c.
|
|
255
|
+
if (options?.pluralName) return options.pluralName;
|
|
256
|
+
if (options?.autoPlural === false) return resourceName;
|
|
257
|
+
return pluralize(resourceName);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Conservative v1 pluralizer.
|
|
262
|
+
*
|
|
263
|
+
* Rules (in order):
|
|
264
|
+
* 1. ends with `y` preceded by a consonant → `ies` ("city" → "cities")
|
|
265
|
+
* 2. ends with `s` / `x` / `z` / `ch` / `sh` → `+es` ("box" → "boxes")
|
|
266
|
+
* 3. default → `+s` ("user" → "users")
|
|
267
|
+
*
|
|
268
|
+
* English has irregular plurals the framework cannot infer — that's the
|
|
269
|
+
* escape hatch `options.persistence.tableName` exists for.
|
|
270
|
+
*/
|
|
271
|
+
function pluralize(singular: string): string {
|
|
272
|
+
if (/[^aeiou]y$/i.test(singular)) {
|
|
273
|
+
return singular.slice(0, -1) + "ies";
|
|
274
|
+
}
|
|
275
|
+
if (/(?:s|x|z|ch|sh)$/i.test(singular)) {
|
|
276
|
+
return singular + "es";
|
|
277
|
+
}
|
|
278
|
+
return singular + "s";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveDeclaredPrimaryKey(declared: ExtendedResourcePersistence["primaryKey"]): string | undefined {
|
|
282
|
+
if (declared === undefined) return undefined;
|
|
283
|
+
if (typeof declared === "string") return declared;
|
|
284
|
+
return declared[0];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ============================================
|
|
288
|
+
// Internals — field normalization
|
|
289
|
+
// ============================================
|
|
290
|
+
|
|
291
|
+
function normalizeField(
|
|
292
|
+
fieldKey: string,
|
|
293
|
+
field: ResourceField,
|
|
294
|
+
override: FieldOverride | undefined,
|
|
295
|
+
declaredPk: string | undefined,
|
|
296
|
+
resourceName: string
|
|
297
|
+
): DdlFieldDef {
|
|
298
|
+
const name = override?.columnName ?? toSnakeCase(fieldKey);
|
|
299
|
+
const nullable = override?.nullable ?? !(field.required ?? false);
|
|
300
|
+
|
|
301
|
+
// A field is a primary key if:
|
|
302
|
+
// - the explicit `persistence.primaryKey` names it, OR
|
|
303
|
+
// - the field's declaration carries `primary: true` (an opt-in, not the
|
|
304
|
+
// default on Mandu's ResourceField type; accessed via a best-effort
|
|
305
|
+
// cast because `ResourceField` predates this feature).
|
|
306
|
+
const declaredPkMatch = declaredPk !== undefined && declaredPk === fieldKey;
|
|
307
|
+
const fieldLevelPk = Boolean((field as ResourceField & { primary?: boolean }).primary);
|
|
308
|
+
const primary = declaredPkMatch || fieldLevelPk;
|
|
309
|
+
|
|
310
|
+
const unique = override?.unique ?? Boolean((field as ResourceField & { unique?: boolean }).unique);
|
|
311
|
+
const indexed = override?.indexed ?? Boolean((field as ResourceField & { indexed?: boolean }).indexed);
|
|
312
|
+
|
|
313
|
+
const def = override?.default ?? normalizeDefault(field.default, fieldKey, resourceName);
|
|
314
|
+
|
|
315
|
+
const result: DdlFieldDef = {
|
|
316
|
+
name,
|
|
317
|
+
type: field.type as DdlFieldType,
|
|
318
|
+
nullable,
|
|
319
|
+
primary,
|
|
320
|
+
unique,
|
|
321
|
+
indexed,
|
|
322
|
+
};
|
|
323
|
+
if (def !== undefined) result.default = def;
|
|
324
|
+
const maxLength = override?.maxLength ?? (field as ResourceField & { maxLength?: number }).maxLength;
|
|
325
|
+
if (typeof maxLength === "number") result.maxLength = maxLength;
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* `camelCase` / `PascalCase` → `snake_case`. Leaves already-snake names
|
|
331
|
+
* untouched. Runs of consecutive capitals are treated as a single word
|
|
332
|
+
* (`HTTPRequest` → `http_request`) which matches PostgreSQL/Drizzle
|
|
333
|
+
* conventions.
|
|
334
|
+
*/
|
|
335
|
+
export function toSnakeCase(input: string): string {
|
|
336
|
+
if (input.length === 0) return input;
|
|
337
|
+
// Insert underscores at run-of-caps/start-of-word boundaries, then lowercase.
|
|
338
|
+
return input
|
|
339
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2") // camelCase → camel_Case
|
|
340
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") // HTTPServer → HTTP_Server
|
|
341
|
+
.toLowerCase();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Normalize `ResourceField.default` to a `DdlDefault` discriminated union.
|
|
346
|
+
*
|
|
347
|
+
* Rules:
|
|
348
|
+
* undefined → no DEFAULT (returns undefined)
|
|
349
|
+
* null → { kind: "null" }
|
|
350
|
+
* "now" | "current_timestamp" → { kind: "now" }
|
|
351
|
+
* string (other) → { kind: "literal", value }
|
|
352
|
+
* number | boolean → { kind: "literal", value }
|
|
353
|
+
* function | symbol | object | array → throws TypeError
|
|
354
|
+
*
|
|
355
|
+
* Arrays/objects are explicitly rejected: their JSON representation depends
|
|
356
|
+
* on the receiving dialect's JSON column semantics, and the v1 contract
|
|
357
|
+
* is that DEFAULT values are scalar. Users with JSON defaults should use
|
|
358
|
+
* `{ kind: "sql", expr: "'[]'" }` via `fieldOverrides[key].default`.
|
|
359
|
+
*/
|
|
360
|
+
function normalizeDefault(
|
|
361
|
+
raw: unknown,
|
|
362
|
+
fieldKey: string,
|
|
363
|
+
resourceName: string
|
|
364
|
+
): DdlDefault | undefined {
|
|
365
|
+
if (raw === undefined) return undefined;
|
|
366
|
+
if (raw === null) return { kind: "null" };
|
|
367
|
+
if (typeof raw === "string") {
|
|
368
|
+
if (raw === "now" || raw === "current_timestamp") return { kind: "now" };
|
|
369
|
+
return { kind: "literal", value: raw };
|
|
370
|
+
}
|
|
371
|
+
if (typeof raw === "number") {
|
|
372
|
+
if (!Number.isFinite(raw)) {
|
|
373
|
+
throw new TypeError(
|
|
374
|
+
`Field "${resourceName}.${fieldKey}" has non-finite default (${String(raw)}). DEFAULT must be a finite number.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return { kind: "literal", value: raw };
|
|
378
|
+
}
|
|
379
|
+
if (typeof raw === "boolean") return { kind: "literal", value: raw };
|
|
380
|
+
// Functions, symbols, objects, arrays, BigInt — none are representable
|
|
381
|
+
// as a portable SQL DEFAULT literal. Force the user to be explicit via
|
|
382
|
+
// `fieldOverrides[key].default = { kind: "sql", expr: ... }`.
|
|
383
|
+
throw new TypeError(
|
|
384
|
+
`Field "${resourceName}.${fieldKey}" has unsupported default type (${typeof raw}). Use a string, number, boolean, or null — or override via options.persistence.fieldOverrides.${fieldKey}.default with an explicit DdlDefault.`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ============================================
|
|
389
|
+
// Internals — index normalization
|
|
390
|
+
// ============================================
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Normalize user-declared indexes to DDL shape. The author's `idx.fields`
|
|
394
|
+
* entries are field KEYS (as written in `definition.fields`); we resolve
|
|
395
|
+
* each to the post-snake_case column name via `keyToColumn`. Entries
|
|
396
|
+
* that don't match a known field key are passed through verbatim —
|
|
397
|
+
* that supports the escape hatch of referencing a column directly.
|
|
398
|
+
*/
|
|
399
|
+
function normalizeIndexes(
|
|
400
|
+
declared: DdlIndex[] | undefined,
|
|
401
|
+
keyToColumn: ReadonlyMap<string, string>,
|
|
402
|
+
resourceName: string
|
|
403
|
+
): DdlIndex[] {
|
|
404
|
+
if (!declared || declared.length === 0) return [];
|
|
405
|
+
const list = declared.map((idx) => {
|
|
406
|
+
if (!idx.name) throw new TypeError(`Index on "${resourceName}" is missing a name`);
|
|
407
|
+
if (!Array.isArray(idx.fields) || idx.fields.length === 0) {
|
|
408
|
+
throw new TypeError(`Index "${resourceName}.${idx.name}" must declare at least one field`);
|
|
409
|
+
}
|
|
410
|
+
const columns = idx.fields.map((key) => keyToColumn.get(key) ?? key);
|
|
411
|
+
return { name: idx.name, fields: columns, unique: Boolean(idx.unique) };
|
|
412
|
+
});
|
|
413
|
+
// Stable sort by name — makes snapshot output deterministic.
|
|
414
|
+
list.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
415
|
+
// Detect duplicate index names after normalization.
|
|
416
|
+
for (let i = 1; i < list.length; i++) {
|
|
417
|
+
if (list[i].name === list[i - 1].name) {
|
|
418
|
+
throw new TypeError(`Duplicate index name "${list[i].name}" on resource "${resourceName}"`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return list;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ============================================
|
|
425
|
+
// Internals — deterministic JSON
|
|
426
|
+
// ============================================
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* `JSON.stringify` that sorts object keys at every depth. Arrays are
|
|
430
|
+
* preserved in order. `undefined` values (and their keys) are dropped.
|
|
431
|
+
*/
|
|
432
|
+
function stringifyWithSortedKeys(value: unknown, indent: number): string {
|
|
433
|
+
return JSON.stringify(sortKeys(value), null, indent);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function sortKeys(value: unknown): unknown {
|
|
437
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
438
|
+
if (value && typeof value === "object") {
|
|
439
|
+
const entries = Object.entries(value as Record<string, unknown>).filter(
|
|
440
|
+
([, v]) => v !== undefined
|
|
441
|
+
);
|
|
442
|
+
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
443
|
+
const out: Record<string, unknown> = {};
|
|
444
|
+
for (const [k, v] of entries) out[k] = sortKeys(v);
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
return value;
|
|
448
|
+
}
|