@beignet/cli 0.0.3 → 0.0.4

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 (98) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/README.md +379 -61
  3. package/dist/ansi.d.ts +10 -0
  4. package/dist/ansi.d.ts.map +1 -0
  5. package/dist/ansi.js +20 -0
  6. package/dist/ansi.js.map +1 -0
  7. package/dist/choices.d.ts +72 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +88 -0
  10. package/dist/choices.js.map +1 -0
  11. package/dist/completion.d.ts +47 -0
  12. package/dist/completion.d.ts.map +1 -0
  13. package/dist/completion.js +123 -0
  14. package/dist/completion.js.map +1 -0
  15. package/dist/config.d.ts +8 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +8 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/create-prompts.d.ts +42 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +136 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +4 -1
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +16 -26
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +1 -0
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +37 -2
  30. package/dist/db.js.map +1 -1
  31. package/dist/github-annotations.d.ts +18 -0
  32. package/dist/github-annotations.d.ts.map +1 -0
  33. package/dist/github-annotations.js +22 -0
  34. package/dist/github-annotations.js.map +1 -0
  35. package/dist/index.d.ts +1 -9
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +657 -588
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +21 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +1938 -131
  42. package/dist/inspect.js.map +1 -1
  43. package/dist/lib.d.ts +20 -0
  44. package/dist/lib.d.ts.map +1 -0
  45. package/dist/lib.js +17 -0
  46. package/dist/lib.js.map +1 -0
  47. package/dist/lint.d.ts +10 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +340 -33
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +20 -3
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +1756 -394
  54. package/dist/make.js.map +1 -1
  55. package/dist/outbox.d.ts +24 -0
  56. package/dist/outbox.d.ts.map +1 -0
  57. package/dist/outbox.js +138 -0
  58. package/dist/outbox.js.map +1 -0
  59. package/dist/schedule.d.ts +36 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +155 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/task.d.ts +26 -0
  64. package/dist/task.d.ts.map +1 -0
  65. package/dist/task.js +106 -0
  66. package/dist/task.js.map +1 -0
  67. package/dist/templates.d.ts +3 -32
  68. package/dist/templates.d.ts.map +1 -1
  69. package/dist/templates.js +1002 -527
  70. package/dist/templates.js.map +1 -1
  71. package/dist/version.d.ts +8 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +18 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +9 -8
  76. package/src/ansi.ts +30 -0
  77. package/src/choices.ts +137 -0
  78. package/src/completion.ts +169 -0
  79. package/src/config.ts +16 -0
  80. package/src/create-prompts.ts +182 -0
  81. package/src/create.ts +24 -31
  82. package/src/db.ts +60 -4
  83. package/src/github-annotations.ts +37 -0
  84. package/src/index.ts +1067 -803
  85. package/src/inspect.ts +2859 -115
  86. package/src/lib.ts +45 -0
  87. package/src/lint.ts +493 -39
  88. package/src/make.ts +2181 -405
  89. package/src/outbox.ts +249 -0
  90. package/src/schedule.ts +272 -0
  91. package/src/task.ts +169 -0
  92. package/src/templates.ts +1073 -567
  93. package/src/version.ts +20 -0
  94. package/dist/create-bin.d.ts +0 -3
  95. package/dist/create-bin.d.ts.map +0 -1
  96. package/dist/create-bin.js +0 -9
  97. package/dist/create-bin.js.map +0 -1
  98. package/src/create-bin.ts +0 -11
package/dist/make.js CHANGED
@@ -1,31 +1,53 @@
1
1
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
4
- export const makeFeatureAddonChoices = [
5
- "policy",
6
- "event",
7
- "events",
8
- "job",
9
- "jobs",
10
- "notification",
11
- "notifications",
12
- "upload",
13
- "uploads",
14
- ];
4
+ export { makeFeatureAddonChoices } from "./choices.js";
15
5
  export async function makeResource(options) {
6
+ return makeResourceSlice(options, "resource");
7
+ }
8
+ export async function makeFeature(options) {
9
+ const addons = normalizeMakeFeatureAddons(options.with ?? []);
10
+ if (addons.length === 0) {
11
+ return makeFeatureResource(options);
12
+ }
13
+ if (options.dryRun) {
14
+ return makeFeatureSlice(options, addons);
15
+ }
16
+ await makeFeatureSlice({ ...options, dryRun: true }, addons);
17
+ return makeFeatureSlice(options, addons);
18
+ }
19
+ async function makeFeatureResource(options) {
20
+ try {
21
+ return await makeResourceSlice(options, "feature");
22
+ }
23
+ catch (error) {
24
+ if (error instanceof Error && error.message.includes("make resource")) {
25
+ throw new Error(error.message.replaceAll("make resource", "make feature"));
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+ async function makeResourceSlice(options, mode) {
16
31
  const targetDir = path.resolve(options.cwd ?? process.cwd());
17
32
  const names = resourceNames(options.name);
18
33
  const config = options.config
19
34
  ? resolveConfig(options.config)
20
35
  : await loadBeignetConfig(targetDir);
21
36
  await assertStandardApp(targetDir, config);
37
+ if (mode === "resource" &&
38
+ !(await fileExists(path.join(targetDir, resourceSharedErrorsPath(config))))) {
39
+ throw new Error(`beignet make resource expects an app error catalog. Missing ${resourceSharedErrorsPath(config)}.`);
40
+ }
22
41
  const persistence = await detectResourcePersistence(targetDir, config);
23
- const generatedFiles = resourceFiles(names, config, persistence);
42
+ const generationOptions = resourceGenerationOptions(options, mode);
43
+ const generatedFiles = resourceFiles(names, config, persistence, mode, generationOptions);
24
44
  const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
25
45
  force: Boolean(options.force),
26
46
  });
27
47
  const plannedWiringUpdates = await updateResourceWiring(targetDir, names, config, persistence, {
28
48
  dryRun: true,
49
+ mode,
50
+ generationOptions,
29
51
  });
30
52
  const createdFiles = plannedFiles
31
53
  .filter((file) => file.result === "created")
@@ -46,9 +68,12 @@ export async function makeResource(options) {
46
68
  updatedFiles.push(...(await updateResourceWiring(targetDir, names, config, {
47
69
  persistence,
48
70
  dryRun: false,
71
+ mode,
72
+ generationOptions,
49
73
  })));
50
74
  }
51
75
  return {
76
+ schemaVersion: 1,
52
77
  name: names.pluralKebab,
53
78
  targetDir,
54
79
  dryRun: Boolean(options.dryRun),
@@ -58,27 +83,16 @@ export async function makeResource(options) {
58
83
  skippedFiles,
59
84
  };
60
85
  }
61
- export async function makeFeature(options) {
62
- const addons = normalizeMakeFeatureAddons(options.with ?? []);
63
- if (addons.length === 0) {
64
- return makeFeatureResource(options);
65
- }
66
- if (options.dryRun) {
67
- return makeFeatureSlice(options, addons);
68
- }
69
- await makeFeatureSlice({ ...options, dryRun: true }, addons);
70
- return makeFeatureSlice(options, addons);
71
- }
72
- async function makeFeatureResource(options) {
73
- try {
74
- return await makeResource(options);
75
- }
76
- catch (error) {
77
- if (error instanceof Error && error.message.includes("make resource")) {
78
- throw new Error(error.message.replaceAll("make resource", "make feature"));
79
- }
80
- throw error;
86
+ function resourceGenerationOptions(options, mode) {
87
+ if (mode !== "resource") {
88
+ return { auth: false, tenant: false, events: false, softDelete: false };
81
89
  }
90
+ return {
91
+ auth: Boolean(options.auth),
92
+ tenant: Boolean(options.tenant),
93
+ events: Boolean(options.events),
94
+ softDelete: Boolean(options.softDelete),
95
+ };
82
96
  }
83
97
  async function makeFeatureSlice(options, addons) {
84
98
  let result = await makeFeatureResource(options);
@@ -97,6 +111,9 @@ async function makeFeatureAddon(addon, feature, options) {
97
111
  if (addon === "policy") {
98
112
  return makePolicy({ ...shared, name: feature });
99
113
  }
114
+ if (addon === "task") {
115
+ return makeTask({ ...shared, name: `${feature}/backfill` });
116
+ }
100
117
  if (addon === "event") {
101
118
  return makeEvent({ ...shared, name: `${feature}/created` });
102
119
  }
@@ -106,6 +123,9 @@ async function makeFeatureAddon(addon, feature, options) {
106
123
  if (addon === "notification") {
107
124
  return makeNotification({ ...shared, name: `${feature}/created` });
108
125
  }
126
+ if (addon === "ui") {
127
+ return makeFeatureUi({ ...shared, name: feature });
128
+ }
109
129
  return makeUpload({ ...shared, name: `${feature}/attachment` });
110
130
  }
111
131
  function normalizeMakeFeatureAddons(addons) {
@@ -113,6 +133,8 @@ function normalizeMakeFeatureAddons(addons) {
113
133
  for (const addon of addons) {
114
134
  if (addon === "events")
115
135
  normalized.add("event");
136
+ else if (addon === "tasks")
137
+ normalized.add("task");
116
138
  else if (addon === "jobs")
117
139
  normalized.add("job");
118
140
  else if (addon === "notifications")
@@ -126,9 +148,11 @@ function normalizeMakeFeatureAddons(addons) {
126
148
  }
127
149
  const featureAddonOrder = [
128
150
  "policy",
151
+ "task",
129
152
  "event",
130
153
  "job",
131
154
  "notification",
155
+ "ui",
132
156
  "upload",
133
157
  ];
134
158
  function mergeMakeResults(left, right) {
@@ -166,6 +190,7 @@ export async function makeContract(options) {
166
190
  skippedFiles.push(file.path);
167
191
  }
168
192
  return {
193
+ schemaVersion: 1,
169
194
  name: names.pluralKebab,
170
195
  targetDir,
171
196
  dryRun: Boolean(options.dryRun),
@@ -209,6 +234,7 @@ export async function makeUseCase(options) {
209
234
  if (indexResult === "skipped")
210
235
  skippedFiles.push(indexFile.path);
211
236
  return {
237
+ schemaVersion: 1,
212
238
  name: `${names.feature.kebab}/${names.action.kebab}`,
213
239
  targetDir,
214
240
  dryRun: Boolean(options.dryRun),
@@ -247,6 +273,7 @@ export async function makeTest(options) {
247
273
  updatedFiles.push("package.json");
248
274
  }
249
275
  return {
276
+ schemaVersion: 1,
250
277
  name: `${names.feature.kebab}/${names.action.kebab}`,
251
278
  targetDir,
252
279
  dryRun: Boolean(options.dryRun),
@@ -298,6 +325,7 @@ export async function makePort(options) {
298
325
  updatedFiles.push(config.paths.infrastructurePorts);
299
326
  }
300
327
  return {
328
+ schemaVersion: 1,
301
329
  name: names.kebab,
302
330
  targetDir,
303
331
  dryRun: Boolean(options.dryRun),
@@ -336,6 +364,7 @@ export async function makeAdapter(options) {
336
364
  updatedFiles.push(config.paths.infrastructurePorts);
337
365
  }
338
366
  return {
367
+ schemaVersion: 1,
339
368
  name: names.kebab,
340
369
  targetDir,
341
370
  dryRun: Boolean(options.dryRun),
@@ -369,6 +398,7 @@ export async function makePolicy(options) {
369
398
  skippedFiles.push(file.path);
370
399
  }
371
400
  return {
401
+ schemaVersion: 1,
372
402
  name: names.kebab,
373
403
  targetDir,
374
404
  dryRun: Boolean(options.dryRun),
@@ -411,13 +441,52 @@ export async function makeJob(options) {
411
441
  force: Boolean(options.force),
412
442
  dryRun: Boolean(options.dryRun),
413
443
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
414
- files: jobFiles(names, config),
444
+ files: [
445
+ ...(await missingCapabilityBuilderFiles(targetDir, "jobs", config)),
446
+ ...jobFiles(names, config),
447
+ ],
415
448
  index: featureArtifactIndexFile("job", names, config),
416
449
  dependencies: {
417
450
  "@beignet/core": beignetDependencyVersion,
418
451
  },
419
452
  });
420
453
  }
454
+ export async function makeTask(options) {
455
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
456
+ const names = taskNames(options.name);
457
+ const config = options.config
458
+ ? resolveConfig(options.config)
459
+ : await loadBeignetConfig(targetDir);
460
+ await assertFeatureArtifactApp(targetDir, config, "task");
461
+ const result = await makeFeatureArtifact({
462
+ targetDir,
463
+ config,
464
+ force: Boolean(options.force),
465
+ dryRun: Boolean(options.dryRun),
466
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
467
+ files: [
468
+ ...(await missingCapabilityBuilderFiles(targetDir, "tasks", config)),
469
+ ...taskFiles(names, config),
470
+ ],
471
+ index: featureArtifactIndexFile("task", names, config),
472
+ dependencies: {
473
+ "@beignet/core": beignetDependencyVersion,
474
+ },
475
+ });
476
+ const registryResult = await updateTaskRegistry(targetDir, names, config, {
477
+ dryRun: Boolean(options.dryRun),
478
+ });
479
+ if (registryResult === "created")
480
+ result.createdFiles.push(config.paths.tasks);
481
+ if (registryResult === "updated")
482
+ result.updatedFiles.push(config.paths.tasks);
483
+ if (registryResult === "skipped")
484
+ result.skippedFiles.push(config.paths.tasks);
485
+ if (!result.files.includes(config.paths.tasks)) {
486
+ result.files.push(config.paths.tasks);
487
+ }
488
+ return result;
489
+ }
421
490
  export async function makeFactory(options) {
422
491
  const targetDir = path.resolve(options.cwd ?? process.cwd());
423
492
  const names = factoryNames(options.name);
@@ -471,7 +540,10 @@ export async function makeNotification(options) {
471
540
  force: Boolean(options.force),
472
541
  dryRun: Boolean(options.dryRun),
473
542
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
474
- files: notificationFiles(names, config),
543
+ files: [
544
+ ...(await missingCapabilityBuilderFiles(targetDir, "notifications", config)),
545
+ ...notificationFiles(names, config),
546
+ ],
475
547
  index: featureArtifactIndexFile("notification", names, config),
476
548
  dependencies: {
477
549
  "@beignet/core": beignetDependencyVersion,
@@ -492,7 +564,10 @@ export async function makeListener(options) {
492
564
  force: Boolean(options.force),
493
565
  dryRun: Boolean(options.dryRun),
494
566
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
495
- files: listenerFiles(names, config),
567
+ files: [
568
+ ...(await missingCapabilityBuilderFiles(targetDir, "listeners", config)),
569
+ ...listenerFiles(names, config),
570
+ ],
496
571
  index: featureArtifactIndexFile("listener", names, config),
497
572
  dependencies: {
498
573
  "@beignet/core": beignetDependencyVersion,
@@ -520,7 +595,10 @@ export async function makeSchedule(options) {
520
595
  await assertFeatureArtifactApp(targetDir, config, "schedule", {
521
596
  requireServer: Boolean(options.route),
522
597
  });
523
- const files = scheduleFiles(names, config, { route: Boolean(options.route) });
598
+ const files = [
599
+ ...(await missingCapabilityBuilderFiles(targetDir, "schedules", config)),
600
+ ...scheduleFiles(names, config, { route: Boolean(options.route) }),
601
+ ];
524
602
  return makeFeatureArtifact({
525
603
  targetDir,
526
604
  config,
@@ -554,6 +632,58 @@ export async function makeUpload(options) {
554
632
  },
555
633
  });
556
634
  }
635
+ async function makeFeatureUi(options) {
636
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
637
+ const names = featureUiNames(options.name);
638
+ const config = options.config
639
+ ? resolveConfig(options.config)
640
+ : await loadBeignetConfig(targetDir);
641
+ await assertFeatureUiApp(targetDir, config);
642
+ const componentFiles = featureUiComponentFiles(names, config);
643
+ const indexFile = featureUiIndexFile(names, config);
644
+ const plannedFiles = await planGeneratedFiles(targetDir, componentFiles, {
645
+ force: Boolean(options.force),
646
+ });
647
+ const createdFiles = plannedFiles
648
+ .filter((file) => file.result === "created")
649
+ .map((file) => file.path);
650
+ const updatedFiles = plannedFiles
651
+ .filter((file) => file.result === "updated")
652
+ .map((file) => file.path);
653
+ const skippedFiles = plannedFiles
654
+ .filter((file) => file.result === "skipped")
655
+ .map((file) => file.path);
656
+ if (!options.dryRun) {
657
+ for (const file of plannedFiles) {
658
+ await writePlannedGeneratedFile(targetDir, file);
659
+ }
660
+ }
661
+ const indexResult = await updateFeatureUiIndex(targetDir, indexFile, {
662
+ dryRun: Boolean(options.dryRun),
663
+ });
664
+ if (indexResult === "created")
665
+ createdFiles.push(indexFile.path);
666
+ if (indexResult === "updated")
667
+ updatedFiles.push(indexFile.path);
668
+ if (indexResult === "skipped")
669
+ skippedFiles.push(indexFile.path);
670
+ if (await updatePackageDependencies(targetDir, {
671
+ "@beignet/react-query": beignetDependencyVersion,
672
+ "@tanstack/react-query": tanstackReactQueryDependencyVersion,
673
+ }, { dryRun: Boolean(options.dryRun) })) {
674
+ updatedFiles.push("package.json");
675
+ }
676
+ return {
677
+ schemaVersion: 1,
678
+ name: names.pluralKebab,
679
+ targetDir,
680
+ dryRun: Boolean(options.dryRun),
681
+ files: [...componentFiles.map((file) => file.path), indexFile.path],
682
+ createdFiles,
683
+ updatedFiles,
684
+ skippedFiles,
685
+ };
686
+ }
557
687
  async function assertStandardApp(targetDir, config) {
558
688
  const requiredFiles = [
559
689
  config.paths.appContext,
@@ -646,6 +776,18 @@ async function assertFeatureArtifactApp(targetDir, config, artifact, options = {
646
776
  }
647
777
  }
648
778
  }
779
+ async function assertFeatureUiApp(targetDir, config) {
780
+ const clientPath = clientIndexPath(config);
781
+ const requiredFiles = [config.paths.appContext, clientPath];
782
+ for (const file of requiredFiles) {
783
+ try {
784
+ await stat(path.join(targetDir, file));
785
+ }
786
+ catch {
787
+ throw new Error(`beignet make feature --with ui expects a standard Beignet app with React Query client helpers. Missing ${file}.`);
788
+ }
789
+ }
790
+ }
649
791
  async function assertPersistenceArtifactApp(targetDir, names, config, artifact) {
650
792
  const requiredFiles = [
651
793
  config.paths.appContext,
@@ -712,6 +854,62 @@ async function readOptionalFile(filePath) {
712
854
  return undefined;
713
855
  }
714
856
  }
857
+ const capabilityBuilders = {
858
+ listeners: {
859
+ factoryName: "createListeners",
860
+ defineName: "defineListener",
861
+ module: "@beignet/core/events",
862
+ },
863
+ jobs: {
864
+ factoryName: "createJobs",
865
+ defineName: "defineJob",
866
+ module: "@beignet/core/jobs",
867
+ },
868
+ schedules: {
869
+ factoryName: "createSchedules",
870
+ defineName: "defineSchedule",
871
+ module: "@beignet/core/schedules",
872
+ },
873
+ notifications: {
874
+ factoryName: "createNotifications",
875
+ defineName: "defineNotification",
876
+ module: "@beignet/core/notifications",
877
+ },
878
+ tasks: {
879
+ factoryName: "createTasks",
880
+ defineName: "defineTask",
881
+ module: "@beignet/core/tasks",
882
+ },
883
+ };
884
+ function capabilityBuilderPath(capability, config) {
885
+ if (capability === "listeners")
886
+ return config.paths.listenersBuilder;
887
+ if (capability === "jobs")
888
+ return config.paths.jobsBuilder;
889
+ if (capability === "schedules")
890
+ return config.paths.schedulesBuilder;
891
+ if (capability === "notifications")
892
+ return config.paths.notificationsBuilder;
893
+ return config.paths.tasksBuilder;
894
+ }
895
+ function capabilityBuilderFile(capability, config) {
896
+ const builder = capabilityBuilders[capability];
897
+ return {
898
+ path: capabilityBuilderPath(capability, config),
899
+ content: `import { ${builder.factoryName} } from "${builder.module}";
900
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
901
+
902
+ export const { ${builder.defineName} } = ${builder.factoryName}<AppContext>();
903
+ `,
904
+ };
905
+ }
906
+ async function missingCapabilityBuilderFiles(targetDir, capability, config) {
907
+ const file = capabilityBuilderFile(capability, config);
908
+ if (await fileExists(path.join(targetDir, file.path))) {
909
+ return [];
910
+ }
911
+ return [file];
912
+ }
715
913
  async function makeFeatureArtifact(options) {
716
914
  const plannedFiles = await planGeneratedFiles(options.targetDir, options.files, {
717
915
  force: options.force,
@@ -743,6 +941,7 @@ async function makeFeatureArtifact(options) {
743
941
  updatedFiles.push("package.json");
744
942
  }
745
943
  return {
944
+ schemaVersion: 1,
746
945
  name: options.name,
747
946
  targetDir: options.targetDir,
748
947
  dryRun: options.dryRun,
@@ -772,6 +971,9 @@ function beignetDependencyVersion(packageJson) {
772
971
  packageJson.dependencies?.["@beignet/next"] ??
773
972
  "*");
774
973
  }
974
+ function tanstackReactQueryDependencyVersion(packageJson) {
975
+ return packageJson.dependencies?.["@tanstack/react-query"] ?? "^5.100.7";
976
+ }
775
977
  async function updateResourceWiring(targetDir, names, config, persistenceOrOptions, maybeOptions) {
776
978
  const persistence = typeof persistenceOrOptions === "string"
777
979
  ? persistenceOrOptions
@@ -779,6 +981,14 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
779
981
  const options = typeof persistenceOrOptions === "string"
780
982
  ? maybeOptions
781
983
  : persistenceOrOptions;
984
+ const mode = typeof persistenceOrOptions === "string"
985
+ ? (maybeOptions?.mode ?? "feature")
986
+ : (persistenceOrOptions.mode ?? "feature");
987
+ const generationOptions = typeof persistenceOrOptions === "string"
988
+ ? (maybeOptions?.generationOptions ??
989
+ resourceGenerationOptions({ name: names.pluralKebab }, mode))
990
+ : (persistenceOrOptions.generationOptions ??
991
+ resourceGenerationOptions({ name: names.pluralKebab }, mode));
782
992
  const updated = new Set();
783
993
  if (await updatePackageJson(targetDir, {
784
994
  ...options,
@@ -786,13 +996,24 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
786
996
  })) {
787
997
  updated.add("package.json");
788
998
  }
789
- if (await updatePortsIndex(targetDir, names, config, options)) {
999
+ if (await updatePortsIndex(targetDir, names, config, {
1000
+ ...options,
1001
+ generationOptions,
1002
+ })) {
790
1003
  updated.add(config.paths.ports);
791
1004
  }
792
1005
  if (persistence === "memory" &&
793
1006
  (await updateInfrastructurePorts(targetDir, names, config, options))) {
794
1007
  updated.add(config.paths.infrastructurePorts);
795
1008
  }
1009
+ if (generationOptions.auth &&
1010
+ (await updateInfrastructureGatePolicies(targetDir, names, config, options))) {
1011
+ updated.add(config.paths.infrastructurePorts);
1012
+ }
1013
+ if (persistence === "drizzle" &&
1014
+ (await updateInfrastructureDeferredPorts(targetDir, names, config, options))) {
1015
+ updated.add(config.paths.infrastructurePorts);
1016
+ }
796
1017
  if (persistence === "drizzle" &&
797
1018
  (await updateDrizzleSchemaIndex(targetDir, names, config, options))) {
798
1019
  updated.add(drizzleSchemaIndexPath(config));
@@ -805,7 +1026,10 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
805
1026
  (await updateServerTransactionPorts(targetDir, names, config, options))) {
806
1027
  updated.add(config.paths.server);
807
1028
  }
808
- if (await updateOpenApiRoute(targetDir, names, config, options)) {
1029
+ if (await updateSharedErrors(targetDir, names, config, { ...options, mode })) {
1030
+ updated.add(resourceSharedErrorsPath(config));
1031
+ }
1032
+ if (await updateOpenApiRoute(targetDir, names, config, { ...options, mode })) {
809
1033
  updated.add(config.paths.openapiRoute);
810
1034
  }
811
1035
  const routesFile = await updateServerRoutes(targetDir, names, config, options);
@@ -840,6 +1064,16 @@ async function updatePortsIndex(targetDir, names, config, options) {
840
1064
  if (!next.includes(importLine)) {
841
1065
  next = insertAfterImports(next, importLine);
842
1066
  }
1067
+ if (options.generationOptions?.auth) {
1068
+ const policyPath = path.join(config.paths.features, names.pluralKebab, "policy.ts");
1069
+ const policyImportLine = `import type { AuthorizationContext as ${names.singularPascal}AuthorizationContext, ${names.singularCamel}Policy } from "${relativeModule(config.paths.ports, policyPath)}";`;
1070
+ if (!next.includes(policyImportLine)) {
1071
+ next = insertAfterImports(next, policyImportLine);
1072
+ }
1073
+ next = appendUnionTypeMember(next, "AppAuthorizationContext", `${names.singularPascal}AuthorizationContext`);
1074
+ next = appendTupleTypeMember(next, "AppGate", `typeof ${names.singularCamel}Policy`);
1075
+ next = appendGatePolicyMember(next, `typeof ${names.singularCamel}Policy`);
1076
+ }
843
1077
  const entry = `\t${names.pluralCamel}: ${names.singularPascal}Repository;`;
844
1078
  if (!next.includes(entry.trim())) {
845
1079
  next = insertTypeProperty(next, "AppPorts", entry.trim(), `Could not find AppPorts in ${config.paths.ports}. Add ${entry.trim()} manually, or restore the generated ports file before running make resource.`);
@@ -934,7 +1168,7 @@ async function updateInfrastructurePorts(targetDir, names, config, options) {
934
1168
  }
935
1169
  const repositoryConst = `const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();`;
936
1170
  if (!next.includes(repositoryConst)) {
937
- next = replaceRequired(next, /(\nexport const appPorts = definePorts(?:<[^>]+>)?\(\{)/, `\n${repositoryConst}\n$1`, `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add ${repositoryConst} manually, or restore the generated infrastructure ports file before running make resource.`);
1171
+ next = replaceRequired(next, /(\nexport const appPorts = definePorts(?:<[^>]+>)?\((?:\)\()?\{)/, `\n${repositoryConst}\n$1`, `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add ${repositoryConst} manually, or restore the generated infrastructure ports file before running make resource.`);
938
1172
  }
939
1173
  const entry = `\t${names.pluralCamel},`;
940
1174
  const definePortsBody = definePortsBodySource(next);
@@ -954,6 +1188,22 @@ async function updateInfrastructurePorts(targetDir, names, config, options) {
954
1188
  await writeFile(filePath, next);
955
1189
  return true;
956
1190
  }
1191
+ async function updateInfrastructureGatePolicies(targetDir, names, config, options) {
1192
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1193
+ const original = await readFile(filePath, "utf8");
1194
+ let next = original;
1195
+ const policyPath = path.join(config.paths.features, names.pluralKebab, "policy.ts");
1196
+ const importLine = `import { ${names.singularCamel}Policy } from "${relativeModule(config.paths.infrastructurePorts, policyPath)}";`;
1197
+ if (!next.includes(importLine)) {
1198
+ next = insertAfterImports(next, importLine);
1199
+ }
1200
+ next = appendCreateGatePolicy(next, `${names.singularCamel}Policy`, `Could not find createGate({ policies: [...] }) in ${config.paths.infrastructurePorts}. Add ${names.singularCamel}Policy to the gate policies manually, or restore the generated infrastructure ports file before running make resource --auth.`);
1201
+ if (next === original)
1202
+ return false;
1203
+ if (!options.dryRun)
1204
+ await writeFile(filePath, next);
1205
+ return true;
1206
+ }
957
1207
  async function updateDrizzleSchemaIndex(targetDir, names, config, options) {
958
1208
  const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
959
1209
  if (!(await fileExists(filePath)))
@@ -992,6 +1242,19 @@ async function updateDrizzleRepositories(targetDir, names, config, options) {
992
1242
  await writeFile(filePath, next);
993
1243
  return true;
994
1244
  }
1245
+ async function updateInfrastructureDeferredPorts(targetDir, names, config, options) {
1246
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1247
+ const original = await readFile(filePath, "utf8");
1248
+ // Drizzle resources are provided by the app database provider at startup,
1249
+ // so deferred-form ports files defer the new repository key. Identity-form
1250
+ // ports files are left untouched.
1251
+ const next = appendDeferredPortKey(original, names.pluralCamel);
1252
+ if (next === original)
1253
+ return false;
1254
+ if (!options.dryRun)
1255
+ await writeFile(filePath, next);
1256
+ return true;
1257
+ }
995
1258
  async function updateServerTransactionPorts(targetDir, names, config, options) {
996
1259
  const filePath = path.join(targetDir, config.paths.server);
997
1260
  const original = await readFile(filePath, "utf8");
@@ -1020,13 +1283,12 @@ async function updateOpenApiRoute(targetDir, names, config, options) {
1020
1283
  const firstArg = firstCreateOpenAPIHandlerArgInfo(original);
1021
1284
  if (!firstArg?.text.startsWith("["))
1022
1285
  return false;
1023
- const listName = `list${names.pluralPascal}`;
1024
- const createName = `create${names.singularPascal}`;
1286
+ const contractNames = resourceContractNames(names, options.mode);
1025
1287
  const listedContracts = identifiersFromArrayExpression(firstArg.text);
1026
- const missingContracts = [listName, createName].filter((contractName) => !listedContracts.has(contractName));
1288
+ const missingContracts = contractNames.filter((contractName) => !listedContracts.has(contractName));
1027
1289
  if (missingContracts.length === 0)
1028
1290
  return false;
1029
- const importLine = `import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
1291
+ const importLine = `import { ${contractNames.join(", ")} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
1030
1292
  const nextArray = appendToArrayExpression(firstArg.text, missingContracts);
1031
1293
  let next = `${original.slice(0, firstArg.start)}${nextArray}${original.slice(firstArg.end)}`;
1032
1294
  if (!next.includes(importLine)) {
@@ -1038,6 +1300,53 @@ async function updateOpenApiRoute(targetDir, names, config, options) {
1038
1300
  await writeFile(filePath, next);
1039
1301
  return true;
1040
1302
  }
1303
+ async function updateSharedErrors(targetDir, names, config, options) {
1304
+ if (options.mode !== "resource")
1305
+ return false;
1306
+ const filePath = path.join(targetDir, resourceSharedErrorsPath(config));
1307
+ if (!(await fileExists(filePath)))
1308
+ return false;
1309
+ const original = await readFile(filePath, "utf8");
1310
+ const notFoundErrorName = `${names.singularPascal}NotFound`;
1311
+ const conflictErrorName = `${names.singularPascal}Conflict`;
1312
+ const missingErrorNames = [notFoundErrorName, conflictErrorName].filter((errorName) => !new RegExp(`\\b${errorName}\\b`).test(original));
1313
+ if (missingErrorNames.length === 0)
1314
+ return false;
1315
+ const expectedErrorNames = missingErrorNames.join(" and ");
1316
+ const defineErrorsStart = original.indexOf("defineErrors(");
1317
+ if (defineErrorsStart === -1) {
1318
+ throw new Error(`Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
1319
+ }
1320
+ const openBrace = original.indexOf("{", defineErrorsStart);
1321
+ if (openBrace === -1) {
1322
+ throw new Error(`Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
1323
+ }
1324
+ const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
1325
+ if (closeBrace === -1) {
1326
+ throw new Error(`Could not update ${resourceSharedErrorsPath(config)} because the error catalog object is malformed. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
1327
+ }
1328
+ const entries = [];
1329
+ if (missingErrorNames.includes(notFoundErrorName)) {
1330
+ entries.push(`${notFoundErrorName}: {
1331
+ \t\tcode: "${constantCase(names.singularKebab)}_NOT_FOUND",
1332
+ \t\tstatus: 404,
1333
+ \t\tmessage: "${names.singularPascal} not found",
1334
+ \t},`);
1335
+ }
1336
+ if (missingErrorNames.includes(conflictErrorName)) {
1337
+ entries.push(`${conflictErrorName}: {
1338
+ \t\tcode: "${constantCase(names.singularKebab)}_CONFLICT",
1339
+ \t\tstatus: 409,
1340
+ \t\tmessage: "${names.singularPascal} was changed by another request",
1341
+ \t},`);
1342
+ }
1343
+ const next = insertBeforeClosingBrace(original, openBrace, closeBrace, entries.join("\n"));
1344
+ if (next === original)
1345
+ return false;
1346
+ if (!options.dryRun)
1347
+ await writeFile(filePath, next);
1348
+ return true;
1349
+ }
1041
1350
  async function updateServerRoutes(targetDir, names, config, options) {
1042
1351
  const filePath = path.join(targetDir, config.paths.server);
1043
1352
  const original = await readFile(filePath, "utf8");
@@ -1299,6 +1608,67 @@ function typeBodySource(source, typeName) {
1299
1608
  return undefined;
1300
1609
  return source.slice(openBrace + 1, closeBrace);
1301
1610
  }
1611
+ function appendUnionTypeMember(source, typeName, member) {
1612
+ const match = new RegExp(`(export\\s+type\\s+${typeName}\\s*=)([\\s\\S]*?);`).exec(source);
1613
+ if (!match)
1614
+ return source;
1615
+ const declaration = match[0];
1616
+ if (new RegExp(`\\b${member}\\b`).test(declaration))
1617
+ return source;
1618
+ const nextDeclaration = declaration.replace(/;\s*$/, ` | ${member};`);
1619
+ return `${source.slice(0, match.index)}${nextDeclaration}${source.slice(match.index + declaration.length)}`;
1620
+ }
1621
+ function appendTupleTypeMember(source, typeName, member) {
1622
+ const match = new RegExp(`export\\s+type\\s+${typeName}\\s*=\\s*`).exec(source);
1623
+ if (!match)
1624
+ return source;
1625
+ const tupleStart = source.indexOf("[", match.index);
1626
+ if (tupleStart === -1)
1627
+ return source;
1628
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
1629
+ if (tupleEnd === -1)
1630
+ return source;
1631
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
1632
+ if (tuple.includes(member))
1633
+ return source;
1634
+ const nextTuple = appendToArrayExpression(tuple, [member]);
1635
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(tupleEnd + 1)}`;
1636
+ }
1637
+ function appendGatePolicyMember(source, member) {
1638
+ const gateMatch = /\bGatePort\s*</.exec(source);
1639
+ if (!gateMatch)
1640
+ return source;
1641
+ const tupleStart = source.indexOf("[", gateMatch.index);
1642
+ if (tupleStart === -1)
1643
+ return source;
1644
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
1645
+ if (tupleEnd === -1)
1646
+ return source;
1647
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
1648
+ if (tuple.includes(member))
1649
+ return source;
1650
+ const nextTuple = appendToArrayExpression(tuple, [member]);
1651
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(tupleEnd + 1)}`;
1652
+ }
1653
+ function appendCreateGatePolicy(source, policy, failureMessage) {
1654
+ const policyMatch = /\bpolicies\s*:\s*\[/.exec(source);
1655
+ if (!policyMatch) {
1656
+ throw new Error(failureMessage);
1657
+ }
1658
+ const arrayStart = source.indexOf("[", policyMatch.index);
1659
+ if (arrayStart === -1) {
1660
+ throw new Error(failureMessage);
1661
+ }
1662
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
1663
+ if (arrayEnd === -1) {
1664
+ throw new Error(failureMessage);
1665
+ }
1666
+ const policies = source.slice(arrayStart, arrayEnd + 1);
1667
+ if (policies.includes(policy))
1668
+ return source;
1669
+ const nextPolicies = appendToArrayExpression(policies, [policy]);
1670
+ return `${source.slice(0, arrayStart)}${nextPolicies}${source.slice(arrayEnd + 1)}`;
1671
+ }
1302
1672
  function insertDefinePortsProperty(source, property, message) {
1303
1673
  const body = definePortsBodyRange(source);
1304
1674
  if (!body)
@@ -1348,7 +1718,15 @@ function definePortsBodySource(source) {
1348
1718
  return source.slice(body.openBrace + 1, body.closeBrace);
1349
1719
  }
1350
1720
  function definePortsBodyRange(source) {
1351
- const match = /definePorts(?:<[^>]+>)?\(\s*\{/.exec(source);
1721
+ const outer = definePortsOuterBodyRange(source);
1722
+ if (!outer)
1723
+ return undefined;
1724
+ // The curried `definePorts<AppPorts>()({ bound, deferred })` form binds app
1725
+ // ports inside `bound`; generated bindings belong there.
1726
+ return (topLevelBoundObjectRange(source, outer.openBrace, outer.closeBrace) ?? outer);
1727
+ }
1728
+ function definePortsOuterBodyRange(source) {
1729
+ const match = /definePorts(?:<[^>]+>)?\(\s*(?:\)\s*\(\s*)?\{/.exec(source);
1352
1730
  if (!match)
1353
1731
  return undefined;
1354
1732
  const openBrace = source.indexOf("{", match.index);
@@ -1357,6 +1735,39 @@ function definePortsBodyRange(source) {
1357
1735
  return undefined;
1358
1736
  return { openBrace, closeBrace };
1359
1737
  }
1738
+ function appendDeferredPortKey(source, portKey) {
1739
+ const outer = definePortsOuterBodyRange(source);
1740
+ if (!outer)
1741
+ return source;
1742
+ const body = source.slice(outer.openBrace + 1, outer.closeBrace);
1743
+ if (!hasTopLevelObjectProperty(body, "deferred"))
1744
+ return source;
1745
+ const deferredMatch = /\bdeferred\s*:\s*\[/.exec(body);
1746
+ if (!deferredMatch)
1747
+ return source;
1748
+ const arrayStart = source.indexOf("[", outer.openBrace + 1 + deferredMatch.index);
1749
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
1750
+ if (arrayEnd === -1)
1751
+ return source;
1752
+ const tuple = source.slice(arrayStart, arrayEnd + 1);
1753
+ if (new RegExp(`["']${portKey}["']`).test(tuple))
1754
+ return source;
1755
+ const nextTuple = appendToArrayExpression(tuple, [`"${portKey}"`]);
1756
+ return `${source.slice(0, arrayStart)}${nextTuple}${source.slice(arrayEnd + 1)}`;
1757
+ }
1758
+ function topLevelBoundObjectRange(source, openBrace, closeBrace) {
1759
+ const body = source.slice(openBrace + 1, closeBrace);
1760
+ if (!hasTopLevelObjectProperty(body, "bound"))
1761
+ return undefined;
1762
+ const boundMatch = /\bbound\s*:\s*\{/.exec(body);
1763
+ if (!boundMatch)
1764
+ return undefined;
1765
+ const boundOpen = source.indexOf("{", openBrace + 1 + boundMatch.index);
1766
+ const boundClose = matchingBraceIndex(source, boundOpen);
1767
+ if (boundClose === -1)
1768
+ return undefined;
1769
+ return { openBrace: boundOpen, closeBrace: boundClose };
1770
+ }
1360
1771
  function hasTopLevelObjectProperty(source, propertyName) {
1361
1772
  let depth = 0;
1362
1773
  let inString;
@@ -1586,6 +1997,18 @@ function resourceNames(input) {
1586
1997
  ]);
1587
1998
  return names;
1588
1999
  }
2000
+ function featureUiNames(input) {
2001
+ const names = resourceNames(input);
2002
+ const resolvedNames = {
2003
+ ...names,
2004
+ componentExportName: `${names.pluralPascal}Panel`,
2005
+ componentFileName: `${names.pluralKebab}-panel`,
2006
+ };
2007
+ assertGeneratedIdentifiers(input, "Feature UI name", [
2008
+ resolvedNames.componentExportName,
2009
+ ]);
2010
+ return resolvedNames;
2011
+ }
1589
2012
  function featureResourceNames(names) {
1590
2013
  return resourceNames(names.feature.kebab);
1591
2014
  }
@@ -1703,6 +2126,18 @@ function jobNames(input) {
1703
2126
  payloadTypeName: `${jobExportName}Payload`,
1704
2127
  };
1705
2128
  }
2129
+ function taskNames(input) {
2130
+ const names = featureArtifactNames(input, "Task");
2131
+ const taskExportName = `${names.artifact.camel}Task`;
2132
+ assertGeneratedIdentifiers(input, "Task name", [taskExportName]);
2133
+ return {
2134
+ ...names,
2135
+ taskName: names.stableName,
2136
+ taskExportName,
2137
+ inputSchemaName: `${names.artifact.pascal}TaskInputSchema`,
2138
+ inputTypeName: `${names.artifact.pascal}TaskInput`,
2139
+ };
2140
+ }
1706
2141
  function factoryNames(input) {
1707
2142
  const names = featureArtifactNames(input, "Factory");
1708
2143
  const factoryExportName = `${names.artifact.camel}Factory`;
@@ -1901,59 +2336,119 @@ function pascalCase(value) {
1901
2336
  const camel = camelCase(value);
1902
2337
  return `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
1903
2338
  }
1904
- function resourceFiles(names, config, persistence = "memory") {
2339
+ function constantCase(value) {
2340
+ return value.replaceAll("-", "_").toUpperCase();
2341
+ }
2342
+ function resourceFiles(names, config, persistence = "memory", mode = "feature", options = {
2343
+ auth: false,
2344
+ tenant: false,
2345
+ events: false,
2346
+ softDelete: false,
2347
+ }) {
1905
2348
  const useCaseDir = resourceUseCaseDir(names, config);
1906
2349
  const infraDir = infrastructureDir(config);
1907
2350
  const featureDir = resourceFeatureDir(names, config);
1908
2351
  const files = [
1909
2352
  {
1910
2353
  path: resourceContractFilePath(names, config),
1911
- content: contractFile(names, config),
2354
+ content: contractFile(names, config, mode, options),
1912
2355
  },
1913
2356
  {
1914
- path: path.join(useCaseDir, "schemas.ts"),
1915
- content: schemasFile(names),
2357
+ path: path.join(featureDir, "schemas.ts"),
2358
+ content: schemasFile(names, mode, options),
1916
2359
  },
1917
2360
  {
1918
2361
  path: path.join(useCaseDir, `list-${names.pluralKebab}.ts`),
1919
- content: listUseCaseFile(names, config),
2362
+ content: listUseCaseFile(names, config, options),
1920
2363
  },
1921
2364
  {
1922
2365
  path: path.join(useCaseDir, `create-${names.singularKebab}.ts`),
1923
- content: createUseCaseFile(names, config),
2366
+ content: createUseCaseFile(names, config, options),
1924
2367
  },
2368
+ ...(mode === "resource"
2369
+ ? [
2370
+ {
2371
+ path: path.join(useCaseDir, `get-${names.singularKebab}.ts`),
2372
+ content: getUseCaseFile(names, config, options),
2373
+ },
2374
+ {
2375
+ path: path.join(useCaseDir, `update-${names.singularKebab}.ts`),
2376
+ content: updateUseCaseFile(names, config, options),
2377
+ },
2378
+ {
2379
+ path: path.join(useCaseDir, `delete-${names.singularKebab}.ts`),
2380
+ content: deleteUseCaseFile(names, config, options),
2381
+ },
2382
+ {
2383
+ path: path.join(featureDir, "policy.ts"),
2384
+ content: resourcePolicyFile(names, config, options),
2385
+ },
2386
+ ]
2387
+ : []),
2388
+ ...(mode === "resource" && options.events
2389
+ ? [
2390
+ {
2391
+ path: path.join(featureDir, "domain/events/index.ts"),
2392
+ content: resourceEventsFile(names),
2393
+ },
2394
+ ]
2395
+ : []),
2396
+ ...(mode === "resource" && options.auth
2397
+ ? [
2398
+ {
2399
+ path: path.join(featureDir, "tests/policy.test.ts"),
2400
+ content: resourcePolicyTestFile(names, options),
2401
+ },
2402
+ ]
2403
+ : []),
1925
2404
  {
1926
2405
  path: path.join(useCaseDir, "index.ts"),
1927
- content: useCasesIndexFile(names),
2406
+ content: useCasesIndexFile(names, mode),
1928
2407
  },
1929
2408
  {
1930
2409
  path: resourcePortFilePath(names, config),
1931
- content: repositoryPortFile(names, config),
2410
+ content: repositoryPortFile(names, config, mode, options),
1932
2411
  },
1933
2412
  {
1934
2413
  path: path.join(infraDir, names.pluralKebab, `in-memory-${names.singularKebab}-repository.ts`),
1935
- content: inMemoryRepositoryFile(names, config),
2414
+ content: inMemoryRepositoryFile(names, config, mode, options),
1936
2415
  },
1937
2416
  {
1938
2417
  path: path.join(featureDir, "routes.ts"),
1939
- content: routeGroupFile(names, config),
2418
+ content: routeGroupFile(names, config, mode, options),
1940
2419
  },
1941
2420
  {
1942
2421
  path: resourceTestFilePath(names, config),
1943
- content: testFile(names, config),
2422
+ content: testFile(names, config, mode, options),
1944
2423
  },
1945
2424
  ];
1946
2425
  if (persistence === "drizzle") {
1947
2426
  files.push({
1948
2427
  path: drizzleResourceSchemaFilePath(names, config),
1949
- content: drizzleSchemaFile(names),
2428
+ content: drizzleSchemaFile(names, options),
1950
2429
  }, {
1951
2430
  path: drizzleResourceRepositoryFilePath(names, config),
1952
- content: drizzleRepositoryFile(names, config),
2431
+ content: drizzleRepositoryFile(names, config, mode, options),
1953
2432
  });
1954
2433
  }
1955
2434
  return files;
1956
2435
  }
2436
+ function featureUiComponentFiles(names, config) {
2437
+ const componentPath = featureUiComponentFilePath(names, config);
2438
+ return [
2439
+ {
2440
+ path: componentPath,
2441
+ content: featureUiComponentFile(names, config),
2442
+ },
2443
+ ];
2444
+ }
2445
+ function featureUiIndexFile(names, config) {
2446
+ return {
2447
+ path: featureUiIndexFilePath(names, config),
2448
+ content: `export { ${names.componentExportName} } from "./${names.componentFileName}";
2449
+ `,
2450
+ };
2451
+ }
1957
2452
  function contractFiles(names, config) {
1958
2453
  return [
1959
2454
  {
@@ -2019,6 +2514,14 @@ function jobFiles(names, config) {
2019
2514
  },
2020
2515
  ];
2021
2516
  }
2517
+ function taskFiles(names, config) {
2518
+ return [
2519
+ {
2520
+ path: taskFilePath(names, config),
2521
+ content: taskFile(names, config),
2522
+ },
2523
+ ];
2524
+ }
2022
2525
  function notificationFiles(names, config) {
2023
2526
  return [
2024
2527
  {
@@ -2095,6 +2598,19 @@ function drizzleResourceSchemaFilePath(names, config) {
2095
2598
  function drizzleResourceRepositoryFilePath(names, config) {
2096
2599
  return path.join(infrastructureDir(config), names.pluralKebab, `drizzle-${names.singularKebab}-repository.ts`);
2097
2600
  }
2601
+ function resourceSharedErrorsPath(config) {
2602
+ return path.join(config.paths.features, "shared", "errors.ts");
2603
+ }
2604
+ function resourceContractNames(names, mode) {
2605
+ const contractNames = [
2606
+ `list${names.pluralPascal}`,
2607
+ `create${names.singularPascal}`,
2608
+ ];
2609
+ if (mode === "resource") {
2610
+ contractNames.push(`get${names.singularPascal}`, `update${names.singularPascal}`, `delete${names.singularPascal}`);
2611
+ }
2612
+ return contractNames;
2613
+ }
2098
2614
  async function detectResourcePersistence(targetDir, config) {
2099
2615
  return (await fileExists(path.join(targetDir, drizzleRepositoriesPath(config))))
2100
2616
  ? "drizzle"
@@ -2103,6 +2619,19 @@ async function detectResourcePersistence(targetDir, config) {
2103
2619
  function resourceFeatureDir(names, config) {
2104
2620
  return path.join(config.paths.features, names.pluralKebab);
2105
2621
  }
2622
+ function featureUiDir(names, config) {
2623
+ return path.join(resourceFeatureDir(names, config), "components");
2624
+ }
2625
+ function featureUiComponentFilePath(names, config) {
2626
+ return path.join(featureUiDir(names, config), `${names.componentFileName}.tsx`);
2627
+ }
2628
+ function featureUiIndexFilePath(names, config) {
2629
+ return path.join(featureUiDir(names, config), "index.ts");
2630
+ }
2631
+ function clientIndexPath(config) {
2632
+ const appRoot = path.posix.dirname(config.paths.appContext);
2633
+ return path.join(appRoot === "." ? "" : appRoot, "client/index.ts");
2634
+ }
2106
2635
  function usesFeatureOwnedContracts(config) {
2107
2636
  return (directoryPath(config.paths.contracts) ===
2108
2637
  directoryPath(config.paths.features));
@@ -2137,6 +2666,9 @@ function resourceUseCaseDir(names, config) {
2137
2666
  function resourceUseCaseIndexPath(names, config) {
2138
2667
  return path.join(resourceUseCaseDir(names, config), "index.ts");
2139
2668
  }
2669
+ function resourceSchemaFilePath(names, config) {
2670
+ return path.join(resourceFeatureDir(names, config), "schemas.ts");
2671
+ }
2140
2672
  function resourcePortFilePath(names, config) {
2141
2673
  if (usesFeatureOwnedContracts(config)) {
2142
2674
  return path.join(resourceFeatureDir(names, config), "ports.ts");
@@ -2177,6 +2709,9 @@ function eventFilePath(names, config) {
2177
2709
  function jobFilePath(names, config) {
2178
2710
  return path.join(featureArtifactDir(names, "jobs", config), `${names.artifact.kebab}.ts`);
2179
2711
  }
2712
+ function taskFilePath(names, config) {
2713
+ return path.join(featureArtifactDir(names, "tasks", config), `${names.artifact.kebab}.ts`);
2714
+ }
2180
2715
  function factoryFilePath(names, config) {
2181
2716
  return path.join(featureArtifactDir(names, "factories", config), `${names.artifact.kebab}.ts`);
2182
2717
  }
@@ -2226,52 +2761,76 @@ async function updateUseCaseIndex(targetDir, file, options) {
2226
2761
  await writeFile(destination, next);
2227
2762
  return "updated";
2228
2763
  }
2764
+ async function updateFeatureUiIndex(targetDir, file, options) {
2765
+ const destination = path.join(targetDir, file.path);
2766
+ const existing = await readOptionalFile(destination);
2767
+ if (existing === undefined) {
2768
+ if (!options.dryRun) {
2769
+ await mkdir(path.dirname(destination), { recursive: true });
2770
+ await writeFile(destination, file.content);
2771
+ }
2772
+ return "created";
2773
+ }
2774
+ if (existing.includes(file.content.trim())) {
2775
+ return "skipped";
2776
+ }
2777
+ const next = `${existing.trimEnd()}\n${file.content}`;
2778
+ if (!options.dryRun)
2779
+ await writeFile(destination, next);
2780
+ return "updated";
2781
+ }
2229
2782
  function featureArtifactIndexFile(kind, names, config) {
2230
2783
  const artifactPath = kind === "event"
2231
2784
  ? eventFilePath(names, config)
2232
2785
  : kind === "job"
2233
2786
  ? jobFilePath(names, config)
2234
- : kind === "factory"
2235
- ? factoryFilePath(names, config)
2236
- : kind === "seed"
2237
- ? seedFilePath(names, config)
2238
- : kind === "notification"
2239
- ? notificationFilePath(names, config)
2240
- : kind === "listener"
2241
- ? listenerFilePath(names, config)
2242
- : kind === "schedule"
2243
- ? scheduleFilePath(names, config)
2244
- : uploadFilePath(names, config);
2787
+ : kind === "task"
2788
+ ? taskFilePath(names, config)
2789
+ : kind === "factory"
2790
+ ? factoryFilePath(names, config)
2791
+ : kind === "seed"
2792
+ ? seedFilePath(names, config)
2793
+ : kind === "notification"
2794
+ ? notificationFilePath(names, config)
2795
+ : kind === "listener"
2796
+ ? listenerFilePath(names, config)
2797
+ : kind === "schedule"
2798
+ ? scheduleFilePath(names, config)
2799
+ : uploadFilePath(names, config);
2245
2800
  const registrySuffix = kind === "event"
2246
2801
  ? "Events"
2247
2802
  : kind === "job"
2248
2803
  ? "Jobs"
2249
- : kind === "factory"
2250
- ? "Factories"
2251
- : kind === "seed"
2252
- ? "Seeds"
2253
- : kind === "notification"
2254
- ? "Notifications"
2255
- : kind === "listener"
2256
- ? "Listeners"
2257
- : kind === "schedule"
2258
- ? "Schedules"
2259
- : "Uploads";
2804
+ : kind === "task"
2805
+ ? "Tasks"
2806
+ : kind === "factory"
2807
+ ? "Factories"
2808
+ : kind === "seed"
2809
+ ? "Seeds"
2810
+ : kind === "notification"
2811
+ ? "Notifications"
2812
+ : kind === "listener"
2813
+ ? "Listeners"
2814
+ : kind === "schedule"
2815
+ ? "Schedules"
2816
+ : "Uploads";
2260
2817
  const importName = kind === "event"
2261
2818
  ? names.eventExportName
2262
2819
  : kind === "job"
2263
2820
  ? names.jobExportName
2264
- : kind === "factory"
2265
- ? names.factoryExportName
2266
- : kind === "seed"
2267
- ? names.seedExportName
2268
- : kind === "notification"
2269
- ? names.notificationExportName
2270
- : kind === "listener"
2271
- ? names.listenerExportName
2272
- : kind === "schedule"
2273
- ? names.scheduleExportName
2274
- : names.uploadExportName;
2821
+ : kind === "task"
2822
+ ? names.taskExportName
2823
+ : kind === "factory"
2824
+ ? names.factoryExportName
2825
+ : kind === "seed"
2826
+ ? names.seedExportName
2827
+ : kind === "notification"
2828
+ ? names.notificationExportName
2829
+ : kind === "listener"
2830
+ ? names.listenerExportName
2831
+ : kind === "schedule"
2832
+ ? names.scheduleExportName
2833
+ : names.uploadExportName;
2275
2834
  return {
2276
2835
  path: path.join(path.dirname(artifactPath), "index.ts"),
2277
2836
  kind,
@@ -2365,24 +2924,83 @@ export const ${file.registryName} = [${file.importName}] as const;
2365
2924
  await writeFile(destination, next);
2366
2925
  return "updated";
2367
2926
  }
2368
- function arrayInitializerInfo(source, constName) {
2369
- const match = new RegExp(`\\bconst\\s+${constName}\\s*=`).exec(source);
2370
- if (!match)
2371
- return undefined;
2372
- const openBracket = source.indexOf("[", match.index);
2373
- if (openBracket === -1)
2374
- return undefined;
2375
- const closeBracket = matchingDelimiterIndex(source, openBracket, "[", "]");
2376
- if (closeBracket === -1)
2377
- return undefined;
2378
- return {
2379
- text: source.slice(openBracket, closeBracket + 1),
2380
- start: openBracket,
2381
- end: closeBracket + 1,
2382
- };
2383
- }
2384
- function defineUploadsInitializerInfo(source, constName) {
2385
- const match = new RegExp(`\\bconst\\s+${constName}\\s*=\\s*defineUploads\\s*\\(`).exec(source);
2927
+ async function updateTaskRegistry(targetDir, names, config, options) {
2928
+ const destination = path.join(targetDir, config.paths.tasks);
2929
+ const existing = await readOptionalFile(destination);
2930
+ const featureTasksPath = path.join(featureArtifactDir(names, "tasks", config), "index.ts");
2931
+ const registryName = `${names.featureSingularCamel}Tasks`;
2932
+ const importLine = `import { ${registryName} } from "${relativeModule(config.paths.tasks, featureTasksPath)}";`;
2933
+ const defineTasksImport = `import { defineTasks } from "@beignet/core/tasks";`;
2934
+ const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
2935
+ const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
2936
+ const serverImport = `import { server } from "${relativeModule(config.paths.tasks, config.paths.server)}";`;
2937
+ const registryEntry = `...${registryName}`;
2938
+ if (existing === undefined) {
2939
+ const content = `${defineTasksImport}
2940
+ ${serviceActorImport}
2941
+ ${appContextImport}
2942
+ ${importLine}
2943
+ ${serverImport}
2944
+
2945
+ export const tasks = defineTasks([
2946
+ \t${registryEntry},
2947
+ ] as const);
2948
+
2949
+ export async function createTaskContext(): Promise<AppContext> {
2950
+ \treturn server.createServiceContext({
2951
+ \t\tactor: createServiceActor("beignet-cli"),
2952
+ \t});
2953
+ }
2954
+
2955
+ export async function stopTaskContext(): Promise<void> {
2956
+ \tawait server.stop();
2957
+ }
2958
+ `;
2959
+ if (!options.dryRun) {
2960
+ await mkdir(path.dirname(destination), { recursive: true });
2961
+ await writeFile(destination, content);
2962
+ }
2963
+ return "created";
2964
+ }
2965
+ let next = existing;
2966
+ if (!next.includes(defineTasksImport)) {
2967
+ next = insertAfterImports(next, defineTasksImport);
2968
+ }
2969
+ if (!next.includes(importLine)) {
2970
+ next = insertAfterImports(next, importLine);
2971
+ }
2972
+ const registry = arrayInitializerInfo(next, "tasks");
2973
+ if (!registry) {
2974
+ next = `${next.trimEnd()}\n\nexport const tasks = defineTasks([\n\t${registryEntry},\n] as const);\n`;
2975
+ }
2976
+ else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
2977
+ const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
2978
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
2979
+ }
2980
+ if (next === existing)
2981
+ return "skipped";
2982
+ if (!options.dryRun)
2983
+ await writeFile(destination, next);
2984
+ return "updated";
2985
+ }
2986
+ function arrayInitializerInfo(source, constName) {
2987
+ const match = new RegExp(`\\bconst\\s+${constName}\\s*=`).exec(source);
2988
+ if (!match)
2989
+ return undefined;
2990
+ const openBracket = source.indexOf("[", match.index);
2991
+ if (openBracket === -1)
2992
+ return undefined;
2993
+ const closeBracket = matchingDelimiterIndex(source, openBracket, "[", "]");
2994
+ if (closeBracket === -1)
2995
+ return undefined;
2996
+ return {
2997
+ text: source.slice(openBracket, closeBracket + 1),
2998
+ start: openBracket,
2999
+ end: closeBracket + 1,
3000
+ };
3001
+ }
3002
+ function defineUploadsInitializerInfo(source, constName) {
3003
+ const match = new RegExp(`\\bconst\\s+${constName}\\s*=\\s*defineUploads\\s*\\(`).exec(source);
2386
3004
  if (!match)
2387
3005
  return undefined;
2388
3006
  const openBrace = source.indexOf("{", match.index);
@@ -2397,27 +3015,106 @@ function defineUploadsInitializerInfo(source, constName) {
2397
3015
  end: closeBrace,
2398
3016
  };
2399
3017
  }
2400
- function schemasFile(names) {
3018
+ function schemasFile(names, mode, options) {
2401
3019
  return `import { z } from "zod";
2402
3020
 
2403
3021
  export const ${names.singularPascal}Schema = z.object({
2404
3022
  id: z.string().uuid(),
2405
- name: z.string().min(1),
3023
+ ${options.tenant ? `\ttenantId: z.string().min(1),\n` : ""} name: z.string().min(1),
3024
+ version: z.number().int().min(1),
2406
3025
  createdAt: z.string().datetime(),
3026
+ updatedAt: z.string().datetime(),
2407
3027
  });
2408
3028
 
2409
- export const List${names.pluralPascal}InputSchema = z.object({
2410
- limit: z.coerce.number().int().min(1).max(100).default(20),
2411
- offset: z.coerce.number().int().min(0).default(0),
3029
+ export const ${names.singularPascal}SortBySchema = z.enum(["createdAt", "name"]);
3030
+ export const ${names.singularPascal}SortDirectionSchema = z.enum(["asc", "desc"]);
3031
+
3032
+ const ${names.singularPascal}CursorPayloadSchema = z.object({
3033
+ sortBy: ${names.singularPascal}SortBySchema,
3034
+ sortDirection: ${names.singularPascal}SortDirectionSchema,
3035
+ sortValue: z.string(),
3036
+ id: z.string().uuid(),
2412
3037
  });
2413
3038
 
3039
+ export type ${names.singularPascal}SortBy = z.infer<
3040
+ typeof ${names.singularPascal}SortBySchema
3041
+ >;
3042
+ export type ${names.singularPascal}SortDirection = z.infer<
3043
+ typeof ${names.singularPascal}SortDirectionSchema
3044
+ >;
3045
+ export type ${names.singularPascal}Cursor = z.infer<
3046
+ typeof ${names.singularPascal}CursorPayloadSchema
3047
+ >;
3048
+
3049
+ function encodeBase64Url(value: string): string {
3050
+ const bytes = new TextEncoder().encode(value);
3051
+ let binary = "";
3052
+ for (const byte of bytes) binary += String.fromCharCode(byte);
3053
+
3054
+ return btoa(binary)
3055
+ .replaceAll("+", "-")
3056
+ .replaceAll("/", "_")
3057
+ .replaceAll("=", "");
3058
+ }
3059
+
3060
+ function decodeBase64Url(value: string): string {
3061
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
3062
+ const padding = "=".repeat((4 - (base64.length % 4)) % 4);
3063
+ const binary = atob(base64 + padding);
3064
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
3065
+
3066
+ return new TextDecoder().decode(bytes);
3067
+ }
3068
+
3069
+ export function encode${names.singularPascal}Cursor(cursor: ${names.singularPascal}Cursor): string {
3070
+ return encodeBase64Url(JSON.stringify(cursor));
3071
+ }
3072
+
3073
+ export function decode${names.singularPascal}Cursor(cursor: string): ${names.singularPascal}Cursor {
3074
+ return ${names.singularPascal}CursorPayloadSchema.parse(
3075
+ JSON.parse(decodeBase64Url(cursor)),
3076
+ );
3077
+ }
3078
+
3079
+ export const List${names.pluralPascal}InputSchema = z
3080
+ .object({
3081
+ limit: z.coerce.number().int().min(1).max(100).default(20),
3082
+ cursor: z.string().nullable().default(null),
3083
+ name: z.string().trim().min(1).max(120).optional(),
3084
+ sortBy: ${names.singularPascal}SortBySchema.default("createdAt"),
3085
+ sortDirection: ${names.singularPascal}SortDirectionSchema.default("desc"),
3086
+ })
3087
+ .superRefine((input, ctx) => {
3088
+ if (!input.cursor) return;
3089
+
3090
+ try {
3091
+ const cursor = decode${names.singularPascal}Cursor(input.cursor);
3092
+ if (
3093
+ cursor.sortBy !== input.sortBy ||
3094
+ cursor.sortDirection !== input.sortDirection
3095
+ ) {
3096
+ ctx.addIssue({
3097
+ code: z.ZodIssueCode.custom,
3098
+ message: "cursor does not match the requested sort.",
3099
+ path: ["cursor"],
3100
+ });
3101
+ }
3102
+ } catch {
3103
+ ctx.addIssue({
3104
+ code: z.ZodIssueCode.custom,
3105
+ message: "cursor is invalid.",
3106
+ path: ["cursor"],
3107
+ });
3108
+ }
3109
+ });
3110
+
2414
3111
  export const List${names.pluralPascal}OutputSchema = z.object({
2415
3112
  items: z.array(${names.singularPascal}Schema),
2416
3113
  page: z.object({
2417
- kind: z.literal("offset"),
3114
+ kind: z.literal("cursor"),
2418
3115
  limit: z.number().int().min(1),
2419
- offset: z.number().int().min(0),
2420
- total: z.number().int().min(0),
3116
+ cursor: z.string().nullable(),
3117
+ nextCursor: z.string().nullable(),
2421
3118
  hasMore: z.boolean(),
2422
3119
  }),
2423
3120
  });
@@ -2425,147 +3122,434 @@ export const List${names.pluralPascal}OutputSchema = z.object({
2425
3122
  export const Create${names.singularPascal}InputSchema = z.object({
2426
3123
  name: z.string().min(1).max(120),
2427
3124
  });
3125
+ ${mode === "resource"
3126
+ ? `
3127
+ export const ${names.singularPascal}IdInputSchema = z.object({
3128
+ id: z.string().uuid(),
3129
+ });
2428
3130
 
3131
+ export const Update${names.singularPascal}BodySchema = z.object({
3132
+ name: z.string().min(1).max(120),
3133
+ version: z.number().int().min(1),
3134
+ });
3135
+
3136
+ export const Update${names.singularPascal}InputSchema =
3137
+ ${names.singularPascal}IdInputSchema.merge(Update${names.singularPascal}BodySchema);
3138
+ `
3139
+ : ""}
2429
3140
  export type ${names.singularPascal} = z.infer<typeof ${names.singularPascal}Schema>;
2430
3141
  export type Create${names.singularPascal}Input = z.infer<
2431
3142
  typeof Create${names.singularPascal}InputSchema
2432
3143
  >;
3144
+ ${mode === "resource"
3145
+ ? `
3146
+ export type ${names.singularPascal}IdInput = z.infer<
3147
+ typeof ${names.singularPascal}IdInputSchema
3148
+ >;
3149
+ export type Update${names.singularPascal}Body = z.infer<
3150
+ typeof Update${names.singularPascal}BodySchema
3151
+ >;
3152
+ export type Update${names.singularPascal}Input = z.infer<
3153
+ typeof Update${names.singularPascal}InputSchema
3154
+ >;
3155
+ `
3156
+ : ""}
2433
3157
  export type List${names.pluralPascal}Input = z.infer<
2434
3158
  typeof List${names.pluralPascal}InputSchema
2435
3159
  >;
2436
3160
  `;
2437
3161
  }
2438
- function listUseCaseFile(names, config) {
3162
+ function listUseCaseFile(names, config, options) {
2439
3163
  const filePath = path.join(resourceUseCaseDir(names, config), `list-${names.pluralKebab}.ts`);
2440
- return `import { normalizeOffsetPage } from "@beignet/core/pagination";
3164
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3165
+ import { normalizeCursorPage } from "@beignet/core/pagination";
2441
3166
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2442
3167
  import {
3168
+ decode${names.singularPascal}Cursor,
2443
3169
  List${names.pluralPascal}InputSchema,
2444
3170
  List${names.pluralPascal}OutputSchema,
2445
- } from "./schemas";
3171
+ } from "../schemas";
2446
3172
 
2447
3173
  export const list${names.pluralPascal}UseCase = useCase
2448
3174
  .query("${names.pluralCamel}.list")
2449
3175
  .input(List${names.pluralPascal}InputSchema)
2450
3176
  .output(List${names.pluralPascal}OutputSchema)
2451
3177
  .run(async ({ ctx, input }) => {
2452
- const page = normalizeOffsetPage(input, {
3178
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to list ${names.pluralKebab}.");\n\t\t}\n\n` : ""} const page = normalizeCursorPage(input, {
2453
3179
  defaultLimit: 20,
2454
3180
  maxLimit: 100,
2455
3181
  });
2456
3182
 
2457
- return ctx.ports.${names.pluralCamel}.list(page);
3183
+ return ctx.ports.${names.pluralCamel}.list({
3184
+ page,
3185
+ cursor: page.cursor ? decode${names.singularPascal}Cursor(page.cursor) : null,
3186
+ name: input.name,
3187
+ sortBy: input.sortBy,
3188
+ sortDirection: input.sortDirection,
3189
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
2458
3190
  });
2459
3191
  `;
2460
3192
  }
2461
- function createUseCaseFile(names, config) {
3193
+ function createUseCaseFile(names, config, options) {
2462
3194
  const filePath = path.join(resourceUseCaseDir(names, config), `create-${names.singularKebab}.ts`);
2463
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3195
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3196
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2464
3197
  import {
2465
3198
  Create${names.singularPascal}InputSchema,
2466
3199
  ${names.singularPascal}Schema,
2467
- } from "./schemas";
3200
+ } from "../schemas";
3201
+ ${options.events ? `import { ${names.singularPascal}Created } from "../domain/events";\n` : ""}
2468
3202
 
2469
3203
  export const create${names.singularPascal}UseCase = useCase
2470
3204
  .command("${names.pluralCamel}.create")
2471
3205
  .input(Create${names.singularPascal}InputSchema)
2472
3206
  .output(${names.singularPascal}Schema)
2473
- .run(async ({ ctx, input }) => ctx.ports.${names.pluralCamel}.create(input));
3207
+ .run(async ({ ctx, input }) => {
3208
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to create ${names.singularKebab}.");\n\t\t}\n\n` : ""}${options.auth ? `\t\tawait ctx.gate.authorize("${names.pluralCamel}.create");\n\n` : ""} const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.create({
3209
+ ...input,
3210
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
3211
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Created, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
3212
+ return ${names.singularCamel};
3213
+ });
2474
3214
  `;
2475
3215
  }
2476
- function useCasesIndexFile(names) {
3216
+ function getUseCaseFile(names, config, options) {
3217
+ const filePath = path.join(resourceUseCaseDir(names, config), `get-${names.singularKebab}.ts`);
3218
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3219
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3220
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3221
+ import {
3222
+ ${names.singularPascal}IdInputSchema,
3223
+ ${names.singularPascal}Schema,
3224
+ } from "../schemas";
3225
+
3226
+ export const get${names.singularPascal}UseCase = useCase
3227
+ .query("${names.pluralCamel}.get")
3228
+ .input(${names.singularPascal}IdInputSchema)
3229
+ .output(${names.singularPascal}Schema)
3230
+ .run(async ({ ctx, input }) => {
3231
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to get ${names.singularKebab}.");\n\t\t}\n\n` : ""} const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.findById(input.id${options.tenant ? ", { tenantId }" : ""});
3232
+ if (!${names.singularCamel}) {
3233
+ throw appError("${names.singularPascal}NotFound", {
3234
+ details: { id: input.id },
3235
+ });
3236
+ }
3237
+
3238
+ return ${names.singularCamel};
3239
+ });
3240
+ `;
3241
+ }
3242
+ function updateUseCaseFile(names, config, options) {
3243
+ const filePath = path.join(resourceUseCaseDir(names, config), `update-${names.singularKebab}.ts`);
3244
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3245
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3246
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3247
+ ${options.events ? `import { ${names.singularPascal}Updated } from "../domain/events";\n` : ""}import {
3248
+ Update${names.singularPascal}InputSchema,
3249
+ ${names.singularPascal}Schema,
3250
+ } from "../schemas";
3251
+
3252
+ export const update${names.singularPascal}UseCase = useCase
3253
+ .command("${names.pluralCamel}.update")
3254
+ .input(Update${names.singularPascal}InputSchema)
3255
+ .output(${names.singularPascal}Schema)
3256
+ .run(async ({ ctx, input }) => {
3257
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to update ${names.singularKebab}.");\n\t\t}\n\n` : ""} const existing = await ctx.ports.${names.pluralCamel}.findById(input.id${options.tenant ? ", { tenantId }" : ""});
3258
+ if (!existing) {
3259
+ throw appError("${names.singularPascal}NotFound", {
3260
+ details: { id: input.id },
3261
+ });
3262
+ }
3263
+ ${options.auth ? `\t\tawait ctx.gate.authorize("${names.pluralCamel}.update", existing);\n\n` : ""} if (existing.version !== input.version) {
3264
+ throw appError("${names.singularPascal}Conflict", {
3265
+ details: { id: input.id, expectedVersion: existing.version },
3266
+ });
3267
+ }
3268
+
3269
+ const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.update({
3270
+ ...input,
3271
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
3272
+ if (!${names.singularCamel}) {
3273
+ throw appError("${names.singularPascal}Conflict", {
3274
+ details: { id: input.id, expectedVersion: existing.version },
3275
+ });
3276
+ }
3277
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Updated, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
3278
+ return ${names.singularCamel};
3279
+ });
3280
+ `;
3281
+ }
3282
+ function deleteUseCaseFile(names, config, options) {
3283
+ const filePath = path.join(resourceUseCaseDir(names, config), `delete-${names.singularKebab}.ts`);
3284
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3285
+ import { z } from "zod";
3286
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3287
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3288
+ ${options.events ? `import { ${names.singularPascal}Deleted } from "../domain/events";\n` : ""}import { ${names.singularPascal}IdInputSchema } from "../schemas";
3289
+
3290
+ export const delete${names.singularPascal}UseCase = useCase
3291
+ .command("${names.pluralCamel}.delete")
3292
+ .input(${names.singularPascal}IdInputSchema)
3293
+ .output(z.void())
3294
+ .run(async ({ ctx, input }) => {
3295
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to delete ${names.singularKebab}.");\n\t\t}\n\n` : ""}${options.auth ? `\t\tconst existing = await ctx.ports.${names.pluralCamel}.findById(input.id${options.tenant ? ", { tenantId }" : ""});\n\t\tif (!existing) {\n\t\t\tthrow appError("${names.singularPascal}NotFound", {\n\t\t\t\tdetails: { id: input.id },\n\t\t\t});\n\t\t}\n\t\tawait ctx.gate.authorize("${names.pluralCamel}.delete", existing);\n\n` : ""} const deleted = await ctx.ports.${names.pluralCamel}.delete(input.id${options.tenant ? ", { tenantId }" : ""});
3296
+ if (!deleted) {
3297
+ throw appError("${names.singularPascal}NotFound", {
3298
+ details: { id: input.id },
3299
+ });
3300
+ }
3301
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Deleted, {\n\t\t\tid: input.id,\n\t\t});\n` : ""} });
3302
+ `;
3303
+ }
3304
+ function useCasesIndexFile(names, mode) {
2477
3305
  return `export { create${names.singularPascal}UseCase } from "./create-${names.singularKebab}";
2478
- export { list${names.pluralPascal}UseCase } from "./list-${names.pluralKebab}";
2479
- export {
3306
+ ${mode === "resource" ? `export { delete${names.singularPascal}UseCase } from "./delete-${names.singularKebab}";\nexport { get${names.singularPascal}UseCase } from "./get-${names.singularKebab}";\n` : ""}export { list${names.pluralPascal}UseCase } from "./list-${names.pluralKebab}";
3307
+ ${mode === "resource" ? `export { update${names.singularPascal}UseCase } from "./update-${names.singularKebab}";\n` : ""}export {
2480
3308
  Create${names.singularPascal}InputSchema,
2481
- List${names.pluralPascal}InputSchema,
3309
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n` : ""} List${names.pluralPascal}InputSchema,
2482
3310
  List${names.pluralPascal}OutputSchema,
2483
- ${names.singularPascal}Schema,
3311
+ ${mode === "resource" ? ` Update${names.singularPascal}BodySchema,\n Update${names.singularPascal}InputSchema,\n` : ""} ${names.singularPascal}Schema,
2484
3312
  type Create${names.singularPascal}Input,
2485
- type List${names.pluralPascal}Input,
3313
+ ${mode === "resource" ? ` type ${names.singularPascal}IdInput,\n type Update${names.singularPascal}Body,\n type Update${names.singularPascal}Input,\n` : ""} type List${names.pluralPascal}Input,
2486
3314
  type ${names.singularPascal},
2487
- } from "./schemas";
3315
+ } from "../schemas";
2488
3316
  `;
2489
3317
  }
2490
- function repositoryPortFile(names, config) {
3318
+ function repositoryPortFile(names, config, mode, options) {
2491
3319
  return `import type {
2492
- OffsetPage,
2493
- OffsetPageInfo,
3320
+ CursorPage,
3321
+ CursorPageInfo,
2494
3322
  PageResult,
3323
+ SortDirection,
2495
3324
  } from "@beignet/core/pagination";
2496
3325
  import type {
2497
3326
  Create${names.singularPascal}Input,
2498
- ${names.singularPascal},
2499
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3327
+ ${names.singularPascal}Cursor,
3328
+ ${names.singularPascal}SortBy,
3329
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3330
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2500
3331
 
2501
- export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, OffsetPageInfo>;
3332
+ export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, CursorPageInfo>;
3333
+ export type List${names.pluralPascal}Query = {
3334
+ page: CursorPage;
3335
+ cursor: ${names.singularPascal}Cursor | null;
3336
+ name?: string;
3337
+ sortBy: ${names.singularPascal}SortBy;
3338
+ sortDirection: SortDirection;
3339
+ ${options.tenant ? `\ttenantId: string;\n` : ""}};
3340
+ ${options.tenant ? `export type ${names.singularPascal}TenantFilter = { tenantId: string };\nexport type Create${names.singularPascal}RepositoryInput = Create${names.singularPascal}Input & ${names.singularPascal}TenantFilter;\n${mode === "resource" ? `export type Update${names.singularPascal}RepositoryInput = Update${names.singularPascal}Input & ${names.singularPascal}TenantFilter;\n` : ""}` : ""}
2502
3341
 
2503
3342
  export interface ${names.singularPascal}Repository {
2504
- list(page: OffsetPage): Promise<List${names.pluralPascal}Result>;
2505
- create(input: Create${names.singularPascal}Input): Promise<${names.singularPascal}>;
2506
- }
3343
+ list(query: List${names.pluralPascal}Query): Promise<List${names.pluralPascal}Result>;
3344
+ create(input: ${options.tenant ? `Create${names.singularPascal}RepositoryInput` : `Create${names.singularPascal}Input`}): Promise<${names.singularPascal}>;
3345
+ ${mode === "resource" ? ` findById(id: string${options.tenant ? `, filter: ${names.singularPascal}TenantFilter` : ""}): Promise<${names.singularPascal} | null>;\n update(input: ${options.tenant ? `Update${names.singularPascal}RepositoryInput` : `Update${names.singularPascal}Input`}): Promise<${names.singularPascal} | null>;\n delete(id: string${options.tenant ? `, filter: ${names.singularPascal}TenantFilter` : ""}): Promise<boolean>;\n` : ""}}
2507
3346
  `;
2508
3347
  }
2509
- function inMemoryRepositoryFile(names, config) {
3348
+ function inMemoryRepositoryFile(names, config, mode, options) {
2510
3349
  const repositoryPortPath = resourcePortFilePath(names, config);
2511
- return `import { offsetPageResult } from "@beignet/core/pagination";
3350
+ const recordType = options.softDelete
3351
+ ? `type ${names.singularPascal}Record = ${names.singularPascal} & {
3352
+ \tdeletedAt: string | null;
3353
+ };
3354
+
3355
+ `
3356
+ : "";
3357
+ const mapValueType = options.softDelete
3358
+ ? `${names.singularPascal}Record`
3359
+ : names.singularPascal;
3360
+ const activeFilter = options.softDelete
3361
+ ? `\t\t\t\t.filter((${names.singularCamel}) => ${names.singularCamel}.deletedAt === null)\n`
3362
+ : "";
3363
+ const newDeletedAtField = options.softDelete
3364
+ ? `\t\t\t\tdeletedAt: null,\n`
3365
+ : "";
3366
+ const findDeletedGuard = options.softDelete
3367
+ ? `\t\t\tif (${names.singularCamel}?.deletedAt !== null) return null;\n`
3368
+ : "";
3369
+ const updateDeletedGuard = options.softDelete
3370
+ ? " || existing.deletedAt !== null"
3371
+ : "";
3372
+ const deleteImplementation = options.softDelete
3373
+ ? `${options.tenant ? `\t\t\tconst existing = ${names.pluralCamel}.get(id);\n\t\t\tif (!existing || existing.tenantId !== filter.tenantId || existing.deletedAt !== null) return false;\n` : `\t\t\tconst existing = ${names.pluralCamel}.get(id);\n\t\t\tif (!existing || existing.deletedAt !== null) return false;\n`} const now = new Date().toISOString();
3374
+ ${names.pluralCamel}.set(id, { ...existing, deletedAt: now, updatedAt: now });
3375
+ return true;`
3376
+ : `${options.tenant ? `\t\t\tconst existing = ${names.pluralCamel}.get(id);\n\t\t\tif (existing?.tenantId !== filter.tenantId) return false;\n` : ""} return ${names.pluralCamel}.delete(id);`;
3377
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3378
+ import { cursorPageResult } from "@beignet/core/pagination";
2512
3379
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3380
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2513
3381
  import type {
2514
3382
  Create${names.singularPascal}Input,
2515
- ${names.singularPascal},
2516
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3383
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3384
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
3385
+
3386
+ ${recordType}function to${names.singularPascal}(${names.singularCamel}: ${mapValueType}): ${names.singularPascal} {
3387
+ return {
3388
+ id: ${names.singularCamel}.id,
3389
+ ${options.tenant ? `\t\ttenantId: ${names.singularCamel}.tenantId,\n` : ""} name: ${names.singularCamel}.name,
3390
+ version: ${names.singularCamel}.version,
3391
+ createdAt: ${names.singularCamel}.createdAt,
3392
+ updatedAt: ${names.singularCamel}.updatedAt,
3393
+ };
3394
+ }
3395
+
3396
+ function compare${names.pluralPascal}(
3397
+ left: ${mapValueType},
3398
+ right: ${mapValueType},
3399
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3400
+ ): number {
3401
+ const leftValue = left[query.sortBy];
3402
+ const rightValue = right[query.sortBy];
3403
+ const fieldComparison =
3404
+ leftValue === rightValue ? left.id.localeCompare(right.id) : leftValue.localeCompare(rightValue);
3405
+
3406
+ return query.sortDirection === "asc" ? fieldComparison : -fieldComparison;
3407
+ }
3408
+
3409
+ function isAfter${names.singularPascal}Cursor(
3410
+ ${names.singularCamel}: ${mapValueType},
3411
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3412
+ ): boolean {
3413
+ if (!query.cursor) return true;
3414
+
3415
+ const sortValue = ${names.singularCamel}[query.sortBy];
3416
+ const fieldComparison =
3417
+ sortValue === query.cursor.sortValue
3418
+ ? ${names.singularCamel}.id.localeCompare(query.cursor.id)
3419
+ : sortValue.localeCompare(query.cursor.sortValue);
3420
+
3421
+ return query.sortDirection === "asc" ? fieldComparison > 0 : fieldComparison < 0;
3422
+ }
3423
+
3424
+ function cursorFor${names.singularPascal}(
3425
+ ${names.singularCamel}: ${mapValueType},
3426
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3427
+ ): string {
3428
+ return encode${names.singularPascal}Cursor({
3429
+ sortBy: query.sortBy,
3430
+ sortDirection: query.sortDirection,
3431
+ sortValue: ${names.singularCamel}[query.sortBy],
3432
+ id: ${names.singularCamel}.id,
3433
+ });
3434
+ }
2517
3435
 
2518
3436
  export function createInMemory${names.singularPascal}Repository(
2519
3437
  seed: ${names.singularPascal}[] = [],
2520
3438
  ): ${names.singularPascal}Repository {
2521
- const ${names.pluralCamel} = new Map(seed.map((${names.singularCamel}) => [${names.singularCamel}.id, ${names.singularCamel}]));
3439
+ const ${names.pluralCamel} = new Map<string, ${mapValueType}>(
3440
+ seed.map((${names.singularCamel}) => [
3441
+ ${names.singularCamel}.id,
3442
+ { ...${names.singularCamel}${options.softDelete ? ", deletedAt: null" : ""} },
3443
+ ]),
3444
+ );
2522
3445
 
2523
3446
  return {
2524
- async list(page) {
2525
- const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values()).sort(
2526
- (left, right) => right.createdAt.localeCompare(left.createdAt),
2527
- );
3447
+ async list(query) {
3448
+ const name = query.name?.toLocaleLowerCase();
3449
+ const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values())
3450
+ ${activeFilter}${options.tenant ? `\t\t\t\t.filter((${names.singularCamel}) => ${names.singularCamel}.tenantId === query.tenantId)\n` : ""} .filter((${names.singularCamel}) => !name || ${names.singularCamel}.name.toLocaleLowerCase().includes(name))
3451
+ .sort((left, right) => compare${names.pluralPascal}(left, right, query))
3452
+ .filter((${names.singularCamel}) => isAfter${names.singularPascal}Cursor(${names.singularCamel}, query));
3453
+ const pageItems = all${names.pluralPascal}.slice(0, query.page.limit);
3454
+ const nextCursor =
3455
+ all${names.pluralPascal}.length > query.page.limit && pageItems.length > 0
3456
+ ? cursorFor${names.singularPascal}(pageItems[pageItems.length - 1]!, query)
3457
+ : null;
2528
3458
 
2529
- return offsetPageResult(
2530
- all${names.pluralPascal}.slice(page.offset, page.offset + page.limit),
2531
- page,
2532
- all${names.pluralPascal}.length,
3459
+ return cursorPageResult(
3460
+ pageItems.map(to${names.singularPascal}),
3461
+ query.page,
3462
+ nextCursor,
2533
3463
  );
2534
3464
  },
2535
- async create(input: Create${names.singularPascal}Input) {
3465
+ async create(input) {
3466
+ const now = new Date().toISOString();
2536
3467
  const ${names.singularCamel}: ${names.singularPascal} = {
2537
3468
  id: crypto.randomUUID(),
2538
- name: input.name,
2539
- createdAt: new Date().toISOString(),
3469
+ ${options.tenant ? `\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
3470
+ version: 1,
3471
+ createdAt: now,
3472
+ updatedAt: now,
2540
3473
  };
2541
- ${names.pluralCamel}.set(${names.singularCamel}.id, ${names.singularCamel});
3474
+ ${names.pluralCamel}.set(${names.singularCamel}.id, {
3475
+ ...${names.singularCamel},
3476
+ ${newDeletedAtField} });
2542
3477
  return ${names.singularCamel};
2543
3478
  },
3479
+ ${mode === "resource" ? ` async findById(id: string${options.tenant ? ", filter" : ""}) {\n const ${names.singularCamel} = ${names.pluralCamel}.get(id) ?? null;\n${options.tenant ? `\t\t\tif (${names.singularCamel}?.tenantId !== filter.tenantId) return null;\n` : ""}${findDeletedGuard} return ${names.singularCamel} ? to${names.singularPascal}(${names.singularCamel}) : null;\n },\n async update(input) {\n const existing = ${names.pluralCamel}.get(input.id);\n if (!existing${options.tenant ? " || existing.tenantId !== input.tenantId" : ""}${updateDeletedGuard} || existing.version !== input.version) return null;\n\n const next: ${mapValueType} = {\n ...existing,\n name: input.name,\n version: existing.version + 1,\n updatedAt: new Date().toISOString(),\n };\n ${names.pluralCamel}.set(input.id, next);\n return to${names.singularPascal}(next);\n },\n async delete(id: string${options.tenant ? ", filter" : ""}) {\n${deleteImplementation}\n },\n` : ""}
2544
3480
  };
2545
3481
  }
2546
3482
  `;
2547
3483
  }
2548
- function drizzleSchemaFile(names) {
2549
- return `import { sqliteTable, text } from "drizzle-orm/sqlite-core";
3484
+ function drizzleSchemaFile(names, options) {
3485
+ return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2550
3486
 
2551
3487
  export const ${names.pluralCamel} = sqliteTable("${names.pluralKebab.replaceAll("-", "_")}", {
2552
3488
  id: text("id").primaryKey(),
2553
- name: text("name").notNull(),
3489
+ ${options.tenant ? `\ttenantId: text("tenant_id").notNull(),\n` : ""} name: text("name").notNull(),
3490
+ version: integer("version").notNull(),
2554
3491
  createdAt: text("created_at").notNull(),
2555
- });
3492
+ updatedAt: text("updated_at").notNull(),
3493
+ ${options.softDelete ? `\tdeletedAt: text("deleted_at"),\n` : ""}});
2556
3494
  `;
2557
3495
  }
2558
- function drizzleRepositoryFile(names, config) {
3496
+ function drizzleResourceWhere(names, options, predicates) {
3497
+ const allPredicates = [
3498
+ ...predicates,
3499
+ ...(options.softDelete
3500
+ ? [`isNull(schema.${names.pluralCamel}.deletedAt)`]
3501
+ : []),
3502
+ ];
3503
+ if (allPredicates.length === 0)
3504
+ return "";
3505
+ if (allPredicates.length === 1)
3506
+ return allPredicates[0] ?? "";
3507
+ return `and(${allPredicates.join(", ")})`;
3508
+ }
3509
+ function drizzleRepositoryFile(names, config, mode, options) {
2559
3510
  const repositoryPortPath = resourcePortFilePath(names, config);
2560
3511
  const schemaPath = drizzleSchemaIndexPath(config);
2561
- return `import { offsetPageResult } from "@beignet/core/pagination";
3512
+ const findWhere = drizzleResourceWhere(names, options, [
3513
+ `eq(schema.${names.pluralCamel}.id, id)`,
3514
+ ...(options.tenant
3515
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
3516
+ : []),
3517
+ ]);
3518
+ const updateWhere = drizzleResourceWhere(names, options, [
3519
+ `eq(schema.${names.pluralCamel}.id, input.id)`,
3520
+ `eq(schema.${names.pluralCamel}.version, input.version)`,
3521
+ ...(options.tenant
3522
+ ? [`eq(schema.${names.pluralCamel}.tenantId, input.tenantId)`]
3523
+ : []),
3524
+ ]);
3525
+ const deleteWhere = drizzleResourceWhere(names, options, [
3526
+ `eq(schema.${names.pluralCamel}.id, id)`,
3527
+ ...(options.tenant
3528
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
3529
+ : []),
3530
+ ]);
3531
+ const drizzleImports = [
3532
+ "and",
3533
+ "asc",
3534
+ "desc",
3535
+ "eq",
3536
+ "gt",
3537
+ options.softDelete ? "isNull" : undefined,
3538
+ "lt",
3539
+ "or",
3540
+ "sql",
3541
+ "type SQL",
3542
+ ].filter((name) => Boolean(name));
3543
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3544
+ import { cursorPageResult } from "@beignet/core/pagination";
2562
3545
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
2563
- import { count, desc } from "drizzle-orm";
3546
+ import { ${drizzleImports.join(", ")} } from "drizzle-orm";
2564
3547
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3548
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2565
3549
  import type {
2566
3550
  Create${names.singularPascal}Input,
2567
- ${names.singularPascal},
2568
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3551
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3552
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2569
3553
  import * as schema from "${aliasModule(schemaPath)}";
2570
3554
 
2571
3555
  type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelect;
@@ -2573,35 +3557,104 @@ type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelec
2573
3557
  function to${names.singularPascal}(row: ${names.singularPascal}Row): ${names.singularPascal} {
2574
3558
  return {
2575
3559
  id: row.id,
2576
- name: row.name,
3560
+ ${options.tenant ? `\t\ttenantId: row.tenantId,\n` : ""} name: row.name,
3561
+ version: row.version,
2577
3562
  createdAt: row.createdAt,
3563
+ updatedAt: row.updatedAt,
2578
3564
  };
2579
3565
  }
2580
3566
 
3567
+ type List${names.pluralPascal}Query = Parameters<${names.singularPascal}Repository["list"]>[0];
3568
+
3569
+ function ${names.singularCamel}SortColumn(query: List${names.pluralPascal}Query) {
3570
+ return query.sortBy === "name" ? schema.${names.pluralCamel}.name : schema.${names.pluralCamel}.createdAt;
3571
+ }
3572
+
3573
+ function ${names.singularCamel}CursorFilter(
3574
+ query: List${names.pluralPascal}Query,
3575
+ ): SQL<unknown> | undefined {
3576
+ if (!query.cursor) return undefined;
3577
+
3578
+ const column = ${names.singularCamel}SortColumn(query);
3579
+ const compare = query.sortDirection === "asc" ? gt : lt;
3580
+
3581
+ return or(
3582
+ compare(column, query.cursor.sortValue),
3583
+ and(
3584
+ eq(column, query.cursor.sortValue),
3585
+ compare(schema.${names.pluralCamel}.id, query.cursor.id),
3586
+ ),
3587
+ ) as SQL<unknown>;
3588
+ }
3589
+
3590
+ function ${names.singularCamel}ListWhere(
3591
+ query: List${names.pluralPascal}Query,
3592
+ ): SQL<unknown> | undefined {
3593
+ const filters: SQL<unknown>[] = [
3594
+ ${options.tenant ? `\t\teq(schema.${names.pluralCamel}.tenantId, query.tenantId),\n` : ""}${options.softDelete ? `\t\tisNull(schema.${names.pluralCamel}.deletedAt),\n` : ""} ];
3595
+ const cursor = ${names.singularCamel}CursorFilter(query);
3596
+
3597
+ if (query.name) {
3598
+ const pattern = \`%\${query.name.toLocaleLowerCase()}%\`;
3599
+ filters.push(sql\`lower(\${schema.${names.pluralCamel}.name}) like \${pattern}\`);
3600
+ }
3601
+ if (cursor) filters.push(cursor);
3602
+
3603
+ return filters.length > 0 ? and(...filters) : undefined;
3604
+ }
3605
+
3606
+ function ${names.singularCamel}OrderBy(query: List${names.pluralPascal}Query) {
3607
+ const order = query.sortDirection === "asc" ? asc : desc;
3608
+
3609
+ return [order(${names.singularCamel}SortColumn(query)), order(schema.${names.pluralCamel}.id)] as const;
3610
+ }
3611
+
3612
+ function cursorFor${names.singularPascal}(
3613
+ row: ${names.singularPascal}Row,
3614
+ query: List${names.pluralPascal}Query,
3615
+ ): string {
3616
+ return encode${names.singularPascal}Cursor({
3617
+ sortBy: query.sortBy,
3618
+ sortDirection: query.sortDirection,
3619
+ sortValue: row[query.sortBy],
3620
+ id: row.id,
3621
+ });
3622
+ }
3623
+
2581
3624
  export function createDrizzle${names.singularPascal}Repository(
2582
3625
  db: DrizzleTursoDatabase<typeof schema>,
2583
3626
  ): ${names.singularPascal}Repository {
2584
3627
  return {
2585
- async list(page) {
3628
+ async list(query) {
3629
+ const where = ${names.singularCamel}ListWhere(query);
2586
3630
  const rows = await db
2587
3631
  .select()
2588
3632
  .from(schema.${names.pluralCamel})
2589
- .orderBy(desc(schema.${names.pluralCamel}.createdAt))
2590
- .limit(page.limit)
2591
- .offset(page.offset);
2592
- const [{ total }] = await db
2593
- .select({ total: count() })
2594
- .from(schema.${names.pluralCamel});
2595
-
2596
- return offsetPageResult(rows.map(to${names.singularPascal}), page, total);
3633
+ .where(where)
3634
+ .orderBy(...${names.singularCamel}OrderBy(query))
3635
+ .limit(query.page.limit + 1);
3636
+ const pageRows = rows.slice(0, query.page.limit);
3637
+ const nextCursor =
3638
+ rows.length > query.page.limit && pageRows.length > 0
3639
+ ? cursorFor${names.singularPascal}(pageRows[pageRows.length - 1]!, query)
3640
+ : null;
3641
+
3642
+ return cursorPageResult(
3643
+ pageRows.map(to${names.singularPascal}),
3644
+ query.page,
3645
+ nextCursor,
3646
+ );
2597
3647
  },
2598
- async create(input: Create${names.singularPascal}Input) {
3648
+ async create(input) {
3649
+ const now = new Date().toISOString();
2599
3650
  const [row] = await db
2600
3651
  .insert(schema.${names.pluralCamel})
2601
3652
  .values({
2602
3653
  id: crypto.randomUUID(),
2603
- name: input.name,
2604
- createdAt: new Date().toISOString(),
3654
+ ${options.tenant ? `\t\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
3655
+ version: 1,
3656
+ createdAt: now,
3657
+ updatedAt: now,
2605
3658
  })
2606
3659
  .returning();
2607
3660
 
@@ -2611,17 +3664,20 @@ export function createDrizzle${names.singularPascal}Repository(
2611
3664
 
2612
3665
  return to${names.singularPascal}(row);
2613
3666
  },
3667
+ ${mode === "resource" ? ` async findById(id: string${options.tenant ? ", filter" : ""}) {\n const [row] = await db\n .select()\n .from(schema.${names.pluralCamel})\n .where(${findWhere})\n .limit(1);\n\n return row ? to${names.singularPascal}(row) : null;\n },\n async update(input) {\n const [row] = await db\n .update(schema.${names.pluralCamel})\n .set({\n name: input.name,\n version: input.version + 1,\n updatedAt: new Date().toISOString(),\n })\n .where(${updateWhere})\n .returning();\n\n return row ? to${names.singularPascal}(row) : null;\n },\n async delete(id: string${options.tenant ? ", filter" : ""}) {\n${options.softDelete ? `\t\t\tconst now = new Date().toISOString();\n\t\t\tconst rows = await db\n\t\t\t\t.update(schema.${names.pluralCamel})\n\t\t\t\t.set({ deletedAt: now, updatedAt: now })\n\t\t\t\t.where(${deleteWhere})\n\t\t\t\t.returning({ id: schema.${names.pluralCamel}.id });\n` : `\t\t\tconst rows = await db\n\t\t\t\t.delete(schema.${names.pluralCamel})\n\t\t\t\t.where(${deleteWhere})\n\t\t\t\t.returning({ id: schema.${names.pluralCamel}.id });\n`}\n return rows.length > 0;\n },\n` : ""}
2614
3668
  };
2615
3669
  }
2616
3670
  `;
2617
3671
  }
2618
- function contractFile(names, config) {
2619
- return `import { createContractGroup } from "@beignet/core/contracts";
3672
+ function contractFile(names, config, mode, options) {
3673
+ return `import { defineContractGroup } from "@beignet/core/contracts";
2620
3674
  import { z } from "zod";
2621
- import {
2622
- create${names.singularPascal}UseCase,
2623
- list${names.pluralPascal}UseCase,
2624
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
3675
+ ${mode === "resource" ? `import { errors } from "${aliasModule(resourceSharedErrorsPath(config))}";\n` : ""}import {
3676
+ Create${names.singularPascal}InputSchema,
3677
+ List${names.pluralPascal}InputSchema,
3678
+ List${names.pluralPascal}OutputSchema,
3679
+ ${names.singularPascal}Schema,
3680
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n Update${names.singularPascal}BodySchema,\n` : ""}} from "${aliasModule(resourceSchemaFilePath(names, config))}";
2625
3681
 
2626
3682
  const ErrorResponseSchema = z.object({
2627
3683
  code: z.string(),
@@ -2629,7 +3685,7 @@ const ErrorResponseSchema = z.object({
2629
3685
  requestId: z.string().optional(),
2630
3686
  });
2631
3687
 
2632
- const ${names.pluralCamel} = createContractGroup()
3688
+ const ${names.pluralCamel} = defineContractGroup()
2633
3689
  .namespace("${names.pluralCamel}")
2634
3690
  .responses({
2635
3691
  500: ErrorResponseSchema,
@@ -2637,21 +3693,49 @@ const ${names.pluralCamel} = createContractGroup()
2637
3693
 
2638
3694
  export const list${names.pluralPascal} = ${names.pluralCamel}
2639
3695
  .get("/api/${names.pluralKebab}")
2640
- .query(list${names.pluralPascal}UseCase.inputSchema)
3696
+ .query(List${names.pluralPascal}InputSchema)
2641
3697
  .responses({
2642
- 200: list${names.pluralPascal}UseCase.outputSchema,
3698
+ 200: List${names.pluralPascal}OutputSchema,
2643
3699
  });
2644
3700
 
2645
3701
  export const create${names.singularPascal} = ${names.pluralCamel}
2646
3702
  .post("/api/${names.pluralKebab}")
2647
- .body(create${names.singularPascal}UseCase.inputSchema)
3703
+ .body(Create${names.singularPascal}InputSchema)
3704
+ ${options.auth ? ` .meta({\n auth: "required",\n authorization: { ability: "${names.pluralCamel}.create" },\n })\n .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden })\n` : ""} .responses({
3705
+ 201: ${names.singularPascal}Schema,
3706
+ });
3707
+ ${mode === "resource"
3708
+ ? `
3709
+ export const get${names.singularPascal} = ${names.pluralCamel}
3710
+ .get("/api/${names.pluralKebab}/:id")
3711
+ .pathParams(${names.singularPascal}IdInputSchema)
3712
+ .errors({ ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound })
2648
3713
  .responses({
2649
- 201: create${names.singularPascal}UseCase.outputSchema,
3714
+ 200: ${names.singularPascal}Schema,
2650
3715
  });
3716
+
3717
+ export const update${names.singularPascal} = ${names.pluralCamel}
3718
+ .patch("/api/${names.pluralKebab}/:id")
3719
+ .pathParams(${names.singularPascal}IdInputSchema)
3720
+ .body(Update${names.singularPascal}BodySchema)
3721
+ ${options.auth ? `\t.meta({\n\t\tauth: "required",\n\t\tauthorization: { ability: "${names.pluralCamel}.update" },\n\t})\n` : ""} .errors({${options.auth ? " Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden," : ""} ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound, ${names.singularPascal}Conflict: errors.${names.singularPascal}Conflict })
3722
+ .responses({
3723
+ 200: ${names.singularPascal}Schema,
3724
+ });
3725
+
3726
+ export const delete${names.singularPascal} = ${names.pluralCamel}
3727
+ .delete("/api/${names.pluralKebab}/:id")
3728
+ .pathParams(${names.singularPascal}IdInputSchema)
3729
+ ${options.auth ? `\t.meta({\n\t\tauth: "required",\n\t\tauthorization: { ability: "${names.pluralCamel}.delete" },\n\t})\n` : ""} .errors({${options.auth ? " Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden," : ""} ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound })
3730
+ .responses({
3731
+ 204: null,
3732
+ });
3733
+ `
3734
+ : ""}
2651
3735
  `;
2652
3736
  }
2653
3737
  function standaloneContractFile(names) {
2654
- return `import { createContractGroup } from "@beignet/core/contracts";
3738
+ return `import { defineContractGroup } from "@beignet/core/contracts";
2655
3739
  import { z } from "zod";
2656
3740
 
2657
3741
  const ErrorResponseSchema = z.object({
@@ -2660,7 +3744,7 @@ const ErrorResponseSchema = z.object({
2660
3744
  requestId: z.string().optional(),
2661
3745
  });
2662
3746
 
2663
- const ${names.pluralCamel} = createContractGroup()
3747
+ const ${names.pluralCamel} = defineContractGroup()
2664
3748
  .namespace("${names.pluralCamel}")
2665
3749
  .responses({
2666
3750
  500: ErrorResponseSchema,
@@ -2696,7 +3780,8 @@ export const ${names.pluralCamel}Contracts = [list${names.pluralPascal}];
2696
3780
  `;
2697
3781
  }
2698
3782
  function standaloneUseCaseFile(names, config, filePath) {
2699
- return `import { z } from "zod";
3783
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3784
+ import { z } from "zod";
2700
3785
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2701
3786
 
2702
3787
  export const ${names.action.pascal}InputSchema = z.object({});
@@ -2724,29 +3809,27 @@ function useCaseTestFile(names, config) {
2724
3809
  return `import { describe, expect, it } from "bun:test";
2725
3810
  import { createUseCaseTester } from "@beignet/core/application";
2726
3811
  import { createInMemoryDevtools } from "@beignet/devtools";
2727
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
3812
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
3813
+ import { createTestAnonymousActor } from "@beignet/core/ports/testing";
2728
3814
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2729
3815
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
2730
3816
  import { ${names.exportName} } from "${relativeModule(filePath, useCaseFilePath(names, config))}";
2731
3817
 
2732
3818
  describe("${names.exportName}", () => {
2733
3819
  it("runs ${names.action.camel}", async () => {
2734
- const testPorts = {
2735
- ...appPorts,
2736
- uow: createNoopUnitOfWork(
2737
- () => appPorts as unknown as AppContext["ports"],
2738
- ) as unknown as AppContext["ports"]["uow"],
2739
- devtools: createInMemoryDevtools(),
2740
- storage: createMemoryStorage(),
2741
- } as AppContext["ports"];
2742
- const actor = createAnonymousActor();
2743
- const tester = createUseCaseTester<AppContext>(() => ({
2744
- requestId: "test-request",
2745
- actor,
2746
- auth: null,
2747
- gate: testPorts.gate.bind({ actor, auth: null }),
2748
- ports: testPorts,
2749
- }));
3820
+ const testFixture = createTestPorts<AppContext["ports"]>({
3821
+ base: appPorts,
3822
+ overrides: {
3823
+ gate: appPorts.gate,
3824
+ devtools: createInMemoryDevtools(),
3825
+ },
3826
+ });
3827
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
3828
+ ports: testFixture.ports,
3829
+ actor: createTestAnonymousActor(),
3830
+ tenant: null,
3831
+ });
3832
+ const tester = createUseCaseTester<AppContext>(createTestContext);
2750
3833
 
2751
3834
  const result = await tester.run(${names.exportName}, {});
2752
3835
 
@@ -2807,6 +3890,134 @@ export const ${names.policyName} = definePolicy({
2807
3890
  });
2808
3891
  `;
2809
3892
  }
3893
+ function resourcePolicyFile(names, _config, options) {
3894
+ return `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
3895
+ import { definePolicy, deny } from "@beignet/core/ports";
3896
+
3897
+ export type AuthorizationContext = {
3898
+ actor: ActivityActor;
3899
+ tenant?: ActivityTenant;
3900
+ };
3901
+
3902
+ type ${names.singularPascal}PolicyResource = {
3903
+ id: string;
3904
+ tenantId?: string;
3905
+ };
3906
+
3907
+ function isAuthenticated(ctx: AuthorizationContext) {
3908
+ return ctx.actor.type === "user" && Boolean(ctx.actor.id);
3909
+ }
3910
+
3911
+ function canWrite(ctx: AuthorizationContext) {
3912
+ if (!isAuthenticated(ctx)) return deny("You must be signed in.");
3913
+ ${options.tenant ? `\tif (!ctx.tenant?.id) return deny("A tenant is required.");\n` : ""} return true;
3914
+ }
3915
+
3916
+ export const ${names.singularCamel}Policy = definePolicy({
3917
+ "${names.pluralCamel}.view": (_ctx: AuthorizationContext) => true,
3918
+ "${names.pluralCamel}.create": (ctx: AuthorizationContext) =>
3919
+ canWrite(ctx),
3920
+ "${names.pluralCamel}.update": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
3921
+ const decision = canWrite(ctx);
3922
+ if (decision !== true) return decision;
3923
+ ${options.tenant ? `\t\tif (${names.singularCamel}.tenantId !== ctx.tenant?.id) {\n\t\t\treturn deny("You cannot update a ${names.singularKebab} from another tenant.");\n\t\t}\n` : ""}
3924
+ return true;
3925
+ },
3926
+ "${names.pluralCamel}.delete": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
3927
+ const decision = canWrite(ctx);
3928
+ if (decision !== true) return decision;
3929
+ ${options.tenant ? `\t\tif (${names.singularCamel}.tenantId !== ctx.tenant?.id) {\n\t\t\treturn deny("You cannot delete a ${names.singularKebab} from another tenant.");\n\t\t}\n` : ""}
3930
+ return true;
3931
+ },
3932
+ "${names.pluralCamel}.manage": (_ctx: AuthorizationContext) =>
3933
+ deny("You are not allowed to manage this ${names.singularKebab}."),
3934
+ });
3935
+ `;
3936
+ }
3937
+ function resourceEventsFile(names) {
3938
+ return `import { defineEvent } from "@beignet/core/events";
3939
+ import { z } from "zod";
3940
+
3941
+ export const ${names.singularPascal}Created = defineEvent("${names.pluralCamel}.created", {
3942
+ payload: z.object({
3943
+ id: z.string().uuid(),
3944
+ }),
3945
+ });
3946
+
3947
+ export const ${names.singularPascal}Updated = defineEvent("${names.pluralCamel}.updated", {
3948
+ payload: z.object({
3949
+ id: z.string().uuid(),
3950
+ }),
3951
+ });
3952
+
3953
+ export const ${names.singularPascal}Deleted = defineEvent("${names.pluralCamel}.deleted", {
3954
+ payload: z.object({
3955
+ id: z.string().uuid(),
3956
+ }),
3957
+ });
3958
+
3959
+ export const ${names.singularCamel}Events = [
3960
+ ${names.singularPascal}Created,
3961
+ ${names.singularPascal}Updated,
3962
+ ${names.singularPascal}Deleted,
3963
+ ] as const;
3964
+ `;
3965
+ }
3966
+ function resourcePolicyTestFile(names, options) {
3967
+ return `import { describe, expect, it } from "bun:test";
3968
+ import { createPolicyTester } from "@beignet/core/ports/testing";
3969
+ import { ${names.singularCamel}Policy } from "../policy";
3970
+ import type { ${names.singularPascal} } from "../schemas";
3971
+
3972
+ function create${names.singularPascal}(): ${names.singularPascal} {
3973
+ const now = new Date().toISOString();
3974
+ return {
3975
+ id: "00000000-0000-4000-8000-000000000001",
3976
+ ${options.tenant ? `\t\ttenantId: "tenant_test",\n` : ""} name: "Test ${names.singularPascal}",
3977
+ version: 1,
3978
+ createdAt: now,
3979
+ updatedAt: now,
3980
+ };
3981
+ }
3982
+
3983
+ describe("${names.singularCamel}Policy", () => {
3984
+ it("documents generated ${names.pluralKebab} authorization rules", async () => {
3985
+ const tester = createPolicyTester({
3986
+ policies: [${names.singularCamel}Policy],
3987
+ });
3988
+ const ${names.singularCamel} = create${names.singularPascal}();
3989
+ const ctx = {
3990
+ actor: { type: "user" as const, id: "user_test" },
3991
+ ${options.tenant ? `\t\t\ttenant: { id: "tenant_test" },\n` : ""} };
3992
+
3993
+ await expect(
3994
+ tester.assertMatrix([
3995
+ {
3996
+ name: "authenticated actor can create ${names.singularKebab}",
3997
+ ctx,
3998
+ ability: "${names.pluralCamel}.create",
3999
+ expected: "allow",
4000
+ },
4001
+ {
4002
+ name: "authenticated actor can update ${names.singularKebab}",
4003
+ ctx,
4004
+ ability: "${names.pluralCamel}.update",
4005
+ subject: ${names.singularCamel},
4006
+ expected: "allow",
4007
+ },
4008
+ {
4009
+ name: "authenticated actor can delete ${names.singularKebab}",
4010
+ ctx,
4011
+ ability: "${names.pluralCamel}.delete",
4012
+ subject: ${names.singularCamel},
4013
+ expected: "allow",
4014
+ },
4015
+ ]),
4016
+ ).resolves.toBeUndefined();
4017
+ });
4018
+ });
4019
+ `;
4020
+ }
2810
4021
  function eventFile(names) {
2811
4022
  return `import { defineEvent } from "@beignet/core/events";
2812
4023
  import { z } from "zod";
@@ -2823,11 +4034,9 @@ export const ${names.eventExportName} = defineEvent("${names.eventName}", {
2823
4034
  `;
2824
4035
  }
2825
4036
  function jobFile(names, config) {
2826
- return `import { createJobHandlers, retry } from "@beignet/core/jobs";
4037
+ return `import { retry } from "@beignet/core/jobs";
2827
4038
  import { z } from "zod";
2828
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2829
-
2830
- const jobs = createJobHandlers<AppContext>();
4039
+ import { defineJob } from "${aliasModule(config.paths.jobsBuilder)}";
2831
4040
 
2832
4041
  export const ${names.payloadSchemaName} = z.object({
2833
4042
  \tid: z.string().uuid(),
@@ -2835,7 +4044,7 @@ export const ${names.payloadSchemaName} = z.object({
2835
4044
 
2836
4045
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2837
4046
 
2838
- export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
4047
+ export const ${names.jobExportName} = defineJob("${names.jobName}", {
2839
4048
  \tpayload: ${names.payloadSchemaName},
2840
4049
  \tretry: retry.exponential({
2841
4050
  \t\tattempts: 3,
@@ -2851,12 +4060,38 @@ export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
2851
4060
  });
2852
4061
  `;
2853
4062
  }
4063
+ function taskFile(names, config) {
4064
+ return `import { z } from "zod";
4065
+ import { defineTask } from "${aliasModule(config.paths.tasksBuilder)}";
4066
+
4067
+ export const ${names.inputSchemaName} = z.object({
4068
+ \tdryRun: z.boolean().default(true),
4069
+ });
4070
+
4071
+ export type ${names.inputTypeName} = z.infer<typeof ${names.inputSchemaName}>;
4072
+
4073
+ export const ${names.taskExportName} = defineTask("${names.taskName}", {
4074
+ \tinput: ${names.inputSchemaName},
4075
+ \tdescription: "Operational ${names.feature.kebab} ${names.artifact.kebab} task.",
4076
+ \tasync handle({ input, ctx }) {
4077
+ \t\tctx.ports.logger.info("Task handled", {
4078
+ \t\t\ttaskName: "${names.taskName}",
4079
+ \t\t\tdryRun: input.dryRun,
4080
+ \t\t});
4081
+
4082
+ \t\treturn {
4083
+ \t\t\tdryRun: input.dryRun,
4084
+ \t\t};
4085
+ \t},
4086
+ });
4087
+ `;
4088
+ }
2854
4089
  function factoryFile(names, config) {
2855
4090
  const resource = featureResourceNames(names);
2856
- return `import { defineFactory } from "@beignet/core/testing";
4091
+ return `import { createFactory } from "@beignet/core/testing";
2857
4092
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2858
4093
 
2859
- export const ${names.factoryExportName} = defineFactory("${names.factoryName}", {
4094
+ export const ${names.factoryExportName} = createFactory("${names.factoryName}", {
2860
4095
  \tdefaults: ({ sequence }) => ({
2861
4096
  \t\tname: "${resource.singularPascal} " + sequence,
2862
4097
  \t}),
@@ -2880,14 +4115,9 @@ export const ${names.seedExportName} = defineSeed("${names.seedName}", {
2880
4115
  `;
2881
4116
  }
2882
4117
  function notificationFile(names, config) {
2883
- return `import {
2884
- \tcreateNotificationHandlers,
2885
- \tdefineMailNotificationChannel,
2886
- } from "@beignet/core/notifications";
4118
+ return `import { defineMailNotificationChannel } from "@beignet/core/notifications";
2887
4119
  import { z } from "zod";
2888
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2889
-
2890
- const notifications = createNotificationHandlers<AppContext>();
4120
+ import { defineNotification } from "${aliasModule(config.paths.notificationsBuilder)}";
2891
4121
 
2892
4122
  export const ${names.payloadSchemaName} = z.object({
2893
4123
  \tid: z.string().uuid(),
@@ -2897,7 +4127,7 @@ export const ${names.payloadSchemaName} = z.object({
2897
4127
 
2898
4128
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2899
4129
 
2900
- export const ${names.notificationExportName} = notifications.defineNotification(
4130
+ export const ${names.notificationExportName} = defineNotification(
2901
4131
  \t"${names.notificationName}",
2902
4132
  \t{
2903
4133
  \t\tpayload: ${names.payloadSchemaName},
@@ -2914,13 +4144,10 @@ export const ${names.notificationExportName} = notifications.defineNotification(
2914
4144
  }
2915
4145
  function listenerFile(names, config) {
2916
4146
  const filePath = listenerFilePath(names, config);
2917
- return `import { createEventHandlers } from "@beignet/core/events";
2918
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4147
+ return `import { defineListener } from "${aliasModule(config.paths.listenersBuilder)}";
2919
4148
  import { ${names.event.eventExportName} } from "${relativeModule(filePath, eventFilePath(names.event, config))}";
2920
4149
 
2921
- const events = createEventHandlers<AppContext>();
2922
-
2923
- export const ${names.listenerExportName} = events.defineListener(${names.event.eventExportName}, {
4150
+ export const ${names.listenerExportName} = defineListener(${names.event.eventExportName}, {
2924
4151
  \tname: "${names.listenerName}",
2925
4152
  \tasync handle({ payload, ctx }) {
2926
4153
  \t\tctx.ports.logger.info("Listener handled", {
@@ -2932,11 +4159,8 @@ export const ${names.listenerExportName} = events.defineListener(${names.event.e
2932
4159
  `;
2933
4160
  }
2934
4161
  function scheduleFile(names, config) {
2935
- return `import { createScheduleHandlers } from "@beignet/core/schedules";
2936
- import { z } from "zod";
2937
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2938
-
2939
- const schedules = createScheduleHandlers<AppContext>();
4162
+ return `import { z } from "zod";
4163
+ import { defineSchedule } from "${aliasModule(config.paths.schedulesBuilder)}";
2940
4164
 
2941
4165
  export const ${names.payloadSchemaName} = z.object({
2942
4166
  \tdate: z.string(),
@@ -2944,7 +4168,7 @@ export const ${names.payloadSchemaName} = z.object({
2944
4168
 
2945
4169
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2946
4170
 
2947
- export const ${names.scheduleExportName} = schedules.defineSchedule(
4171
+ export const ${names.scheduleExportName} = defineSchedule(
2948
4172
  \t"${names.scheduleName}",
2949
4173
  \t{
2950
4174
  \t\tcron: "${names.cron}",
@@ -3020,108 +4244,110 @@ export const ${names.uploadExportName} = defineUpload<
3020
4244
  });
3021
4245
  `;
3022
4246
  }
3023
- function scheduleRouteFile(names, config) {
3024
- return `import { createInlineScheduleRunner } from "@beignet/core/schedules";
3025
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3026
- import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
3027
- import { env } from "@/lib/env";
3028
- import { server } from "${aliasModule(config.paths.server)}";
4247
+ function featureUiComponentFile(names, config) {
4248
+ return `"use client";
3029
4249
 
3030
- async function run${names.artifact.pascal}(request: Request) {
3031
- \tconst cronSecret = env.CRON_SECRET;
4250
+ import { contractErrorMessage } from "@beignet/core/client";
4251
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4252
+ import { useState } from "react";
4253
+ import { rq } from "${aliasModule(clientIndexPath(config))}";
4254
+ import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
3032
4255
 
3033
- \tif (!cronSecret) {
3034
- \t\treturn Response.json(
3035
- \t\t\t{
3036
- \t\t\t\tok: false,
3037
- \t\t\t\terror: "CRON_SECRET is not configured.",
3038
- \t\t\t\tscheduleName: ${names.scheduleExportName}.name,
4256
+ export function ${names.componentExportName}() {
4257
+ \tconst [name, setName] = useState("");
4258
+ \tconst queryClient = useQueryClient();
4259
+ \tconst ${names.pluralCamel}Query = useQuery(
4260
+ \t\trq(list${names.pluralPascal}).queryOptions({ query: {} }),
4261
+ \t);
4262
+ \tconst create${names.singularPascal}Mutation = useMutation(
4263
+ \t\trq(create${names.singularPascal}).mutationOptions({
4264
+ \t\t\tonSuccess: async () => {
4265
+ \t\t\t\tsetName("");
4266
+ \t\t\t\tawait rq(list${names.pluralPascal}).invalidate(queryClient);
3039
4267
  \t\t\t},
3040
- \t\t\t{ status: 500 },
3041
- \t\t);
3042
- \t}
3043
-
3044
- \tif (
3045
- \t\trequest.headers.get("authorization") !== \`Bearer \${cronSecret}\`
3046
- \t) {
3047
- \t\treturn Response.json({ error: "Unauthorized" }, { status: 401 });
3048
- \t}
3049
-
3050
- \tconst ctx = await server.createContextFromNext();
3051
- \tconst runner = createInlineScheduleRunner<AppContext>({
3052
- \t\tctx,
3053
- \t\tonStart({ run, schedule }) {
3054
- \t\t\tctx.ports.devtools.record({
3055
- \t\t\t\ttype: "schedule",
3056
- \t\t\t\twatcher: "schedules",
3057
- \t\t\t\trequestId: ctx.requestId,
3058
- \t\t\t\tscheduleName: schedule.name,
3059
- \t\t\t\tstatus: "started",
3060
- \t\t\t\tcron: schedule.cron,
3061
- \t\t\t\ttimezone: schedule.timezone,
3062
- \t\t\t\tdetails: {
3063
- \t\t\t\t\tsource: run.source,
3064
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3065
- \t\t\t\t},
3066
- \t\t\t});
3067
- \t\t},
3068
- \t\tonSuccess({ run, schedule }) {
3069
- \t\t\tctx.ports.devtools.record({
3070
- \t\t\t\ttype: "schedule",
3071
- \t\t\t\twatcher: "schedules",
3072
- \t\t\t\trequestId: ctx.requestId,
3073
- \t\t\t\tscheduleName: schedule.name,
3074
- \t\t\t\tstatus: "completed",
3075
- \t\t\t\tcron: schedule.cron,
3076
- \t\t\t\ttimezone: schedule.timezone,
3077
- \t\t\t\tdetails: {
3078
- \t\t\t\t\tsource: run.source,
3079
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3080
- \t\t\t\t},
3081
- \t\t\t});
3082
- \t\t},
3083
- \t\tonError({ error, run, schedule }) {
3084
- \t\t\tctx.ports.devtools.record({
3085
- \t\t\t\ttype: "schedule",
3086
- \t\t\t\twatcher: "schedules",
3087
- \t\t\t\trequestId: ctx.requestId,
3088
- \t\t\t\tscheduleName: schedule.name,
3089
- \t\t\t\tstatus: "failed",
3090
- \t\t\t\tcron: schedule.cron,
3091
- \t\t\t\ttimezone: schedule.timezone,
3092
- \t\t\t\tdetails: {
3093
- \t\t\t\t\terror,
3094
- \t\t\t\t\tsource: run.source,
3095
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3096
- \t\t\t\t},
3097
- \t\t\t});
3098
- \t\t\tctx.ports.logger.error("Schedule failed", {
3099
- \t\t\t\terror,
3100
- \t\t\t\tscheduleName: schedule.name,
3101
- \t\t\t});
3102
- \t\t},
3103
- \t\tonHookError({ error, hook, schedule }) {
3104
- \t\t\tctx.ports.logger.warn("Schedule lifecycle hook failed", {
3105
- \t\t\t\terror,
3106
- \t\t\t\thook,
3107
- \t\t\t\tscheduleName: schedule.name,
3108
- \t\t\t});
3109
- \t\t},
3110
- \t});
4268
+ \t\t}),
4269
+ \t);
3111
4270
 
3112
- \ttry {
3113
- \t\tawait runner.run(${names.scheduleExportName}, {
3114
- \t\t\tsource: "cron-route",
3115
- \t\t});
3116
- \t} catch {
3117
- \t\treturn Response.json({ error: "Schedule failed" }, { status: 500 });
3118
- \t}
4271
+ \treturn (
4272
+ \t\t<section className="${names.pluralKebab}-panel">
4273
+ \t\t\t<form
4274
+ \t\t\t\tclassName="${names.pluralKebab}-composer"
4275
+ \t\t\t\tonSubmit={(event) => {
4276
+ \t\t\t\t\tevent.preventDefault();
4277
+ \t\t\t\t\tconst trimmedName = name.trim();
4278
+ \t\t\t\t\tif (!trimmedName) return;
4279
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.reset();
4280
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.mutate({
4281
+ \t\t\t\t\t\tbody: { name: trimmedName },
4282
+ \t\t\t\t\t});
4283
+ \t\t\t\t}}
4284
+ \t\t\t>
4285
+ \t\t\t\t<label htmlFor="${names.singularKebab}-name">New ${names.singularKebab}</label>
4286
+ \t\t\t\t<div className="${names.pluralKebab}-composer-row">
4287
+ \t\t\t\t\t<input
4288
+ \t\t\t\t\t\tid="${names.singularKebab}-name"
4289
+ \t\t\t\t\t\tvalue={name}
4290
+ \t\t\t\t\t\tonChange={(event) => setName(event.currentTarget.value)}
4291
+ \t\t\t\t\t\tplaceholder="Name"
4292
+ \t\t\t\t\t/>
4293
+ \t\t\t\t\t<button
4294
+ \t\t\t\t\t\ttype="submit"
4295
+ \t\t\t\t\t\tdisabled={!name.trim() || create${names.singularPascal}Mutation.isPending}
4296
+ \t\t\t\t\t>
4297
+ \t\t\t\t\t\t{create${names.singularPascal}Mutation.isPending ? "Creating" : "Create"}
4298
+ \t\t\t\t\t</button>
4299
+ \t\t\t\t</div>
4300
+ \t\t\t\t{create${names.singularPascal}Mutation.isError ? (
4301
+ \t\t\t\t\t<p role="alert">
4302
+ \t\t\t\t\t\t{contractErrorMessage(
4303
+ \t\t\t\t\t\t\tcreate${names.singularPascal}Mutation.error,
4304
+ \t\t\t\t\t\t\t"Could not create ${names.singularKebab}.",
4305
+ \t\t\t\t\t\t)}
4306
+ \t\t\t\t\t</p>
4307
+ \t\t\t\t) : null}
4308
+ \t\t\t</form>
3119
4309
 
3120
- \treturn Response.json({ ok: true });
4310
+ \t\t\t<div className="${names.pluralKebab}-list">
4311
+ \t\t\t\t<div className="${names.pluralKebab}-list-heading">
4312
+ \t\t\t\t\t<h2>${names.pluralPascal}</h2>
4313
+ \t\t\t\t\t<span>{${names.pluralCamel}Query.data?.items.length ?? 0} shown</span>
4314
+ \t\t\t\t</div>
4315
+ \t\t\t\t{${names.pluralCamel}Query.isLoading ? <p>Loading...</p> : null}
4316
+ \t\t\t\t{${names.pluralCamel}Query.isError ? (
4317
+ \t\t\t\t\t<p role="alert">Could not load ${names.pluralKebab}.</p>
4318
+ \t\t\t\t) : null}
4319
+ \t\t\t\t{${names.pluralCamel}Query.data?.items.length === 0 ? (
4320
+ \t\t\t\t\t<p>No ${names.pluralKebab} yet.</p>
4321
+ \t\t\t\t) : null}
4322
+ \t\t\t\t<ul>
4323
+ \t\t\t\t\t{${names.pluralCamel}Query.data?.items.map((${names.singularCamel}) => (
4324
+ \t\t\t\t\t\t<li key={${names.singularCamel}.id}>
4325
+ \t\t\t\t\t\t\t<strong>{${names.singularCamel}.name}</strong>
4326
+ \t\t\t\t\t\t\t<small>{new Date(${names.singularCamel}.createdAt).toLocaleDateString()}</small>
4327
+ \t\t\t\t\t\t</li>
4328
+ \t\t\t\t\t))}
4329
+ \t\t\t\t</ul>
4330
+ \t\t\t</div>
4331
+ \t\t</section>
4332
+ \t);
4333
+ }
4334
+ `;
3121
4335
  }
4336
+ function scheduleRouteFile(names, config) {
4337
+ return `import { createScheduleRoute } from "@beignet/next";
4338
+ import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
4339
+ import { env } from "@/lib/env";
4340
+ import { server } from "${aliasModule(config.paths.server)}";
3122
4341
 
3123
- export const GET = run${names.artifact.pascal};
3124
- export const POST = run${names.artifact.pascal};
4342
+ export const runtime = "nodejs";
4343
+
4344
+ export const { GET, POST } = createScheduleRoute({
4345
+ \tserver,
4346
+ \tschedules: [${names.scheduleExportName}],
4347
+ \tschedule: ${names.scheduleExportName}.name,
4348
+ \tsecret: env.CRON_SECRET,
4349
+ \tsource: "cron-route",
4350
+ });
3125
4351
  `;
3126
4352
  }
3127
4353
  function useCaseIndexExport(names) {
@@ -3134,78 +4360,138 @@ function useCaseIndexExport(names) {
3134
4360
  } from "./${names.action.kebab}";
3135
4361
  `;
3136
4362
  }
3137
- function routeGroupFile(names, config) {
3138
- return `import { defineRouteGroup } from "@beignet/next";
3139
- import type { AppContext } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), config.paths.appContext)}";
3140
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceContractFilePath(names, config))}";
4363
+ function routeGroupFile(names, config, mode, _options) {
4364
+ const routeFilePath = path.join(config.paths.features, names.pluralKebab, "routes.ts");
4365
+ const contractsPath = resourceContractFilePath(names, config);
4366
+ const useCasesPath = resourceUseCaseIndexPath(names, config);
4367
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
4368
+ import { defineRouteGroup } from "@beignet/next";
4369
+ import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
4370
+ import {
4371
+ create${names.singularPascal},
4372
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
4373
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${relativeModule(routeFilePath, contractsPath)}";
3141
4374
  import {
3142
4375
  create${names.singularPascal}UseCase,
3143
- list${names.pluralPascal}UseCase,
3144
- } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceUseCaseIndexPath(names, config))}";
4376
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
4377
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${relativeModule(routeFilePath, useCasesPath)}";
3145
4378
 
3146
4379
  export const ${names.singularCamel}Routes = defineRouteGroup<AppContext>({
3147
4380
  name: "${names.pluralKebab}",
3148
4381
  routes: [
3149
- {
3150
- contract: list${names.pluralPascal},
3151
- handle: async ({ ctx, query }) => ({
3152
- status: 200,
3153
- body: await list${names.pluralPascal}UseCase.run({ ctx, input: query }),
3154
- }),
3155
- },
3156
- {
3157
- contract: create${names.singularPascal},
3158
- handle: async ({ ctx, body }) => ({
3159
- status: 201,
3160
- body: await create${names.singularPascal}UseCase.run({ ctx, input: body }),
3161
- }),
3162
- },
3163
- ],
4382
+ { contract: list${names.pluralPascal}, useCase: list${names.pluralPascal}UseCase },
4383
+ { contract: create${names.singularPascal}, useCase: create${names.singularPascal}UseCase },
4384
+ ${mode === "resource" ? ` { contract: get${names.singularPascal}, useCase: get${names.singularPascal}UseCase },\n { contract: update${names.singularPascal}, useCase: update${names.singularPascal}UseCase },\n { contract: delete${names.singularPascal}, useCase: delete${names.singularPascal}UseCase },\n` : ""} ],
3164
4385
  });
3165
4386
  `;
3166
4387
  }
3167
- function testFile(names, config) {
4388
+ function testFile(names, config, mode, options) {
3168
4389
  const repositoryPath = path.join(path.dirname(config.paths.infrastructurePorts), names.pluralKebab, `in-memory-${names.singularKebab}-repository.ts`);
4390
+ const serverContextPath = path.join(path.dirname(config.paths.server), "context.ts");
4391
+ const requestTarget = options.tenant ? "requester" : "app";
3169
4392
  return `import { describe, expect, it } from "bun:test";
3170
4393
  import { createUseCaseTester } from "@beignet/core/application";
3171
- import { defineRoutes } from "@beignet/web";
3172
- import { createTestApp } from "@beignet/web/testing";
4394
+ ${mode === "resource" ? `import { isAppError } from "@beignet/core/errors";\n` : ""}import { defineRoutes } from "@beignet/web";
4395
+ import { createTestApp${options.tenant ? ", createTestRequester" : ""} } from "@beignet/web/testing";
3173
4396
  import { createInMemoryDevtools } from "@beignet/devtools";
3174
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
4397
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
4398
+ import { ${options.auth ? "createTestUserActor" : "createTestAnonymousActor"}${options.tenant ? ", createTestTenant" : ""} } from "@beignet/core/ports/testing";
3175
4399
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3176
4400
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
3177
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
4401
+ import { appContext } from "${aliasModule(serverContextPath)}";
4402
+ import {
4403
+ create${names.singularPascal},
4404
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
4405
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${aliasModule(resourceContractFilePath(names, config))}";
3178
4406
  import { createInMemory${names.singularPascal}Repository } from "${aliasModule(repositoryPath)}";
3179
4407
  import { ${names.singularCamel}Routes } from "${aliasModule(path.join(resourceFeatureDir(names, config), "routes.ts"))}";
3180
4408
  import {
3181
4409
  create${names.singularPascal}UseCase,
3182
- list${names.pluralPascal}UseCase,
3183
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
4410
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
4411
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
3184
4412
 
3185
4413
  describe("${names.pluralCamel} resource", () => {
3186
- it("creates and lists ${names.pluralCamel}", async () => {
3187
- const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();
3188
- const testPorts = {
3189
- ...appPorts,
3190
- ${names.pluralCamel},
3191
- uow: createNoopUnitOfWork(() => ({
3192
- ...(appPorts as unknown as AppContext["ports"]),
4414
+ it("${mode === "resource" ? `exercises ${names.pluralCamel} CRUD` : `creates and lists ${names.pluralCamel}`}", async () => {
4415
+ const seed${names.pluralPascal} = [
4416
+ {
4417
+ id: "00000000-0000-4000-8000-000000000101",
4418
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Alpha ${names.singularPascal}",
4419
+ version: 1,
4420
+ createdAt: "2024-01-01T00:00:00.000Z",
4421
+ updatedAt: "2024-01-01T00:00:00.000Z",
4422
+ },
4423
+ {
4424
+ id: "00000000-0000-4000-8000-000000000102",
4425
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Beta ${names.singularPascal}",
4426
+ version: 1,
4427
+ createdAt: "2024-01-02T00:00:00.000Z",
4428
+ updatedAt: "2024-01-02T00:00:00.000Z",
4429
+ },
4430
+ {
4431
+ id: "00000000-0000-4000-8000-000000000103",
4432
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Gamma ${names.singularPascal}",
4433
+ version: 1,
4434
+ createdAt: "2024-01-03T00:00:00.000Z",
4435
+ updatedAt: "2024-01-03T00:00:00.000Z",
4436
+ },
4437
+ ${options.tenant ? `\t\t\t{\n\t\t\t\tid: "00000000-0000-4000-8000-000000000104",\n\t\t\t\ttenantId: "tenant_other",\n\t\t\t\tname: "Other Tenant ${names.singularPascal}",\n\t\t\t\tversion: 1,\n\t\t\t\tcreatedAt: "2024-01-04T00:00:00.000Z",\n\t\t\t\tupdatedAt: "2024-01-04T00:00:00.000Z",\n\t\t\t},\n` : ""} ];
4438
+ const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository(seed${names.pluralPascal});
4439
+ const testFixture = createTestPorts<AppContext["ports"]>({
4440
+ base: appPorts,
4441
+ overrides: {
3193
4442
  ${names.pluralCamel},
3194
- })) as unknown as AppContext["ports"]["uow"],
3195
- devtools: createInMemoryDevtools(),
3196
- storage: createMemoryStorage(),
3197
- } as AppContext["ports"];
3198
- const actor = createAnonymousActor();
3199
- const createTestContext = () => ({
3200
- requestId: "test-request",
3201
- actor,
3202
- auth: null,
3203
- gate: testPorts.gate.bind({ actor, auth: null }),
3204
- ports: testPorts,
4443
+ ${options.auth
4444
+ ? `\t\t\t\tauth: {
4445
+ getSession: async () => ({
4446
+ user: { id: "user_test", name: "Test User" },
4447
+ }),
4448
+ },
4449
+ gate: appPorts.gate,\n`
4450
+ : `\t\t\t\tauth: {
4451
+ getSession: async () => null,
4452
+ },\n`} devtools: createInMemoryDevtools(),
4453
+ },
4454
+ transaction: {
4455
+ ports: (ports) => ({
4456
+ ...ports,
4457
+ ${names.pluralCamel},
4458
+ }),
4459
+ },
4460
+ });
4461
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
4462
+ ports: testFixture.ports,
4463
+ actor: ${options.auth ? `createTestUserActor("user_test")` : "createTestAnonymousActor()"},
4464
+ tenant: ${options.tenant ? `createTestTenant("tenant_test")` : "null"},
3205
4465
  });
3206
4466
  const tester = createUseCaseTester<AppContext>(createTestContext);
3207
4467
 
3208
4468
  const ctx = await tester.ctx();
4469
+ const defaultPage = await tester.run(list${names.pluralPascal}UseCase, {}, { ctx });
4470
+ const filteredPage = await tester.run(
4471
+ list${names.pluralPascal}UseCase,
4472
+ { name: "alp" },
4473
+ { ctx },
4474
+ );
4475
+ const firstPage = await tester.run(
4476
+ list${names.pluralPascal}UseCase,
4477
+ { limit: 2 },
4478
+ { ctx },
4479
+ );
4480
+ const secondPage = await tester.run(
4481
+ list${names.pluralPascal}UseCase,
4482
+ { limit: 2, cursor: firstPage.page.nextCursor },
4483
+ { ctx },
4484
+ );
4485
+ const nameAscendingPage = await tester.run(
4486
+ list${names.pluralPascal}UseCase,
4487
+ { sortBy: "name", sortDirection: "asc" },
4488
+ { ctx },
4489
+ );
4490
+ const nameDescendingPage = await tester.run(
4491
+ list${names.pluralPascal}UseCase,
4492
+ { sortBy: "name", sortDirection: "desc" },
4493
+ { ctx },
4494
+ );
3209
4495
  const created = await tester.run(
3210
4496
  create${names.singularPascal}UseCase,
3211
4497
  { name: "First ${names.singularPascal}" },
@@ -3213,35 +4499,111 @@ describe("${names.pluralCamel} resource", () => {
3213
4499
  );
3214
4500
  const result = await tester.run(
3215
4501
  list${names.pluralPascal}UseCase,
3216
- { limit: 20, offset: 0 },
4502
+ { name: "First" },
3217
4503
  { ctx },
3218
4504
  );
3219
4505
 
4506
+ expect(defaultPage.page).toMatchObject({
4507
+ kind: "cursor",
4508
+ limit: 20,
4509
+ cursor: null,
4510
+ nextCursor: null,
4511
+ hasMore: false,
4512
+ });
4513
+ expect(defaultPage.items.map((item) => item.name)).toEqual([
4514
+ "Gamma ${names.singularPascal}",
4515
+ "Beta ${names.singularPascal}",
4516
+ "Alpha ${names.singularPascal}",
4517
+ ]);
4518
+ expect(filteredPage.items.map((item) => item.name)).toEqual([
4519
+ "Alpha ${names.singularPascal}",
4520
+ ]);
4521
+ expect(firstPage.items.map((item) => item.name)).toEqual([
4522
+ "Gamma ${names.singularPascal}",
4523
+ "Beta ${names.singularPascal}",
4524
+ ]);
4525
+ expect(firstPage.page.nextCursor).toEqual(expect.any(String));
4526
+ expect(secondPage.items.map((item) => item.name)).toEqual([
4527
+ "Alpha ${names.singularPascal}",
4528
+ ]);
4529
+ expect(secondPage.page.nextCursor).toBeNull();
4530
+ expect(nameAscendingPage.items.map((item) => item.name)).toEqual([
4531
+ "Alpha ${names.singularPascal}",
4532
+ "Beta ${names.singularPascal}",
4533
+ "Gamma ${names.singularPascal}",
4534
+ ]);
4535
+ expect(nameDescendingPage.items.map((item) => item.name)).toEqual([
4536
+ "Gamma ${names.singularPascal}",
4537
+ "Beta ${names.singularPascal}",
4538
+ "Alpha ${names.singularPascal}",
4539
+ ]);
3220
4540
  expect(created.name).toBe("First ${names.singularPascal}");
3221
- expect(result.page.total).toBe(1);
3222
4541
  expect(result.items).toEqual([created]);
4542
+ ${mode === "resource"
4543
+ ? `
4544
+ const fetched = await tester.run(
4545
+ get${names.singularPascal}UseCase,
4546
+ { id: created.id },
4547
+ { ctx },
4548
+ );
4549
+ const updated = await tester.run(
4550
+ update${names.singularPascal}UseCase,
4551
+ { id: created.id, name: "Updated ${names.singularPascal}", version: fetched.version },
4552
+ { ctx },
4553
+ );
4554
+ try {
4555
+ await tester.run(
4556
+ update${names.singularPascal}UseCase,
4557
+ { id: created.id, name: "Stale ${names.singularPascal}", version: fetched.version },
4558
+ { ctx },
4559
+ );
4560
+ throw new Error("Expected ${names.singularPascal}Conflict");
4561
+ } catch (error) {
4562
+ expect(isAppError(error)).toBe(true);
4563
+ if (isAppError(error)) {
4564
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_CONFLICT");
4565
+ }
4566
+ }
4567
+ await tester.run(delete${names.singularPascal}UseCase, { id: created.id }, { ctx });
4568
+ const afterDelete = await tester.run(
4569
+ list${names.pluralPascal}UseCase,
4570
+ { name: "Updated" },
4571
+ { ctx },
4572
+ );
3223
4573
 
3224
- const app = await createTestApp<AppContext, AppContext["ports"]>({
3225
- ports: testPorts,
4574
+ expect(fetched).toEqual(created);
4575
+ expect(updated.name).toBe("Updated ${names.singularPascal}");
4576
+ expect(updated.version).toBe(fetched.version + 1);
4577
+ expect(afterDelete.items).toEqual([]);
4578
+
4579
+ try {
4580
+ await tester.run(get${names.singularPascal}UseCase, { id: created.id }, { ctx });
4581
+ throw new Error("Expected ${names.singularPascal}NotFound");
4582
+ } catch (error) {
4583
+ expect(isAppError(error)).toBe(true);
4584
+ if (isAppError(error)) {
4585
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_NOT_FOUND");
4586
+ }
4587
+ }
4588
+ `
4589
+ : ""}
4590
+
4591
+ const app = await createTestApp({
4592
+ ports: testFixture.ports,
3226
4593
  routes: defineRoutes<AppContext>([${names.singularCamel}Routes]),
3227
- createContext: async () => createTestContext(),
3228
- mapUnhandledError: () => ({
3229
- status: 500,
3230
- body: { code: "INTERNAL_SERVER_ERROR", message: "Internal server error" },
3231
- }),
4594
+ context: appContext,
3232
4595
  });
3233
-
3234
- const createdViaRoute = await app.request(create${names.singularPascal}, {
4596
+ ${options.tenant ? `\t\tconst requester = createTestRequester(app, {\n\t\t\theaders: { "x-tenant-id": "tenant_test" },\n\t\t});\n` : ""}
4597
+ const createdViaRoute = await ${requestTarget}.request(create${names.singularPascal}, {
3235
4598
  body: { name: "Second ${names.singularPascal}" },
3236
4599
  });
3237
- const listedViaRoute = await app.request(list${names.pluralPascal}, {
3238
- query: { limit: 20, offset: 0 },
4600
+ ${mode === "resource" ? ` const fetchedViaRoute = await ${requestTarget}.request(get${names.singularPascal}, {\n path: { id: createdViaRoute.id },\n });\n const updatedViaRoute = await ${requestTarget}.request(update${names.singularPascal}, {\n path: { id: createdViaRoute.id },\n body: { name: "Updated via route", version: fetchedViaRoute.version },\n });\n` : ""} const listedViaRoute = await ${requestTarget}.request(list${names.pluralPascal}, {
4601
+ query: { name: "${mode === "resource" ? "Updated via route" : "Second"}" },
3239
4602
  });
3240
- await app.stop();
4603
+ ${mode === "resource" ? ` const deletedViaRoute = await ${requestTarget}.request(delete${names.singularPascal}, {\n path: { id: createdViaRoute.id },\n });\n const listedAfterDeleteViaRoute = await ${requestTarget}.request(list${names.pluralPascal}, {\n query: { name: "Updated via route" },\n });\n` : ""} await app.stop();
3241
4604
 
3242
4605
  expect(createdViaRoute.name).toBe("Second ${names.singularPascal}");
3243
- expect(listedViaRoute.page.total).toBe(2);
3244
- expect(listedViaRoute.items).toContainEqual(createdViaRoute);
4606
+ ${mode === "resource" ? ` expect(fetchedViaRoute).toEqual(createdViaRoute);\n expect(updatedViaRoute.name).toBe("Updated via route");\n expect(updatedViaRoute.version).toBe(fetchedViaRoute.version + 1);\n expect(deletedViaRoute).toBeUndefined();\n expect(listedViaRoute.items).toContainEqual(updatedViaRoute);\n expect(listedAfterDeleteViaRoute.items).toEqual([]);\n` : ` expect(listedViaRoute.items).toContainEqual(createdViaRoute);\n`}
3245
4607
  });
3246
4608
  });
3247
4609
  `;