@mandujs/core 0.20.10 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Phase 4c — Agent B tests for `snapshot.ts`.
3
+ *
4
+ * Coverage targets:
5
+ * - Normalization correctness (provider gate, table name, column name,
6
+ * primary key resolution, default normalization)
7
+ * - Error paths (conflicting providers, zero/multi PK, unsupported defaults)
8
+ * - JSON serialization determinism (sorted keys, 2-space indent)
9
+ * - Roundtrip parse/serialize
10
+ * - Version rejection, malformed JSON
11
+ * - SHA-256 stability under key-order permutation
12
+ */
13
+
14
+ import { describe, test, expect } from "bun:test";
15
+ import type { ParsedResource } from "../../parser";
16
+ import type { ResourceDefinition } from "../../schema";
17
+ import type { DdlIndex } from "../types";
18
+ import {
19
+ snapshotFromResources,
20
+ serializeSnapshot,
21
+ parseSnapshot,
22
+ hashSnapshot,
23
+ toSnakeCase,
24
+ } from "../snapshot";
25
+
26
+ // --------------------------------------------------------------------
27
+ // Builders — keep test data minimal + explicit
28
+ // --------------------------------------------------------------------
29
+
30
+ function makeParsed(def: ResourceDefinition, file = `/virtual/${def.name}.resource.ts`): ParsedResource {
31
+ return {
32
+ definition: def,
33
+ filePath: file,
34
+ fileName: def.name,
35
+ resourceName: def.name,
36
+ };
37
+ }
38
+
39
+ function pkPostgresUser(): ResourceDefinition {
40
+ return {
41
+ name: "user",
42
+ fields: {
43
+ id: { type: "uuid", required: true, primary: true } as ResourceDefinition["fields"][string],
44
+ email: { type: "email", required: true },
45
+ passwordHash: { type: "string", required: true },
46
+ createdAt: { type: "date", required: true, default: "now" },
47
+ },
48
+ options: {
49
+ persistence: {
50
+ provider: "postgres",
51
+ },
52
+ } as unknown as ResourceDefinition["options"],
53
+ };
54
+ }
55
+
56
+ // --------------------------------------------------------------------
57
+ // Basic inclusion / exclusion
58
+ // --------------------------------------------------------------------
59
+
60
+ describe("snapshotFromResources — inclusion policy", () => {
61
+ test("1. empty resources array → snapshot with empty resources[]", () => {
62
+ const snap = snapshotFromResources([]);
63
+ expect(snap.version).toBe(1);
64
+ expect(snap.resources).toEqual([]);
65
+ });
66
+
67
+ test("2. non-persistent resource is excluded from snapshot", () => {
68
+ const resource = makeParsed({
69
+ name: "widget",
70
+ fields: { id: { type: "uuid", required: true } },
71
+ // no options.persistence → skip entirely
72
+ });
73
+ const snap = snapshotFromResources([resource]);
74
+ expect(snap.resources).toEqual([]);
75
+ });
76
+
77
+ test("3. mixing postgres + mysql providers throws TypeError", () => {
78
+ const a = makeParsed({
79
+ name: "user",
80
+ fields: { id: { type: "uuid", required: true, primary: true } as any },
81
+ options: { persistence: { provider: "postgres" } } as any,
82
+ });
83
+ const b = makeParsed({
84
+ name: "post",
85
+ fields: { id: { type: "uuid", required: true, primary: true } as any },
86
+ options: { persistence: { provider: "mysql" } } as any,
87
+ });
88
+ expect(() => snapshotFromResources([a, b])).toThrow(TypeError);
89
+ expect(() => snapshotFromResources([a, b])).toThrow(/Mixed SQL providers/);
90
+ });
91
+ });
92
+
93
+ // --------------------------------------------------------------------
94
+ // Field + table name normalization
95
+ // --------------------------------------------------------------------
96
+
97
+ describe("snapshotFromResources — table + column naming", () => {
98
+ test("4. camelCase field key becomes snake_case column name", () => {
99
+ const snap = snapshotFromResources([makeParsed(pkPostgresUser())]);
100
+ const user = snap.resources.find((r) => r.name === "users")!;
101
+ const passwordHash = user.fields.find((f) => f.name === "password_hash");
102
+ expect(passwordHash).toBeDefined();
103
+ expect(user.fields.find((f) => f.name === "created_at")).toBeDefined();
104
+ });
105
+
106
+ test("5. autoPlural: false keeps singular table name", () => {
107
+ const def = pkPostgresUser();
108
+ def.options = { autoPlural: false, persistence: { provider: "postgres" } } as any;
109
+ const snap = snapshotFromResources([makeParsed(def)]);
110
+ expect(snap.resources[0].name).toBe("user");
111
+ });
112
+
113
+ test("6. persistence.tableName beats auto-plural", () => {
114
+ const def = pkPostgresUser();
115
+ def.options = {
116
+ persistence: { provider: "postgres", tableName: "app_user" },
117
+ } as any;
118
+ const snap = snapshotFromResources([makeParsed(def)]);
119
+ expect(snap.resources[0].name).toBe("app_user");
120
+ });
121
+
122
+ test("7. fieldOverrides[key].columnName beats snake_case default", () => {
123
+ const def = pkPostgresUser();
124
+ def.options = {
125
+ persistence: {
126
+ provider: "postgres",
127
+ fieldOverrides: { passwordHash: { columnName: "pwd" } },
128
+ },
129
+ } as any;
130
+ const snap = snapshotFromResources([makeParsed(def)]);
131
+ const f = snap.resources[0].fields.find((f) => f.name === "pwd");
132
+ expect(f).toBeDefined();
133
+ expect(snap.resources[0].fields.find((f) => f.name === "password_hash")).toBeUndefined();
134
+ });
135
+
136
+ test("17. duplicate table name after pluralization throws (both names listed)", () => {
137
+ // Two resources both pluralize to "users".
138
+ const defA: ResourceDefinition = {
139
+ name: "user",
140
+ fields: { id: { type: "uuid", required: true, primary: true } as any },
141
+ options: { persistence: { provider: "postgres" } } as any,
142
+ };
143
+ const defB: ResourceDefinition = {
144
+ name: "account",
145
+ fields: { id: { type: "uuid", required: true, primary: true } as any },
146
+ options: { persistence: { provider: "postgres", tableName: "users" } } as any,
147
+ };
148
+ expect(() => snapshotFromResources([makeParsed(defA), makeParsed(defB)])).toThrow(
149
+ /Duplicate table name "users".*user.*account/s
150
+ );
151
+ });
152
+
153
+ test("pluralizer handles -y → -ies", () => {
154
+ const def: ResourceDefinition = {
155
+ name: "category",
156
+ fields: { id: { type: "uuid", required: true, primary: true } as any },
157
+ options: { persistence: { provider: "postgres" } } as any,
158
+ };
159
+ const snap = snapshotFromResources([makeParsed(def)]);
160
+ expect(snap.resources[0].name).toBe("categories");
161
+ });
162
+
163
+ test("pluralizer handles -s → -ses", () => {
164
+ const def: ResourceDefinition = {
165
+ name: "bus",
166
+ fields: { id: { type: "uuid", required: true, primary: true } as any },
167
+ options: { persistence: { provider: "postgres" } } as any,
168
+ };
169
+ const snap = snapshotFromResources([makeParsed(def)]);
170
+ expect(snap.resources[0].name).toBe("buses");
171
+ });
172
+ });
173
+
174
+ // --------------------------------------------------------------------
175
+ // Primary key resolution
176
+ // --------------------------------------------------------------------
177
+
178
+ describe("snapshotFromResources — primary key", () => {
179
+ test("8. zero primary-key fields throws", () => {
180
+ const def: ResourceDefinition = {
181
+ name: "user",
182
+ fields: {
183
+ id: { type: "uuid", required: true }, // no primary flag
184
+ email: { type: "email", required: true },
185
+ },
186
+ options: { persistence: { provider: "postgres" } } as any,
187
+ };
188
+ expect(() => snapshotFromResources([makeParsed(def)])).toThrow(/exactly one primary key/);
189
+ });
190
+
191
+ test("9. multiple primary:true fields throws (composite PK not supported)", () => {
192
+ const def: ResourceDefinition = {
193
+ name: "user",
194
+ fields: {
195
+ id: { type: "uuid", required: true, primary: true } as any,
196
+ email: { type: "email", required: true, primary: true } as any,
197
+ },
198
+ options: { persistence: { provider: "postgres" } } as any,
199
+ };
200
+ expect(() => snapshotFromResources([makeParsed(def)])).toThrow(/Composite primary keys/);
201
+ });
202
+
203
+ test("persistence.primaryKey string resolves PK without field-level flag", () => {
204
+ const def: ResourceDefinition = {
205
+ name: "user",
206
+ fields: {
207
+ id: { type: "uuid", required: true }, // no field-level primary
208
+ email: { type: "email", required: true },
209
+ },
210
+ options: { persistence: { provider: "postgres", primaryKey: "id" } } as any,
211
+ };
212
+ const snap = snapshotFromResources([makeParsed(def)]);
213
+ const pk = snap.resources[0].fields.find((f) => f.primary);
214
+ expect(pk?.name).toBe("id");
215
+ });
216
+
217
+ test("persistence.primaryKey [string] (single-element array) resolves PK", () => {
218
+ const def: ResourceDefinition = {
219
+ name: "user",
220
+ fields: { id: { type: "uuid", required: true } },
221
+ options: { persistence: { provider: "postgres", primaryKey: ["id"] } } as any,
222
+ };
223
+ const snap = snapshotFromResources([makeParsed(def)]);
224
+ expect(snap.resources[0].fields[0].primary).toBe(true);
225
+ });
226
+ });
227
+
228
+ // --------------------------------------------------------------------
229
+ // Default value normalization
230
+ // --------------------------------------------------------------------
231
+
232
+ describe("snapshotFromResources — default normalization", () => {
233
+ test("10. default values map correctly to DdlDefault", () => {
234
+ const def: ResourceDefinition = {
235
+ name: "user",
236
+ fields: {
237
+ id: { type: "uuid", required: true, primary: true } as any,
238
+ createdAt: { type: "date", required: true, default: "now" },
239
+ deletedAt: { type: "date", required: false, default: null },
240
+ age: { type: "number", required: false, default: 42 },
241
+ active: { type: "boolean", required: false, default: true },
242
+ nickname: { type: "string", required: false, default: "anon" },
243
+ },
244
+ options: { persistence: { provider: "postgres" } } as any,
245
+ };
246
+ const snap = snapshotFromResources([makeParsed(def)]);
247
+ const byName = new Map(snap.resources[0].fields.map((f) => [f.name, f]));
248
+ expect(byName.get("created_at")!.default).toEqual({ kind: "now" });
249
+ expect(byName.get("deleted_at")!.default).toEqual({ kind: "null" });
250
+ expect(byName.get("age")!.default).toEqual({ kind: "literal", value: 42 });
251
+ expect(byName.get("active")!.default).toEqual({ kind: "literal", value: true });
252
+ expect(byName.get("nickname")!.default).toEqual({ kind: "literal", value: "anon" });
253
+ // id has no default
254
+ expect(byName.get("id")!.default).toBeUndefined();
255
+ });
256
+
257
+ test("'current_timestamp' also normalizes to { kind: 'now' }", () => {
258
+ const def: ResourceDefinition = {
259
+ name: "user",
260
+ fields: {
261
+ id: { type: "uuid", required: true, primary: true } as any,
262
+ created_at: { type: "date", required: true, default: "current_timestamp" },
263
+ },
264
+ options: { persistence: { provider: "postgres" } } as any,
265
+ };
266
+ const snap = snapshotFromResources([makeParsed(def)]);
267
+ const f = snap.resources[0].fields.find((f) => f.name === "created_at")!;
268
+ expect(f.default).toEqual({ kind: "now" });
269
+ });
270
+
271
+ test("11. function default throws TypeError", () => {
272
+ const def: ResourceDefinition = {
273
+ name: "user",
274
+ fields: {
275
+ id: { type: "uuid", required: true, primary: true } as any,
276
+ createdAt: { type: "date", required: true, default: () => new Date() } as any,
277
+ },
278
+ options: { persistence: { provider: "postgres" } } as any,
279
+ };
280
+ expect(() => snapshotFromResources([makeParsed(def)])).toThrow(/unsupported default type/);
281
+ });
282
+
283
+ test("object default throws TypeError (not a scalar)", () => {
284
+ const def: ResourceDefinition = {
285
+ name: "user",
286
+ fields: {
287
+ id: { type: "uuid", required: true, primary: true } as any,
288
+ meta: { type: "json", required: false, default: {} } as any,
289
+ },
290
+ options: { persistence: { provider: "postgres" } } as any,
291
+ };
292
+ expect(() => snapshotFromResources([makeParsed(def)])).toThrow(/unsupported default type/);
293
+ });
294
+
295
+ test("non-finite number default throws", () => {
296
+ const def: ResourceDefinition = {
297
+ name: "user",
298
+ fields: {
299
+ id: { type: "uuid", required: true, primary: true } as any,
300
+ score: { type: "number", required: false, default: Infinity },
301
+ },
302
+ options: { persistence: { provider: "postgres" } } as any,
303
+ };
304
+ expect(() => snapshotFromResources([makeParsed(def)])).toThrow(/non-finite default/);
305
+ });
306
+
307
+ test("fieldOverrides[key].default bypasses field.default magic", () => {
308
+ const def: ResourceDefinition = {
309
+ name: "user",
310
+ fields: {
311
+ id: { type: "uuid", required: true, primary: true } as any,
312
+ createdAt: { type: "date", required: true, default: "now" },
313
+ },
314
+ options: {
315
+ persistence: {
316
+ provider: "postgres",
317
+ fieldOverrides: {
318
+ createdAt: { default: { kind: "sql", expr: "timezone('UTC', now())" } },
319
+ },
320
+ },
321
+ } as any,
322
+ };
323
+ const snap = snapshotFromResources([makeParsed(def)]);
324
+ const f = snap.resources[0].fields.find((f) => f.name === "created_at")!;
325
+ expect(f.default).toEqual({ kind: "sql", expr: "timezone('UTC', now())" });
326
+ });
327
+ });
328
+
329
+ // --------------------------------------------------------------------
330
+ // Serialization + roundtrip
331
+ // --------------------------------------------------------------------
332
+
333
+ describe("serializeSnapshot / parseSnapshot", () => {
334
+ test("12. roundtrip preserves bytes", () => {
335
+ const snap = snapshotFromResources([makeParsed(pkPostgresUser())]);
336
+ const serialized = serializeSnapshot(snap);
337
+ const reparsed = parseSnapshot(serialized);
338
+ const reserialized = serializeSnapshot(reparsed);
339
+ expect(reserialized).toBe(serialized);
340
+ });
341
+
342
+ test("13. serialization uses 2-space indent and sorted keys", () => {
343
+ const snap = snapshotFromResources([makeParsed(pkPostgresUser())]);
344
+ const json = serializeSnapshot(snap);
345
+ // 2-space indent → keys appear after " " on their own line.
346
+ expect(json).toContain(' "generatedAt":');
347
+ expect(json).toContain(' "provider":');
348
+ expect(json).toContain(' "resources":');
349
+ expect(json).toContain(' "version":');
350
+ // Sorted keys — "generatedAt" precedes "provider" precedes "resources"
351
+ // precedes "version" alphabetically.
352
+ const generatedIdx = json.indexOf('"generatedAt"');
353
+ const providerIdx = json.indexOf('"provider"');
354
+ const resourcesIdx = json.indexOf('"resources"');
355
+ const versionIdx = json.indexOf('"version"');
356
+ expect(generatedIdx).toBeLessThan(providerIdx);
357
+ expect(providerIdx).toBeLessThan(resourcesIdx);
358
+ expect(resourcesIdx).toBeLessThan(versionIdx);
359
+ });
360
+
361
+ test("14. parseSnapshot rejects version: 2 with clear message", () => {
362
+ const v2 = JSON.stringify({
363
+ version: 2,
364
+ provider: "postgres",
365
+ resources: [],
366
+ generatedAt: new Date().toISOString(),
367
+ });
368
+ expect(() => parseSnapshot(v2)).toThrow(/Unsupported snapshot version/);
369
+ });
370
+
371
+ test("15. parseSnapshot rejects malformed JSON", () => {
372
+ expect(() => parseSnapshot("{not-json")).toThrow(/Invalid snapshot JSON/);
373
+ });
374
+
375
+ test("parseSnapshot rejects missing fields", () => {
376
+ expect(() =>
377
+ parseSnapshot(JSON.stringify({ version: 1, provider: "postgres" }))
378
+ ).toThrow(/Snapshot.resources/);
379
+ });
380
+
381
+ test("parseSnapshot rejects invalid provider", () => {
382
+ expect(() =>
383
+ parseSnapshot(JSON.stringify({ version: 1, provider: "oracle", resources: [], generatedAt: "now" }))
384
+ ).toThrow(/invalid provider/);
385
+ });
386
+ });
387
+
388
+ // --------------------------------------------------------------------
389
+ // Hash stability
390
+ // --------------------------------------------------------------------
391
+
392
+ describe("hashSnapshot", () => {
393
+ test("16. hash is stable across object-key permutations (serialization is the canonicalizer)", () => {
394
+ const now = new Date("2026-01-01T00:00:00Z").toISOString();
395
+ // Same snapshot expressed two different ways — different key order.
396
+ const a = {
397
+ version: 1 as const,
398
+ provider: "postgres" as const,
399
+ generatedAt: now,
400
+ resources: [
401
+ {
402
+ name: "users",
403
+ fields: [
404
+ { name: "id", type: "uuid" as const, nullable: false, primary: true, unique: false, indexed: false },
405
+ ],
406
+ indexes: [],
407
+ },
408
+ ],
409
+ };
410
+ const b = {
411
+ // Different key insertion order — must produce same hash.
412
+ provider: "postgres" as const,
413
+ resources: [
414
+ {
415
+ fields: [
416
+ { unique: false, primary: true, nullable: false, name: "id", type: "uuid" as const, indexed: false },
417
+ ],
418
+ name: "users",
419
+ indexes: [],
420
+ },
421
+ ],
422
+ generatedAt: now,
423
+ version: 1 as const,
424
+ };
425
+ expect(hashSnapshot(a)).toBe(hashSnapshot(b));
426
+ });
427
+
428
+ test("hash is hex string of SHA-256 (64 chars)", () => {
429
+ const snap = snapshotFromResources([makeParsed(pkPostgresUser())]);
430
+ const h = hashSnapshot(snap);
431
+ expect(h).toMatch(/^[0-9a-f]{64}$/);
432
+ });
433
+ });
434
+
435
+ // --------------------------------------------------------------------
436
+ // toSnakeCase — exported for reuse by Agent D generator
437
+ // --------------------------------------------------------------------
438
+
439
+ describe("toSnakeCase", () => {
440
+ test("camelCase", () => {
441
+ expect(toSnakeCase("passwordHash")).toBe("password_hash");
442
+ });
443
+ test("PascalCase", () => {
444
+ expect(toSnakeCase("PasswordHash")).toBe("password_hash");
445
+ });
446
+ test("runs of caps stay together", () => {
447
+ expect(toSnakeCase("HTTPRequestId")).toBe("http_request_id");
448
+ });
449
+ test("already snake is untouched", () => {
450
+ expect(toSnakeCase("password_hash")).toBe("password_hash");
451
+ });
452
+ test("single letter", () => {
453
+ expect(toSnakeCase("x")).toBe("x");
454
+ });
455
+ });
456
+
457
+ // --------------------------------------------------------------------
458
+ // Indexes normalization sanity
459
+ // --------------------------------------------------------------------
460
+
461
+ describe("snapshotFromResources — indexes", () => {
462
+ test("index field keys resolve to snake_case column names", () => {
463
+ const def: ResourceDefinition = {
464
+ name: "user",
465
+ fields: {
466
+ id: { type: "uuid", required: true, primary: true } as any,
467
+ createdAt: { type: "date", required: true },
468
+ },
469
+ options: {
470
+ persistence: {
471
+ provider: "postgres",
472
+ indexes: [{ name: "idx_user_created", fields: ["createdAt"], unique: false } as DdlIndex],
473
+ },
474
+ } as any,
475
+ };
476
+ const snap = snapshotFromResources([makeParsed(def)]);
477
+ expect(snap.resources[0].indexes[0].fields).toEqual(["created_at"]);
478
+ });
479
+
480
+ test("duplicate index names throw", () => {
481
+ const def: ResourceDefinition = {
482
+ name: "user",
483
+ fields: {
484
+ id: { type: "uuid", required: true, primary: true } as any,
485
+ email: { type: "email", required: true },
486
+ },
487
+ options: {
488
+ persistence: {
489
+ provider: "postgres",
490
+ indexes: [
491
+ { name: "dup", fields: ["email"], unique: false } as DdlIndex,
492
+ { name: "dup", fields: ["id"], unique: true } as DdlIndex,
493
+ ],
494
+ },
495
+ } as any,
496
+ };
497
+ expect(() => snapshotFromResources([makeParsed(def)])).toThrow(/Duplicate index name/);
498
+ });
499
+ });