@mandujs/core 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +101 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -1,10 +1,23 @@
1
1
  /**
2
2
  * Resource Generator Tests
3
+ *
4
+ * Phase 4c additions (Agent D — refactoring-expert):
5
+ * - Appendix B TC-1~6 preservation guarantees (existing behaviour stays).
6
+ * - generator-repo.ts emission (shape, dialect, snake_case, header).
7
+ * - generator-schema.ts orchestration (diff → desired SQL → migration file).
8
+ * - writeSchemaArtifacts filename sequencing + immutability.
9
+ * - applied.json read-only contract (Agent C owns writes).
3
10
  */
4
11
 
5
12
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
6
- import { generateResourceArtifacts } from "../generator";
13
+ import { generateResourceArtifacts, generateSchemaArtifacts } from "../generator";
14
+ import { generateRepoSource, shouldEmitRepo } from "../generator-repo";
15
+ import {
16
+ computeSchemaGeneration,
17
+ writeSchemaArtifacts,
18
+ } from "../generator-schema";
7
19
  import type { ParsedResource } from "../parser";
20
+ import type { ResourceDefinition } from "../schema";
8
21
  import { resolveGeneratedPaths } from "../../paths";
9
22
  import path from "path";
10
23
  import fs from "fs/promises";
@@ -264,7 +277,9 @@ describe("generateResourceArtifacts", () => {
264
277
  });
265
278
 
266
279
  describe("Generated Content Validation", () => {
267
- test("contract should contain Mandu.contract definition", async () => {
280
+ beforeAll(async () => {
281
+ // Generate artifacts once for the whole describe block so tests are
282
+ // order-independent (previously the first test generated for the others).
268
283
  const parsed = createTestParsedResource("test", {
269
284
  name: "test",
270
285
  fields: {
@@ -277,7 +292,9 @@ describe("Generated Content Validation", () => {
277
292
  rootDir: testDir,
278
293
  force: false,
279
294
  });
295
+ });
280
296
 
297
+ test("contract should contain Mandu.contract definition", async () => {
281
298
  const paths = resolveGeneratedPaths(testDir);
282
299
  const contractPath = path.join(paths.resourceContractsDir, "test.contract.ts");
283
300
  const contractContent = await fs.readFile(contractPath, "utf-8");
@@ -322,3 +339,587 @@ describe("Generated Content Validation", () => {
322
339
  expect(clientContent).toContain("async create(");
323
340
  });
324
341
  });
342
+
343
+ // ========================================================================
344
+ // Phase 4c — Agent D additions
345
+ // ========================================================================
346
+
347
+ /**
348
+ * Build a persistent resource fixture with three commonly-appearing fields
349
+ * (uuid PK, email-unique-like string, camelCase field that requires
350
+ * snake_case conversion). Provider is postgres by default.
351
+ */
352
+ function persistentResource(
353
+ resourceName: string,
354
+ provider: "postgres" | "mysql" | "sqlite" = "postgres",
355
+ extraFields: Record<string, ResourceDefinition["fields"][string]> = {},
356
+ ): ParsedResource {
357
+ const fields: ResourceDefinition["fields"] = {
358
+ // `primary: true` lives on the field (not public on ResourceField but
359
+ // accepted via best-effort cast in snapshot.ts — same pattern here).
360
+ id: { type: "uuid", required: true, primary: true } as ResourceDefinition["fields"][string],
361
+ email: { type: "email", required: true },
362
+ passwordHash: { type: "string", required: true },
363
+ ...extraFields,
364
+ };
365
+ return {
366
+ definition: { name: resourceName, fields, options: { persistence: { provider } } as ResourceDefinition["options"] },
367
+ filePath: `/virtual/${resourceName}.resource.ts`,
368
+ fileName: resourceName,
369
+ resourceName,
370
+ };
371
+ }
372
+
373
+ describe("Appendix B — Preservation TC-1~6 (Phase 4c non-negotiable)", () => {
374
+ test("TC-1: slot untouched for non-persistent resource; repo NOT emitted", async () => {
375
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-tc1-"));
376
+ try {
377
+ const parsed: ParsedResource = {
378
+ definition: {
379
+ name: "article",
380
+ fields: { id: { type: "uuid", required: true }, body: { type: "string", required: true } },
381
+ },
382
+ filePath: "/virtual/article.resource.ts",
383
+ fileName: "article",
384
+ resourceName: "article",
385
+ };
386
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
387
+ const paths = resolveGeneratedPaths(rootDir);
388
+
389
+ const slotPath = path.join(paths.resourceSlotsDir, "article.slot.ts");
390
+ const userContent = "// user wrote this\n" + (await fs.readFile(slotPath, "utf8"));
391
+ await fs.writeFile(slotPath, userContent);
392
+
393
+ const result = await generateResourceArtifacts(parsed, { rootDir, force: false });
394
+ expect(result.skipped).toContain(slotPath);
395
+ const afterSlot = await fs.readFile(slotPath, "utf8");
396
+ expect(afterSlot).toBe(userContent);
397
+
398
+ // No repo file for a non-persistent resource.
399
+ const repoPath = path.join(paths.resourceReposDir, "article.repo.ts");
400
+ await expect(fs.access(repoPath)).rejects.toBeDefined();
401
+ expect(result.repoEmitted).toBeUndefined();
402
+ } finally {
403
+ await fs.rm(rootDir, { recursive: true, force: true });
404
+ }
405
+ });
406
+
407
+ test("TC-2: user-edited slot preserved when persistence is added", async () => {
408
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-tc2-"));
409
+ try {
410
+ // First generation — non-persistent.
411
+ const nonPersistent: ParsedResource = {
412
+ definition: {
413
+ name: "comment",
414
+ fields: { id: { type: "uuid", required: true }, text: { type: "string", required: true } },
415
+ },
416
+ filePath: "/virtual/comment.resource.ts",
417
+ fileName: "comment",
418
+ resourceName: "comment",
419
+ };
420
+ await generateResourceArtifacts(nonPersistent, { rootDir, force: false });
421
+ const paths = resolveGeneratedPaths(rootDir);
422
+ const slotPath = path.join(paths.resourceSlotsDir, "comment.slot.ts");
423
+ const userEdit = "// MY EDIT\n" + (await fs.readFile(slotPath, "utf8"));
424
+ await fs.writeFile(slotPath, userEdit);
425
+
426
+ // Second generation — now with persistence.
427
+ const persistent: ParsedResource = {
428
+ definition: {
429
+ name: "comment",
430
+ fields: {
431
+ id: { type: "uuid", required: true, primary: true } as ResourceDefinition["fields"][string],
432
+ text: { type: "string", required: true },
433
+ },
434
+ options: { persistence: { provider: "postgres" } } as ResourceDefinition["options"],
435
+ },
436
+ filePath: "/virtual/comment.resource.ts",
437
+ fileName: "comment",
438
+ resourceName: "comment",
439
+ };
440
+ const result = await generateResourceArtifacts(persistent, { rootDir, force: false });
441
+
442
+ // Slot still preserved.
443
+ const slotAfter = await fs.readFile(slotPath, "utf8");
444
+ expect(slotAfter).toBe(userEdit);
445
+ expect(result.skipped).toContain(slotPath);
446
+
447
+ // Repo file emitted.
448
+ const repoPath = path.join(paths.resourceReposDir, "comment.repo.ts");
449
+ expect(result.repoEmitted).toBe(true);
450
+ expect(result.created).toContain(repoPath);
451
+ } finally {
452
+ await fs.rm(rootDir, { recursive: true, force: true });
453
+ }
454
+ });
455
+
456
+ test("TC-3: contract regenerates freely (no preservation)", async () => {
457
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-tc3-"));
458
+ try {
459
+ const parsed = persistentResource("widget");
460
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
461
+ const paths = resolveGeneratedPaths(rootDir);
462
+ const contractPath = path.join(paths.resourceContractsDir, "widget.contract.ts");
463
+ await fs.writeFile(contractPath, "// CORRUPTED");
464
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
465
+ const content = await fs.readFile(contractPath, "utf8");
466
+ expect(content).not.toBe("// CORRUPTED");
467
+ expect(content).toContain("Mandu.contract");
468
+ } finally {
469
+ await fs.rm(rootDir, { recursive: true, force: true });
470
+ }
471
+ });
472
+
473
+ test("TC-4: types regenerates freely (no preservation)", async () => {
474
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-tc4-"));
475
+ try {
476
+ const parsed = persistentResource("gadget");
477
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
478
+ const paths = resolveGeneratedPaths(rootDir);
479
+ const typesPath = path.join(paths.resourceTypesDir, "gadget.types.ts");
480
+ await fs.writeFile(typesPath, "// CORRUPTED");
481
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
482
+ const content = await fs.readFile(typesPath, "utf8");
483
+ expect(content).not.toBe("// CORRUPTED");
484
+ expect(content).toContain("InferContract");
485
+ } finally {
486
+ await fs.rm(rootDir, { recursive: true, force: true });
487
+ }
488
+ });
489
+
490
+ test("TC-5: repo regenerates freely (derived, no preservation)", async () => {
491
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-tc5-"));
492
+ try {
493
+ const parsed = persistentResource("token");
494
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
495
+ const paths = resolveGeneratedPaths(rootDir);
496
+ const repoPath = path.join(paths.resourceReposDir, "token.repo.ts");
497
+ await fs.writeFile(repoPath, "// CORRUPTED");
498
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
499
+ const content = await fs.readFile(repoPath, "utf8");
500
+ expect(content).not.toBe("// CORRUPTED");
501
+ expect(content).toContain("createTokensRepo");
502
+ } finally {
503
+ await fs.rm(rootDir, { recursive: true, force: true });
504
+ }
505
+ });
506
+
507
+ test("TC-6: schema snapshot SQL regenerates freely (derived, no preservation)", async () => {
508
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-tc6-"));
509
+ try {
510
+ const parsed = persistentResource("ticket");
511
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
512
+ // The app-level schema step is NOT called by per-resource
513
+ // generateResourceArtifacts — verify generateSchemaArtifacts emits
514
+ // the per-resource schema file and that it's overwritable.
515
+ await generateSchemaArtifacts([parsed], { rootDir });
516
+ const paths = resolveGeneratedPaths(rootDir);
517
+ const schemaPath = path.join(paths.resourceSchemaOutDir, "tickets.sql");
518
+ await fs.writeFile(schemaPath, "-- CORRUPTED");
519
+ await generateSchemaArtifacts([parsed], { rootDir });
520
+ const content = await fs.readFile(schemaPath, "utf8");
521
+ expect(content).not.toBe("-- CORRUPTED");
522
+ expect(content).toContain("CREATE TABLE");
523
+ } finally {
524
+ await fs.rm(rootDir, { recursive: true, force: true });
525
+ }
526
+ });
527
+ });
528
+
529
+ describe("generateRepoSource — emission shape", () => {
530
+ test("emits factory function, row interface, and five CRUD methods", () => {
531
+ const parsed = persistentResource("user");
532
+ const src = generateRepoSource(parsed)!;
533
+ expect(src).toContain("@generated by Mandu");
534
+ expect(src).toContain("export function createUsersRepo(db: Db)");
535
+ expect(src).toContain("export interface User");
536
+ expect(src).toContain("async findById(");
537
+ expect(src).toContain("async findMany(");
538
+ expect(src).toContain("async create(");
539
+ expect(src).toContain("async update(");
540
+ expect(src).toContain("async delete(");
541
+ });
542
+
543
+ test("imports Db type from @mandujs/core/db by default", () => {
544
+ const parsed = persistentResource("user");
545
+ const src = generateRepoSource(parsed)!;
546
+ expect(src).toContain(`import type { Db } from "@mandujs/core/db"`);
547
+ });
548
+
549
+ test("honors custom dbImport", () => {
550
+ const parsed = persistentResource("user");
551
+ const src = generateRepoSource(parsed, { dbImport: "../../../db/handle" })!;
552
+ expect(src).toContain(`import type { Db } from "../../../db/handle"`);
553
+ });
554
+
555
+ test("column names are snake_case in SQL", () => {
556
+ const parsed = persistentResource("user");
557
+ const src = generateRepoSource(parsed)!;
558
+ // passwordHash field → password_hash column
559
+ expect(src).toContain(`"password_hash"`);
560
+ // email → email (no change)
561
+ expect(src).toContain(`"email"`);
562
+ // Camel aliased in SELECT: "password_hash" AS "passwordHash"
563
+ expect(src).toMatch(/"password_hash"\s+AS\s+"passwordHash"/);
564
+ });
565
+
566
+ test("non-persistent resource throws by default, null with enable:false", () => {
567
+ const nonPersistent: ParsedResource = {
568
+ definition: { name: "note", fields: { id: { type: "uuid", required: true } } },
569
+ filePath: "/virtual/note.resource.ts",
570
+ fileName: "note",
571
+ resourceName: "note",
572
+ };
573
+ expect(() => generateRepoSource(nonPersistent)).toThrow();
574
+ expect(generateRepoSource(nonPersistent, { enable: false })).toBeNull();
575
+ });
576
+
577
+ test("postgres/sqlite emit INSERT ... RETURNING, mysql emits INSERT + SELECT LAST_INSERT_ID", () => {
578
+ // Assert on SQL in the emitted code rather than on comments —
579
+ // comments reference "RETURNING" as prose across all three providers.
580
+ // The discriminator we check is the actual SQL keyword on an insert
581
+ // statement (`INSERT INTO ... RETURNING`) vs MySQL's `LAST_INSERT_ID()`.
582
+ const pg = generateRepoSource(persistentResource("user", "postgres"))!;
583
+ expect(pg).toMatch(/INSERT INTO[\s\S]*?RETURNING/);
584
+
585
+ const sqlite = generateRepoSource(persistentResource("user", "sqlite"))!;
586
+ expect(sqlite).toMatch(/INSERT INTO[\s\S]*?RETURNING/);
587
+
588
+ const mysql = generateRepoSource(persistentResource("user", "mysql"))!;
589
+ expect(mysql).not.toMatch(/INSERT INTO[\s\S]*?RETURNING/);
590
+ expect(mysql).toContain("LAST_INSERT_ID()");
591
+ });
592
+
593
+ test("mysql repo uses backtick identifiers, postgres uses double quotes", () => {
594
+ const mysql = generateRepoSource(persistentResource("user", "mysql"))!;
595
+ expect(mysql).toContain("`users`");
596
+ expect(mysql).not.toContain(`"users"`);
597
+
598
+ const pg = generateRepoSource(persistentResource("user", "postgres"))!;
599
+ expect(pg).toContain(`"users"`);
600
+ expect(pg).not.toContain("`users`");
601
+ });
602
+
603
+ test("shouldEmitRepo returns true only when persistence is declared", () => {
604
+ const persistent = persistentResource("user");
605
+ const nonPersistent: ParsedResource = {
606
+ definition: { name: "note", fields: { id: { type: "uuid", required: true } } },
607
+ filePath: "/virtual/note.resource.ts",
608
+ fileName: "note",
609
+ resourceName: "note",
610
+ };
611
+ expect(shouldEmitRepo(persistent)).toBe(true);
612
+ expect(shouldEmitRepo(nonPersistent)).toBe(false);
613
+ });
614
+
615
+ test("factory name uses table name (pluralized) not singular resource name", () => {
616
+ const parsed = persistentResource("category"); // plural "categories"
617
+ const src = generateRepoSource(parsed)!;
618
+ expect(src).toContain("createCategoriesRepo");
619
+ expect(src).not.toContain("createCategoryRepo");
620
+ });
621
+
622
+ test("row interface mirrors field types (string, number, boolean)", () => {
623
+ const parsed: ParsedResource = {
624
+ definition: {
625
+ name: "item",
626
+ fields: {
627
+ id: { type: "uuid", required: true, primary: true } as ResourceDefinition["fields"][string],
628
+ price: { type: "number", required: true },
629
+ inStock: { type: "boolean", required: true },
630
+ deletedAt: { type: "date" },
631
+ },
632
+ options: { persistence: { provider: "postgres" } } as ResourceDefinition["options"],
633
+ },
634
+ filePath: "/virtual/item.resource.ts",
635
+ fileName: "item",
636
+ resourceName: "item",
637
+ };
638
+ const src = generateRepoSource(parsed)!;
639
+ expect(src).toMatch(/id:\s*string;/);
640
+ expect(src).toMatch(/price:\s*number;/);
641
+ expect(src).toMatch(/inStock:\s*boolean;/);
642
+ expect(src).toMatch(/deletedAt\?:\s*string;/); // optional because required: undefined/false
643
+ });
644
+ });
645
+
646
+ describe("generateSchemaArtifacts — diff + migration orchestration", () => {
647
+ test("first run with two persistent resources emits two CREATE TABLE changes", async () => {
648
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch1-"));
649
+ try {
650
+ const resources = [
651
+ persistentResource("user"),
652
+ persistentResource("product"),
653
+ ];
654
+ const result = await computeSchemaGeneration(resources, rootDir);
655
+ expect(result.changes.length).toBe(2);
656
+ expect(result.changes.every((c) => c.kind === "create-table")).toBe(true);
657
+ expect(result.migrationFilename).not.toBeNull();
658
+ expect(result.migrationSql).toContain("CREATE TABLE");
659
+ expect(result.desiredSchema).toContain("CREATE TABLE");
660
+ expect(Object.keys(result.desiredSchemaByTable).sort()).toEqual(["products", "users"]);
661
+ } finally {
662
+ await fs.rm(rootDir, { recursive: true, force: true });
663
+ }
664
+ });
665
+
666
+ test("unchanged resources → empty changes, null migration filename", async () => {
667
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch2-"));
668
+ try {
669
+ const resources = [persistentResource("user")];
670
+ // Simulate an already-applied snapshot by writing applied.json as
671
+ // Agent C would after `mandu db apply`.
672
+ const first = await computeSchemaGeneration(resources, rootDir);
673
+ const paths = resolveGeneratedPaths(rootDir);
674
+ await fs.mkdir(paths.schemaStateDir, { recursive: true });
675
+ await fs.writeFile(
676
+ path.join(paths.schemaStateDir, "applied.json"),
677
+ JSON.stringify(first.nextSnapshot),
678
+ "utf8",
679
+ );
680
+
681
+ // Second compute with identical resources → zero changes.
682
+ const second = await computeSchemaGeneration(resources, rootDir);
683
+ expect(second.changes.length).toBe(0);
684
+ expect(second.migrationFilename).toBeNull();
685
+ expect(second.migrationSql).toBe("");
686
+ } finally {
687
+ await fs.rm(rootDir, { recursive: true, force: true });
688
+ }
689
+ });
690
+
691
+ test("adding a field produces add-column change with ALTER TABLE SQL", async () => {
692
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch3-"));
693
+ try {
694
+ const original = [persistentResource("user")];
695
+ const first = await computeSchemaGeneration(original, rootDir);
696
+ const paths = resolveGeneratedPaths(rootDir);
697
+ await fs.mkdir(paths.schemaStateDir, { recursive: true });
698
+ await fs.writeFile(
699
+ path.join(paths.schemaStateDir, "applied.json"),
700
+ JSON.stringify(first.nextSnapshot),
701
+ "utf8",
702
+ );
703
+
704
+ const extended = [
705
+ persistentResource("user", "postgres", {
706
+ age: { type: "number", required: false },
707
+ }),
708
+ ];
709
+ const second = await computeSchemaGeneration(extended, rootDir);
710
+ expect(second.changes.length).toBe(1);
711
+ expect(second.changes[0]?.kind).toBe("add-column");
712
+ expect(second.migrationSql).toContain("ALTER TABLE");
713
+ expect(second.migrationSql).toContain("ADD COLUMN");
714
+ } finally {
715
+ await fs.rm(rootDir, { recursive: true, force: true });
716
+ }
717
+ });
718
+
719
+ test("writeSchemaArtifacts assigns NNNN+1 starting from 0001 on first run", async () => {
720
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch4-"));
721
+ try {
722
+ const resources = [persistentResource("user")];
723
+ const result = await generateSchemaArtifacts(resources, { rootDir });
724
+ expect(result.write).not.toBeNull();
725
+ expect(result.write!.migrationVersion).toBe("0001");
726
+ expect(result.write!.migrationFilePath).toMatch(/0001_auto_[^/\\]+\.sql$/);
727
+ } finally {
728
+ await fs.rm(rootDir, { recursive: true, force: true });
729
+ }
730
+ });
731
+
732
+ test("writeSchemaArtifacts never overwrites existing numbered migrations", async () => {
733
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch5-"));
734
+ try {
735
+ const paths = resolveGeneratedPaths(rootDir);
736
+ // Pre-existing user-authored migration.
737
+ await fs.mkdir(paths.migrationsDir, { recursive: true });
738
+ const existingPath = path.join(paths.migrationsDir, "0001_user_init.sql");
739
+ const userSql = "-- user-authored\nCREATE TABLE manual_bootstrap (id INTEGER PRIMARY KEY);";
740
+ await fs.writeFile(existingPath, userSql);
741
+
742
+ const resources = [persistentResource("user")];
743
+ const result = await generateSchemaArtifacts(resources, { rootDir });
744
+
745
+ // Assigned version must be 0002 (next after 0001).
746
+ expect(result.write!.migrationVersion).toBe("0002");
747
+ // Existing file untouched.
748
+ const stillThere = await fs.readFile(existingPath, "utf8");
749
+ expect(stillThere).toBe(userSql);
750
+ } finally {
751
+ await fs.rm(rootDir, { recursive: true, force: true });
752
+ }
753
+ });
754
+
755
+ test("applied.json is NEVER modified by generateSchemaArtifacts (Agent C owns it)", async () => {
756
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch6-"));
757
+ try {
758
+ const resources = [persistentResource("user")];
759
+ const paths = resolveGeneratedPaths(rootDir);
760
+
761
+ // Write an intentionally-stale applied.json so we can verify it
762
+ // doesn't get touched.
763
+ await fs.mkdir(paths.schemaStateDir, { recursive: true });
764
+ const appliedPath = path.join(paths.schemaStateDir, "applied.json");
765
+ const stalePayload = JSON.stringify({
766
+ version: 1,
767
+ provider: "postgres",
768
+ resources: [],
769
+ generatedAt: "2000-01-01T00:00:00.000Z",
770
+ });
771
+ await fs.writeFile(appliedPath, stalePayload);
772
+ const statBefore = await fs.stat(appliedPath);
773
+
774
+ await generateSchemaArtifacts(resources, { rootDir });
775
+
776
+ const statAfter = await fs.stat(appliedPath);
777
+ const contentAfter = await fs.readFile(appliedPath, "utf8");
778
+ // Content identical.
779
+ expect(contentAfter).toBe(stalePayload);
780
+ // mtime unchanged (within a generous tolerance for fs precision).
781
+ expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs);
782
+ } finally {
783
+ await fs.rm(rootDir, { recursive: true, force: true });
784
+ }
785
+ });
786
+
787
+ test("dryRun: true skips all file writes but returns the diff", async () => {
788
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch7-"));
789
+ try {
790
+ const resources = [persistentResource("user")];
791
+ const result = await generateSchemaArtifacts(resources, { rootDir, dryRun: true });
792
+ expect(result.write).toBeNull();
793
+ expect(result.generation.changes.length).toBe(1);
794
+ // Verify no files written.
795
+ const paths = resolveGeneratedPaths(rootDir);
796
+ await expect(fs.access(paths.resourceSchemaOutDir)).rejects.toBeDefined();
797
+ await expect(fs.access(paths.migrationsDir)).rejects.toBeDefined();
798
+ } finally {
799
+ await fs.rm(rootDir, { recursive: true, force: true });
800
+ }
801
+ });
802
+
803
+ test("writeSchemaArtifacts writes per-resource *.sql files with CREATE TABLE", async () => {
804
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch8-"));
805
+ try {
806
+ const resources = [persistentResource("user"), persistentResource("product")];
807
+ const result = await generateSchemaArtifacts(resources, { rootDir });
808
+ expect(result.write!.schemaFilesWritten).toBe(2);
809
+ const paths = resolveGeneratedPaths(rootDir);
810
+ const userSql = await fs.readFile(
811
+ path.join(paths.resourceSchemaOutDir, "users.sql"),
812
+ "utf8",
813
+ );
814
+ const productSql = await fs.readFile(
815
+ path.join(paths.resourceSchemaOutDir, "products.sql"),
816
+ "utf8",
817
+ );
818
+ expect(userSql).toContain("CREATE TABLE");
819
+ expect(userSql).toContain(`"users"`);
820
+ expect(productSql).toContain("CREATE TABLE");
821
+ expect(productSql).toContain(`"products"`);
822
+ } finally {
823
+ await fs.rm(rootDir, { recursive: true, force: true });
824
+ }
825
+ });
826
+
827
+ test("running twice with identical resources produces no duplicate migrations", async () => {
828
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch9-"));
829
+ try {
830
+ const resources = [persistentResource("user")];
831
+ const first = await generateSchemaArtifacts(resources, { rootDir });
832
+ expect(first.write!.migrationVersion).toBe("0001");
833
+
834
+ // Simulate Agent C applying the migration by writing applied.json.
835
+ const paths = resolveGeneratedPaths(rootDir);
836
+ await fs.mkdir(paths.schemaStateDir, { recursive: true });
837
+ await fs.writeFile(
838
+ path.join(paths.schemaStateDir, "applied.json"),
839
+ JSON.stringify(first.generation.nextSnapshot),
840
+ );
841
+
842
+ // Second run — identical input → no new migration, no duplicate file.
843
+ const second = await generateSchemaArtifacts(resources, { rootDir });
844
+ expect(second.write!.migrationVersion).toBeNull();
845
+ expect(second.write!.migrationFilePath).toBeNull();
846
+
847
+ // Only one migration file on disk.
848
+ const files = await fs.readdir(paths.migrationsDir);
849
+ const migrationFiles = files.filter((f) => /^\d{4,}_.*\.sql$/.test(f));
850
+ expect(migrationFiles.length).toBe(1);
851
+ } finally {
852
+ await fs.rm(rootDir, { recursive: true, force: true });
853
+ }
854
+ });
855
+
856
+ test("resources without persistence are silently dropped from snapshot", async () => {
857
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-sch10-"));
858
+ try {
859
+ const resources: ParsedResource[] = [
860
+ persistentResource("user"),
861
+ {
862
+ definition: {
863
+ name: "article",
864
+ fields: { id: { type: "uuid", required: true }, body: { type: "string", required: true } },
865
+ },
866
+ filePath: "/virtual/article.resource.ts",
867
+ fileName: "article",
868
+ resourceName: "article",
869
+ },
870
+ ];
871
+ const result = await computeSchemaGeneration(resources, rootDir);
872
+ // Only `user` made it into the snapshot.
873
+ expect(result.nextSnapshot.resources.length).toBe(1);
874
+ expect(result.nextSnapshot.resources[0]?.name).toBe("users");
875
+ } finally {
876
+ await fs.rm(rootDir, { recursive: true, force: true });
877
+ }
878
+ });
879
+ });
880
+
881
+ describe("generateResourceArtifacts — Phase 4c repo integration", () => {
882
+ test("repoEmitted flag true when persistence declared", async () => {
883
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-repo-int-"));
884
+ try {
885
+ const parsed = persistentResource("session");
886
+ const result = await generateResourceArtifacts(parsed, { rootDir, force: false });
887
+ expect(result.repoEmitted).toBe(true);
888
+ const paths = resolveGeneratedPaths(rootDir);
889
+ const repoPath = path.join(paths.resourceReposDir, "session.repo.ts");
890
+ await fs.access(repoPath);
891
+ const content = await fs.readFile(repoPath, "utf8");
892
+ expect(content).toContain("createSessionsRepo");
893
+ } finally {
894
+ await fs.rm(rootDir, { recursive: true, force: true });
895
+ }
896
+ });
897
+
898
+ test("only: ['repo'] regenerates repo without touching other artifacts", async () => {
899
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-only-repo-"));
900
+ try {
901
+ const parsed = persistentResource("profile");
902
+ await generateResourceArtifacts(parsed, { rootDir, force: false });
903
+ const paths = resolveGeneratedPaths(rootDir);
904
+
905
+ const contractPath = path.join(paths.resourceContractsDir, "profile.contract.ts");
906
+ const beforeStat = await fs.stat(contractPath);
907
+ // Wait >1ms so mtime granularity doesn't collide on fast filesystems.
908
+ await new Promise((r) => setTimeout(r, 20));
909
+ const result = await generateResourceArtifacts(parsed, {
910
+ rootDir,
911
+ force: false,
912
+ only: ["repo"],
913
+ });
914
+ expect(result.repoEmitted).toBe(true);
915
+
916
+ const repoPath = path.join(paths.resourceReposDir, "profile.repo.ts");
917
+ expect(result.created).toContain(repoPath);
918
+ // Contract not regenerated because `only` excluded it.
919
+ const afterStat = await fs.stat(contractPath);
920
+ expect(afterStat.mtimeMs).toBe(beforeStat.mtimeMs);
921
+ } finally {
922
+ await fs.rm(rootDir, { recursive: true, force: true });
923
+ }
924
+ });
925
+ });