@beignet/cli 0.0.3 → 0.0.5

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 (138) hide show
  1. package/CHANGELOG.md +219 -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 +49 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +53 -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 +41 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +78 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +10 -5
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +28 -47
  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 +665 -603
  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 +1896 -382
  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/base.d.ts +13 -0
  68. package/dist/templates/base.d.ts.map +1 -0
  69. package/dist/templates/base.js +300 -0
  70. package/dist/templates/base.js.map +1 -0
  71. package/dist/templates/db.d.ts +14 -0
  72. package/dist/templates/db.d.ts.map +1 -0
  73. package/dist/templates/db.js +962 -0
  74. package/dist/templates/db.js.map +1 -0
  75. package/dist/templates/index.d.ts +14 -0
  76. package/dist/templates/index.d.ts.map +1 -0
  77. package/dist/templates/index.js +123 -0
  78. package/dist/templates/index.js.map +1 -0
  79. package/dist/templates/server.d.ts +26 -0
  80. package/dist/templates/server.d.ts.map +1 -0
  81. package/dist/templates/server.js +520 -0
  82. package/dist/templates/server.js.map +1 -0
  83. package/dist/templates/shadcn.d.ts +5 -0
  84. package/dist/templates/shadcn.d.ts.map +1 -0
  85. package/dist/templates/shadcn.js +555 -0
  86. package/dist/templates/shadcn.js.map +1 -0
  87. package/dist/templates/shared.d.ts +49 -0
  88. package/dist/templates/shared.d.ts.map +1 -0
  89. package/dist/templates/shared.js +57 -0
  90. package/dist/templates/shared.js.map +1 -0
  91. package/dist/templates/shell.d.ts +5 -0
  92. package/dist/templates/shell.d.ts.map +1 -0
  93. package/dist/templates/shell.js +1190 -0
  94. package/dist/templates/shell.js.map +1 -0
  95. package/dist/templates/todos.d.ts +17 -0
  96. package/dist/templates/todos.d.ts.map +1 -0
  97. package/dist/templates/todos.js +607 -0
  98. package/dist/templates/todos.js.map +1 -0
  99. package/dist/version.d.ts +8 -0
  100. package/dist/version.d.ts.map +1 -0
  101. package/dist/version.js +18 -0
  102. package/dist/version.js.map +1 -0
  103. package/package.json +9 -8
  104. package/src/ansi.ts +30 -0
  105. package/src/choices.ts +83 -0
  106. package/src/completion.ts +169 -0
  107. package/src/config.ts +16 -0
  108. package/src/create-prompts.ts +114 -0
  109. package/src/create.ts +40 -64
  110. package/src/db.ts +60 -4
  111. package/src/github-annotations.ts +37 -0
  112. package/src/index.ts +1075 -817
  113. package/src/inspect.ts +2859 -115
  114. package/src/lib.ts +45 -0
  115. package/src/lint.ts +493 -39
  116. package/src/make.ts +2389 -406
  117. package/src/outbox.ts +249 -0
  118. package/src/schedule.ts +272 -0
  119. package/src/task.ts +169 -0
  120. package/src/templates/base.ts +377 -0
  121. package/src/templates/db.ts +963 -0
  122. package/src/templates/index.ts +148 -0
  123. package/src/templates/server.ts +528 -0
  124. package/src/templates/shadcn.ts +570 -0
  125. package/src/templates/shared.ts +90 -0
  126. package/src/templates/shell.ts +1219 -0
  127. package/src/templates/todos.ts +607 -0
  128. package/src/version.ts +20 -0
  129. package/dist/create-bin.d.ts +0 -3
  130. package/dist/create-bin.d.ts.map +0 -1
  131. package/dist/create-bin.js +0 -9
  132. package/dist/create-bin.js.map +0 -1
  133. package/dist/templates.d.ts +0 -55
  134. package/dist/templates.d.ts.map +0 -1
  135. package/dist/templates.js +0 -3520
  136. package/dist/templates.js.map +0 -1
  137. package/src/create-bin.ts +0 -11
  138. package/src/templates.ts +0 -3774
package/src/make.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import type { MakeFeatureAddon } from "./choices.js";
3
5
  import {
4
6
  type BeignetConfig,
5
7
  directoryPath,
@@ -15,27 +17,22 @@ type MakeResourceOptions = {
15
17
  force?: boolean;
16
18
  dryRun?: boolean;
17
19
  config?: BeignetConfig;
20
+ auth?: boolean;
21
+ tenant?: boolean;
22
+ events?: boolean;
23
+ softDelete?: boolean;
18
24
  };
19
25
 
20
- export const makeFeatureAddonChoices = [
21
- "policy",
22
- "event",
23
- "events",
24
- "job",
25
- "jobs",
26
- "notification",
27
- "notifications",
28
- "upload",
29
- "uploads",
30
- ] as const;
31
-
32
- export type MakeFeatureAddon = (typeof makeFeatureAddonChoices)[number];
26
+ export type { MakeFeatureAddon } from "./choices.js";
27
+ export { makeFeatureAddonChoices } from "./choices.js";
33
28
 
34
29
  type NormalizedMakeFeatureAddon =
35
30
  | "policy"
31
+ | "task"
36
32
  | "event"
37
33
  | "job"
38
34
  | "notification"
35
+ | "ui"
39
36
  | "upload";
40
37
 
41
38
  type MakeFeatureOptions = MakeResourceOptions & {
@@ -58,6 +55,14 @@ type MakeUseCaseOptions = {
58
55
  config?: BeignetConfig;
59
56
  };
60
57
 
58
+ type MakeTaskOptions = {
59
+ name: string;
60
+ cwd?: string;
61
+ force?: boolean;
62
+ dryRun?: boolean;
63
+ config?: BeignetConfig;
64
+ };
65
+
61
66
  type MakeTestOptions = {
62
67
  name: string;
63
68
  cwd?: string;
@@ -159,6 +164,7 @@ type MakeUploadOptions = {
159
164
  };
160
165
 
161
166
  type MakeResult = {
167
+ schemaVersion: 1;
162
168
  name: string;
163
169
  targetDir: string;
164
170
  dryRun: boolean;
@@ -184,6 +190,10 @@ export type MakeContractResult = MakeResult;
184
190
  * Result returned by `beignet make use-case`.
185
191
  */
186
192
  export type MakeUseCaseResult = MakeResult;
193
+ /**
194
+ * Result returned by `beignet make task`.
195
+ */
196
+ export type MakeTaskResult = MakeResult;
187
197
  /**
188
198
  * Result returned by `beignet make test`.
189
199
  */
@@ -243,6 +253,15 @@ type ResourceNames = {
243
253
  pluralPascal: string;
244
254
  };
245
255
 
256
+ type ResourceGenerationMode = "feature" | "resource";
257
+
258
+ type ResourceGenerationOptions = {
259
+ auth: boolean;
260
+ tenant: boolean;
261
+ events: boolean;
262
+ softDelete: boolean;
263
+ };
264
+
246
265
  type GeneratedFile = {
247
266
  path: string;
248
267
  content: string;
@@ -300,6 +319,13 @@ type JobNames = FeatureArtifactNames & {
300
319
  payloadTypeName: string;
301
320
  };
302
321
 
322
+ type TaskNames = FeatureArtifactNames & {
323
+ taskName: string;
324
+ taskExportName: string;
325
+ inputSchemaName: string;
326
+ inputTypeName: string;
327
+ };
328
+
303
329
  type FactoryNames = FeatureArtifactNames & {
304
330
  factoryName: string;
305
331
  factoryExportName: string;
@@ -339,8 +365,51 @@ type UploadNames = FeatureArtifactNames & {
339
365
  metadataTypeName: string;
340
366
  };
341
367
 
368
+ type FeatureUiNames = ResourceNames & {
369
+ componentExportName: string;
370
+ componentFileName: string;
371
+ };
372
+
342
373
  export async function makeResource(
343
374
  options: MakeResourceOptions,
375
+ ): Promise<MakeResourceResult> {
376
+ return makeResourceSlice(options, "resource");
377
+ }
378
+
379
+ export async function makeFeature(
380
+ options: MakeFeatureOptions,
381
+ ): Promise<MakeFeatureResult> {
382
+ const addons = normalizeMakeFeatureAddons(options.with ?? []);
383
+ if (addons.length === 0) {
384
+ return makeFeatureResource(options);
385
+ }
386
+
387
+ if (options.dryRun) {
388
+ return makeFeatureSlice(options, addons);
389
+ }
390
+
391
+ await makeFeatureSlice({ ...options, dryRun: true }, addons);
392
+ return makeFeatureSlice(options, addons);
393
+ }
394
+
395
+ async function makeFeatureResource(
396
+ options: MakeResourceOptions,
397
+ ): Promise<MakeResourceResult> {
398
+ try {
399
+ return await makeResourceSlice(options, "feature");
400
+ } catch (error) {
401
+ if (error instanceof Error && error.message.includes("make resource")) {
402
+ throw new Error(
403
+ error.message.replaceAll("make resource", "make feature"),
404
+ );
405
+ }
406
+ throw error;
407
+ }
408
+ }
409
+
410
+ async function makeResourceSlice(
411
+ options: MakeResourceOptions,
412
+ mode: ResourceGenerationMode,
344
413
  ): Promise<MakeResourceResult> {
345
414
  const targetDir = path.resolve(options.cwd ?? process.cwd());
346
415
  const names = resourceNames(options.name);
@@ -349,9 +418,24 @@ export async function makeResource(
349
418
  : await loadBeignetConfig(targetDir);
350
419
 
351
420
  await assertStandardApp(targetDir, config);
421
+ if (
422
+ mode === "resource" &&
423
+ !(await fileExists(path.join(targetDir, resourceSharedErrorsPath(config))))
424
+ ) {
425
+ throw new Error(
426
+ `beignet make resource expects an app error catalog. Missing ${resourceSharedErrorsPath(config)}.`,
427
+ );
428
+ }
352
429
 
353
430
  const persistence = await detectResourcePersistence(targetDir, config);
354
- const generatedFiles = resourceFiles(names, config, persistence);
431
+ const generationOptions = resourceGenerationOptions(options, mode);
432
+ const generatedFiles = resourceFiles(
433
+ names,
434
+ config,
435
+ persistence,
436
+ mode,
437
+ generationOptions,
438
+ );
355
439
  const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
356
440
  force: Boolean(options.force),
357
441
  });
@@ -362,6 +446,8 @@ export async function makeResource(
362
446
  persistence,
363
447
  {
364
448
  dryRun: true,
449
+ mode,
450
+ generationOptions,
365
451
  },
366
452
  );
367
453
  const createdFiles = plannedFiles
@@ -385,11 +471,14 @@ export async function makeResource(
385
471
  ...(await updateResourceWiring(targetDir, names, config, {
386
472
  persistence,
387
473
  dryRun: false,
474
+ mode,
475
+ generationOptions,
388
476
  })),
389
477
  );
390
478
  }
391
479
 
392
480
  return {
481
+ schemaVersion: 1,
393
482
  name: names.pluralKebab,
394
483
  targetDir,
395
484
  dryRun: Boolean(options.dryRun),
@@ -400,35 +489,20 @@ export async function makeResource(
400
489
  };
401
490
  }
402
491
 
403
- export async function makeFeature(
404
- options: MakeFeatureOptions,
405
- ): Promise<MakeFeatureResult> {
406
- const addons = normalizeMakeFeatureAddons(options.with ?? []);
407
- if (addons.length === 0) {
408
- return makeFeatureResource(options);
409
- }
410
-
411
- if (options.dryRun) {
412
- return makeFeatureSlice(options, addons);
413
- }
414
-
415
- await makeFeatureSlice({ ...options, dryRun: true }, addons);
416
- return makeFeatureSlice(options, addons);
417
- }
418
-
419
- async function makeFeatureResource(
492
+ function resourceGenerationOptions(
420
493
  options: MakeResourceOptions,
421
- ): Promise<MakeResourceResult> {
422
- try {
423
- return await makeResource(options);
424
- } catch (error) {
425
- if (error instanceof Error && error.message.includes("make resource")) {
426
- throw new Error(
427
- error.message.replaceAll("make resource", "make feature"),
428
- );
429
- }
430
- throw error;
494
+ mode: ResourceGenerationMode,
495
+ ): ResourceGenerationOptions {
496
+ if (mode !== "resource") {
497
+ return { auth: false, tenant: false, events: false, softDelete: false };
431
498
  }
499
+
500
+ return {
501
+ auth: Boolean(options.auth),
502
+ tenant: Boolean(options.tenant),
503
+ events: Boolean(options.events),
504
+ softDelete: Boolean(options.softDelete),
505
+ };
432
506
  }
433
507
 
434
508
  async function makeFeatureSlice(
@@ -462,6 +536,9 @@ async function makeFeatureAddon(
462
536
  if (addon === "policy") {
463
537
  return makePolicy({ ...shared, name: feature });
464
538
  }
539
+ if (addon === "task") {
540
+ return makeTask({ ...shared, name: `${feature}/backfill` });
541
+ }
465
542
  if (addon === "event") {
466
543
  return makeEvent({ ...shared, name: `${feature}/created` });
467
544
  }
@@ -471,6 +548,9 @@ async function makeFeatureAddon(
471
548
  if (addon === "notification") {
472
549
  return makeNotification({ ...shared, name: `${feature}/created` });
473
550
  }
551
+ if (addon === "ui") {
552
+ return makeFeatureUi({ ...shared, name: feature });
553
+ }
474
554
 
475
555
  return makeUpload({ ...shared, name: `${feature}/attachment` });
476
556
  }
@@ -482,6 +562,7 @@ function normalizeMakeFeatureAddons(
482
562
 
483
563
  for (const addon of addons) {
484
564
  if (addon === "events") normalized.add("event");
565
+ else if (addon === "tasks") normalized.add("task");
485
566
  else if (addon === "jobs") normalized.add("job");
486
567
  else if (addon === "notifications") normalized.add("notification");
487
568
  else if (addon === "uploads") normalized.add("upload");
@@ -493,9 +574,11 @@ function normalizeMakeFeatureAddons(
493
574
 
494
575
  const featureAddonOrder: readonly NormalizedMakeFeatureAddon[] = [
495
576
  "policy",
577
+ "task",
496
578
  "event",
497
579
  "job",
498
580
  "notification",
581
+ "ui",
499
582
  "upload",
500
583
  ];
501
584
 
@@ -537,6 +620,7 @@ export async function makeContract(
537
620
  }
538
621
 
539
622
  return {
623
+ schemaVersion: 1,
540
624
  name: names.pluralKebab,
541
625
  targetDir,
542
626
  dryRun: Boolean(options.dryRun),
@@ -582,6 +666,7 @@ export async function makeUseCase(
582
666
  if (indexResult === "skipped") skippedFiles.push(indexFile.path);
583
667
 
584
668
  return {
669
+ schemaVersion: 1,
585
670
  name: `${names.feature.kebab}/${names.action.kebab}`,
586
671
  targetDir,
587
672
  dryRun: Boolean(options.dryRun),
@@ -627,6 +712,7 @@ export async function makeTest(
627
712
  }
628
713
 
629
714
  return {
715
+ schemaVersion: 1,
630
716
  name: `${names.feature.kebab}/${names.action.kebab}`,
631
717
  targetDir,
632
718
  dryRun: Boolean(options.dryRun),
@@ -689,6 +775,7 @@ export async function makePort(
689
775
  }
690
776
 
691
777
  return {
778
+ schemaVersion: 1,
692
779
  name: names.kebab,
693
780
  targetDir,
694
781
  dryRun: Boolean(options.dryRun),
@@ -734,6 +821,7 @@ export async function makeAdapter(
734
821
  }
735
822
 
736
823
  return {
824
+ schemaVersion: 1,
737
825
  name: names.kebab,
738
826
  targetDir,
739
827
  dryRun: Boolean(options.dryRun),
@@ -771,6 +859,7 @@ export async function makePolicy(
771
859
  }
772
860
 
773
861
  return {
862
+ schemaVersion: 1,
774
863
  name: names.kebab,
775
864
  targetDir,
776
865
  dryRun: Boolean(options.dryRun),
@@ -821,7 +910,10 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
821
910
  force: Boolean(options.force),
822
911
  dryRun: Boolean(options.dryRun),
823
912
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
824
- files: jobFiles(names, config),
913
+ files: [
914
+ ...(await missingCapabilityBuilderFiles(targetDir, "jobs", config)),
915
+ ...jobFiles(names, config),
916
+ ],
825
917
  index: featureArtifactIndexFile("job", names, config),
826
918
  dependencies: {
827
919
  "@beignet/core": beignetDependencyVersion,
@@ -829,6 +921,49 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
829
921
  });
830
922
  }
831
923
 
924
+ export async function makeTask(
925
+ options: MakeTaskOptions,
926
+ ): Promise<MakeTaskResult> {
927
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
928
+ const names = taskNames(options.name);
929
+ const config = options.config
930
+ ? resolveConfig(options.config)
931
+ : await loadBeignetConfig(targetDir);
932
+
933
+ await assertFeatureArtifactApp(targetDir, config, "task");
934
+
935
+ const result = await makeFeatureArtifact({
936
+ targetDir,
937
+ config,
938
+ force: Boolean(options.force),
939
+ dryRun: Boolean(options.dryRun),
940
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
941
+ files: [
942
+ ...(await missingCapabilityBuilderFiles(targetDir, "tasks", config)),
943
+ ...taskFiles(names, config),
944
+ ],
945
+ index: featureArtifactIndexFile("task", names, config),
946
+ dependencies: {
947
+ "@beignet/core": beignetDependencyVersion,
948
+ },
949
+ });
950
+
951
+ const registryResult = await updateTaskRegistry(targetDir, names, config, {
952
+ dryRun: Boolean(options.dryRun),
953
+ });
954
+ if (registryResult === "created")
955
+ result.createdFiles.push(config.paths.tasks);
956
+ if (registryResult === "updated")
957
+ result.updatedFiles.push(config.paths.tasks);
958
+ if (registryResult === "skipped")
959
+ result.skippedFiles.push(config.paths.tasks);
960
+ if (!result.files.includes(config.paths.tasks)) {
961
+ result.files.push(config.paths.tasks);
962
+ }
963
+
964
+ return result;
965
+ }
966
+
832
967
  export async function makeFactory(
833
968
  options: MakeFactoryOptions,
834
969
  ): Promise<MakeFactoryResult> {
@@ -896,7 +1031,14 @@ export async function makeNotification(
896
1031
  force: Boolean(options.force),
897
1032
  dryRun: Boolean(options.dryRun),
898
1033
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
899
- files: notificationFiles(names, config),
1034
+ files: [
1035
+ ...(await missingCapabilityBuilderFiles(
1036
+ targetDir,
1037
+ "notifications",
1038
+ config,
1039
+ )),
1040
+ ...notificationFiles(names, config),
1041
+ ],
900
1042
  index: featureArtifactIndexFile("notification", names, config),
901
1043
  dependencies: {
902
1044
  "@beignet/core": beignetDependencyVersion,
@@ -922,7 +1064,10 @@ export async function makeListener(
922
1064
  force: Boolean(options.force),
923
1065
  dryRun: Boolean(options.dryRun),
924
1066
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
925
- files: listenerFiles(names, config),
1067
+ files: [
1068
+ ...(await missingCapabilityBuilderFiles(targetDir, "listeners", config)),
1069
+ ...listenerFiles(names, config),
1070
+ ],
926
1071
  index: featureArtifactIndexFile("listener", names, config),
927
1072
  dependencies: {
928
1073
  "@beignet/core": beignetDependencyVersion,
@@ -962,9 +1107,18 @@ export async function makeSchedule(
962
1107
  requireServer: Boolean(options.route),
963
1108
  });
964
1109
 
965
- const files = scheduleFiles(names, config, { route: Boolean(options.route) });
1110
+ // Plan the CRON_SECRET wiring before writing any files so an anchor miss
1111
+ // in lib/env.ts aborts the generator without leaving partial output.
1112
+ const cronSecretPlan = options.route
1113
+ ? await planCronSecretWiring(targetDir)
1114
+ : undefined;
966
1115
 
967
- return makeFeatureArtifact({
1116
+ const files = [
1117
+ ...(await missingCapabilityBuilderFiles(targetDir, "schedules", config)),
1118
+ ...scheduleFiles(names, config, { route: Boolean(options.route) }),
1119
+ ];
1120
+
1121
+ const result = await makeFeatureArtifact({
968
1122
  targetDir,
969
1123
  config,
970
1124
  force: Boolean(options.force),
@@ -976,6 +1130,139 @@ export async function makeSchedule(
976
1130
  "@beignet/core": beignetDependencyVersion,
977
1131
  },
978
1132
  });
1133
+
1134
+ const registryResult = await updateScheduleRegistry(
1135
+ targetDir,
1136
+ names,
1137
+ config,
1138
+ { dryRun: Boolean(options.dryRun) },
1139
+ );
1140
+ if (registryResult === "created")
1141
+ result.createdFiles.push(config.paths.schedules);
1142
+ if (registryResult === "updated")
1143
+ result.updatedFiles.push(config.paths.schedules);
1144
+ if (registryResult === "skipped")
1145
+ result.skippedFiles.push(config.paths.schedules);
1146
+ if (!result.files.includes(config.paths.schedules)) {
1147
+ result.files.push(config.paths.schedules);
1148
+ }
1149
+
1150
+ if (cronSecretPlan) {
1151
+ const applied = await applyCronSecretWiring(targetDir, cronSecretPlan, {
1152
+ dryRun: Boolean(options.dryRun),
1153
+ });
1154
+ result.createdFiles.push(...applied.createdFiles);
1155
+ result.updatedFiles.push(...applied.updatedFiles);
1156
+ result.skippedFiles.push(...applied.skippedFiles);
1157
+ }
1158
+
1159
+ return result;
1160
+ }
1161
+
1162
+ const cronSecretEnvFile = "lib/env.ts";
1163
+ const cronSecretEnvEntry = `\t\tCRON_SECRET: z.string().min(1).optional(),`;
1164
+ // The literal APP_URL line every generated lib/env.ts ships with. CRON_SECRET
1165
+ // slots in directly below it, matching the documented env shape.
1166
+ const cronSecretEnvAnchor = `\t\tAPP_URL: z.string().url().default("http://localhost:3000"),`;
1167
+
1168
+ type CronSecretWiringPlan = {
1169
+ files: Array<GeneratedFile & { result: "created" | "updated" }>;
1170
+ skippedFiles: string[];
1171
+ };
1172
+
1173
+ async function planCronSecretWiring(
1174
+ targetDir: string,
1175
+ ): Promise<CronSecretWiringPlan> {
1176
+ const plan: CronSecretWiringPlan = { files: [], skippedFiles: [] };
1177
+ const manualInstruction = `Add ${cronSecretEnvEntry.trim()} to the createEnv server block in ${cronSecretEnvFile} manually, or restore the generated env file before running make schedule --route.`;
1178
+
1179
+ const envSource = await readOptionalFile(
1180
+ path.join(targetDir, cronSecretEnvFile),
1181
+ );
1182
+ if (envSource === undefined) {
1183
+ throw new Error(
1184
+ `Could not find the generated env file ${cronSecretEnvFile}. ${manualInstruction}`,
1185
+ );
1186
+ }
1187
+
1188
+ if (/\bCRON_SECRET\b/.test(envSource)) {
1189
+ plan.skippedFiles.push(cronSecretEnvFile);
1190
+ } else if (!envSource.includes(cronSecretEnvAnchor)) {
1191
+ throw new Error(
1192
+ `Could not find the generated APP_URL server entry in ${cronSecretEnvFile}. ${manualInstruction}`,
1193
+ );
1194
+ } else {
1195
+ plan.files.push({
1196
+ path: cronSecretEnvFile,
1197
+ content: envSource.replace(
1198
+ cronSecretEnvAnchor,
1199
+ `${cronSecretEnvAnchor}\n${cronSecretEnvEntry}`,
1200
+ ),
1201
+ result: "updated",
1202
+ });
1203
+ }
1204
+
1205
+ const secretLine = `CRON_SECRET=${randomBytes(16).toString("hex")}`;
1206
+
1207
+ const envExample = await readOptionalFile(
1208
+ path.join(targetDir, ".env.example"),
1209
+ );
1210
+ if (envExample === undefined) {
1211
+ plan.files.push({
1212
+ path: ".env.example",
1213
+ content: `${secretLine}\n`,
1214
+ result: "created",
1215
+ });
1216
+ } else if (/^CRON_SECRET=/m.test(envExample)) {
1217
+ plan.skippedFiles.push(".env.example");
1218
+ } else {
1219
+ plan.files.push({
1220
+ path: ".env.example",
1221
+ content: `${envExample.replace(/\n*$/, "\n")}${secretLine}\n`,
1222
+ result: "updated",
1223
+ });
1224
+ }
1225
+
1226
+ const envLocal = await readOptionalFile(path.join(targetDir, ".env.local"));
1227
+ if (envLocal !== undefined) {
1228
+ if (/^CRON_SECRET=/m.test(envLocal)) {
1229
+ plan.skippedFiles.push(".env.local");
1230
+ } else {
1231
+ plan.files.push({
1232
+ path: ".env.local",
1233
+ content: `${envLocal.replace(/\n*$/, "\n")}${secretLine}\n`,
1234
+ result: "updated",
1235
+ });
1236
+ }
1237
+ }
1238
+
1239
+ return plan;
1240
+ }
1241
+
1242
+ async function applyCronSecretWiring(
1243
+ targetDir: string,
1244
+ plan: CronSecretWiringPlan,
1245
+ options: { dryRun: boolean },
1246
+ ): Promise<{
1247
+ createdFiles: string[];
1248
+ updatedFiles: string[];
1249
+ skippedFiles: string[];
1250
+ }> {
1251
+ if (!options.dryRun) {
1252
+ for (const file of plan.files) {
1253
+ await writeFile(path.join(targetDir, file.path), file.content);
1254
+ }
1255
+ }
1256
+
1257
+ return {
1258
+ createdFiles: plan.files
1259
+ .filter((file) => file.result === "created")
1260
+ .map((file) => file.path),
1261
+ updatedFiles: plan.files
1262
+ .filter((file) => file.result === "updated")
1263
+ .map((file) => file.path),
1264
+ skippedFiles: plan.skippedFiles,
1265
+ };
979
1266
  }
980
1267
 
981
1268
  export async function makeUpload(
@@ -1003,6 +1290,70 @@ export async function makeUpload(
1003
1290
  });
1004
1291
  }
1005
1292
 
1293
+ async function makeFeatureUi(
1294
+ options: MakeResourceOptions,
1295
+ ): Promise<MakeResult> {
1296
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
1297
+ const names = featureUiNames(options.name);
1298
+ const config = options.config
1299
+ ? resolveConfig(options.config)
1300
+ : await loadBeignetConfig(targetDir);
1301
+
1302
+ await assertFeatureUiApp(targetDir, config);
1303
+
1304
+ const componentFiles = featureUiComponentFiles(names, config);
1305
+ const indexFile = featureUiIndexFile(names, config);
1306
+ const plannedFiles = await planGeneratedFiles(targetDir, componentFiles, {
1307
+ force: Boolean(options.force),
1308
+ });
1309
+ const createdFiles = plannedFiles
1310
+ .filter((file) => file.result === "created")
1311
+ .map((file) => file.path);
1312
+ const updatedFiles = plannedFiles
1313
+ .filter((file) => file.result === "updated")
1314
+ .map((file) => file.path);
1315
+ const skippedFiles = plannedFiles
1316
+ .filter((file) => file.result === "skipped")
1317
+ .map((file) => file.path);
1318
+
1319
+ if (!options.dryRun) {
1320
+ for (const file of plannedFiles) {
1321
+ await writePlannedGeneratedFile(targetDir, file);
1322
+ }
1323
+ }
1324
+
1325
+ const indexResult = await updateFeatureUiIndex(targetDir, indexFile, {
1326
+ dryRun: Boolean(options.dryRun),
1327
+ });
1328
+ if (indexResult === "created") createdFiles.push(indexFile.path);
1329
+ if (indexResult === "updated") updatedFiles.push(indexFile.path);
1330
+ if (indexResult === "skipped") skippedFiles.push(indexFile.path);
1331
+
1332
+ if (
1333
+ await updatePackageDependencies(
1334
+ targetDir,
1335
+ {
1336
+ "@beignet/react-query": beignetDependencyVersion,
1337
+ "@tanstack/react-query": tanstackReactQueryDependencyVersion,
1338
+ },
1339
+ { dryRun: Boolean(options.dryRun) },
1340
+ )
1341
+ ) {
1342
+ updatedFiles.push("package.json");
1343
+ }
1344
+
1345
+ return {
1346
+ schemaVersion: 1,
1347
+ name: names.pluralKebab,
1348
+ targetDir,
1349
+ dryRun: Boolean(options.dryRun),
1350
+ files: [...componentFiles.map((file) => file.path), indexFile.path],
1351
+ createdFiles,
1352
+ updatedFiles,
1353
+ skippedFiles,
1354
+ };
1355
+ }
1356
+
1006
1357
  async function assertStandardApp(
1007
1358
  targetDir: string,
1008
1359
  config: ResolvedBeignetConfig,
@@ -1131,6 +1482,7 @@ async function assertFeatureArtifactApp(
1131
1482
  artifact:
1132
1483
  | "event"
1133
1484
  | "job"
1485
+ | "task"
1134
1486
  | "notification"
1135
1487
  | "listener"
1136
1488
  | "schedule"
@@ -1153,6 +1505,24 @@ async function assertFeatureArtifactApp(
1153
1505
  }
1154
1506
  }
1155
1507
 
1508
+ async function assertFeatureUiApp(
1509
+ targetDir: string,
1510
+ config: ResolvedBeignetConfig,
1511
+ ): Promise<void> {
1512
+ const clientPath = clientIndexPath(config);
1513
+ const requiredFiles = [config.paths.appContext, clientPath];
1514
+
1515
+ for (const file of requiredFiles) {
1516
+ try {
1517
+ await stat(path.join(targetDir, file));
1518
+ } catch {
1519
+ throw new Error(
1520
+ `beignet make feature --with ui expects a standard Beignet app with React Query client helpers. Missing ${file}.`,
1521
+ );
1522
+ }
1523
+ }
1524
+ }
1525
+
1156
1526
  async function assertPersistenceArtifactApp(
1157
1527
  targetDir: string,
1158
1528
  names: FactoryNames | SeedNames,
@@ -1264,18 +1634,95 @@ async function readOptionalFile(filePath: string): Promise<string | undefined> {
1264
1634
  }
1265
1635
  }
1266
1636
 
1267
- async function makeFeatureArtifact(options: {
1268
- targetDir: string;
1269
- config: ResolvedBeignetConfig;
1270
- force: boolean;
1271
- dryRun: boolean;
1272
- name: string;
1273
- files: GeneratedFile[];
1274
- index: FeatureArtifactIndexFile;
1275
- dependencies: Record<string, (packageJson: PackageJsonLike) => string>;
1276
- }): Promise<MakeResult> {
1277
- const plannedFiles = await planGeneratedFiles(
1278
- options.targetDir,
1637
+ type CapabilityBuilder =
1638
+ | "listeners"
1639
+ | "jobs"
1640
+ | "schedules"
1641
+ | "notifications"
1642
+ | "tasks";
1643
+
1644
+ const capabilityBuilders: Record<
1645
+ CapabilityBuilder,
1646
+ { factoryName: string; defineName: string; module: string }
1647
+ > = {
1648
+ listeners: {
1649
+ factoryName: "createListeners",
1650
+ defineName: "defineListener",
1651
+ module: "@beignet/core/events",
1652
+ },
1653
+ jobs: {
1654
+ factoryName: "createJobs",
1655
+ defineName: "defineJob",
1656
+ module: "@beignet/core/jobs",
1657
+ },
1658
+ schedules: {
1659
+ factoryName: "createSchedules",
1660
+ defineName: "defineSchedule",
1661
+ module: "@beignet/core/schedules",
1662
+ },
1663
+ notifications: {
1664
+ factoryName: "createNotifications",
1665
+ defineName: "defineNotification",
1666
+ module: "@beignet/core/notifications",
1667
+ },
1668
+ tasks: {
1669
+ factoryName: "createTasks",
1670
+ defineName: "defineTask",
1671
+ module: "@beignet/core/tasks",
1672
+ },
1673
+ };
1674
+
1675
+ function capabilityBuilderPath(
1676
+ capability: CapabilityBuilder,
1677
+ config: ResolvedBeignetConfig,
1678
+ ): string {
1679
+ if (capability === "listeners") return config.paths.listenersBuilder;
1680
+ if (capability === "jobs") return config.paths.jobsBuilder;
1681
+ if (capability === "schedules") return config.paths.schedulesBuilder;
1682
+ if (capability === "notifications") return config.paths.notificationsBuilder;
1683
+ return config.paths.tasksBuilder;
1684
+ }
1685
+
1686
+ function capabilityBuilderFile(
1687
+ capability: CapabilityBuilder,
1688
+ config: ResolvedBeignetConfig,
1689
+ ): GeneratedFile {
1690
+ const builder = capabilityBuilders[capability];
1691
+
1692
+ return {
1693
+ path: capabilityBuilderPath(capability, config),
1694
+ content: `import { ${builder.factoryName} } from "${builder.module}";
1695
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
1696
+
1697
+ export const { ${builder.defineName} } = ${builder.factoryName}<AppContext>();
1698
+ `,
1699
+ };
1700
+ }
1701
+
1702
+ async function missingCapabilityBuilderFiles(
1703
+ targetDir: string,
1704
+ capability: CapabilityBuilder,
1705
+ config: ResolvedBeignetConfig,
1706
+ ): Promise<GeneratedFile[]> {
1707
+ const file = capabilityBuilderFile(capability, config);
1708
+ if (await fileExists(path.join(targetDir, file.path))) {
1709
+ return [];
1710
+ }
1711
+ return [file];
1712
+ }
1713
+
1714
+ async function makeFeatureArtifact(options: {
1715
+ targetDir: string;
1716
+ config: ResolvedBeignetConfig;
1717
+ force: boolean;
1718
+ dryRun: boolean;
1719
+ name: string;
1720
+ files: GeneratedFile[];
1721
+ index: FeatureArtifactIndexFile;
1722
+ dependencies: Record<string, (packageJson: PackageJsonLike) => string>;
1723
+ }): Promise<MakeResult> {
1724
+ const plannedFiles = await planGeneratedFiles(
1725
+ options.targetDir,
1279
1726
  options.files,
1280
1727
  {
1281
1728
  force: options.force,
@@ -1315,6 +1762,7 @@ async function makeFeatureArtifact(options: {
1315
1762
  }
1316
1763
 
1317
1764
  return {
1765
+ schemaVersion: 1,
1318
1766
  name: options.name,
1319
1767
  targetDir: options.targetDir,
1320
1768
  dryRun: options.dryRun,
@@ -1358,14 +1806,29 @@ function beignetDependencyVersion(packageJson: PackageJsonLike): string {
1358
1806
  );
1359
1807
  }
1360
1808
 
1809
+ function tanstackReactQueryDependencyVersion(
1810
+ packageJson: PackageJsonLike,
1811
+ ): string {
1812
+ return packageJson.dependencies?.["@tanstack/react-query"] ?? "^5.100.7";
1813
+ }
1814
+
1361
1815
  async function updateResourceWiring(
1362
1816
  targetDir: string,
1363
1817
  names: ResourceNames,
1364
1818
  config: ResolvedBeignetConfig,
1365
1819
  persistenceOrOptions:
1366
1820
  | ResourcePersistence
1367
- | { dryRun: boolean; persistence?: ResourcePersistence },
1368
- maybeOptions?: { dryRun: boolean },
1821
+ | {
1822
+ dryRun: boolean;
1823
+ persistence?: ResourcePersistence;
1824
+ mode?: ResourceGenerationMode;
1825
+ generationOptions?: ResourceGenerationOptions;
1826
+ },
1827
+ maybeOptions?: {
1828
+ dryRun: boolean;
1829
+ mode?: ResourceGenerationMode;
1830
+ generationOptions?: ResourceGenerationOptions;
1831
+ },
1369
1832
  ): Promise<string[]> {
1370
1833
  const persistence =
1371
1834
  typeof persistenceOrOptions === "string"
@@ -1375,6 +1838,16 @@ async function updateResourceWiring(
1375
1838
  typeof persistenceOrOptions === "string"
1376
1839
  ? (maybeOptions as { dryRun: boolean })
1377
1840
  : persistenceOrOptions;
1841
+ const mode =
1842
+ typeof persistenceOrOptions === "string"
1843
+ ? (maybeOptions?.mode ?? "feature")
1844
+ : (persistenceOrOptions.mode ?? "feature");
1845
+ const generationOptions =
1846
+ typeof persistenceOrOptions === "string"
1847
+ ? (maybeOptions?.generationOptions ??
1848
+ resourceGenerationOptions({ name: names.pluralKebab }, mode))
1849
+ : (persistenceOrOptions.generationOptions ??
1850
+ resourceGenerationOptions({ name: names.pluralKebab }, mode));
1378
1851
  const updated = new Set<string>();
1379
1852
  if (
1380
1853
  await updatePackageJson(targetDir, {
@@ -1384,7 +1857,12 @@ async function updateResourceWiring(
1384
1857
  ) {
1385
1858
  updated.add("package.json");
1386
1859
  }
1387
- if (await updatePortsIndex(targetDir, names, config, options)) {
1860
+ if (
1861
+ await updatePortsIndex(targetDir, names, config, {
1862
+ ...options,
1863
+ generationOptions,
1864
+ })
1865
+ ) {
1388
1866
  updated.add(config.paths.ports);
1389
1867
  }
1390
1868
  if (
@@ -1393,6 +1871,18 @@ async function updateResourceWiring(
1393
1871
  ) {
1394
1872
  updated.add(config.paths.infrastructurePorts);
1395
1873
  }
1874
+ if (
1875
+ generationOptions.auth &&
1876
+ (await updateInfrastructureGatePolicies(targetDir, names, config, options))
1877
+ ) {
1878
+ updated.add(config.paths.infrastructurePorts);
1879
+ }
1880
+ if (
1881
+ persistence === "drizzle" &&
1882
+ (await updateInfrastructureDeferredPorts(targetDir, names, config, options))
1883
+ ) {
1884
+ updated.add(config.paths.infrastructurePorts);
1885
+ }
1396
1886
  if (
1397
1887
  persistence === "drizzle" &&
1398
1888
  (await updateDrizzleSchemaIndex(targetDir, names, config, options))
@@ -1411,7 +1901,14 @@ async function updateResourceWiring(
1411
1901
  ) {
1412
1902
  updated.add(config.paths.server);
1413
1903
  }
1414
- if (await updateOpenApiRoute(targetDir, names, config, options)) {
1904
+ if (
1905
+ await updateSharedErrors(targetDir, names, config, { ...options, mode })
1906
+ ) {
1907
+ updated.add(resourceSharedErrorsPath(config));
1908
+ }
1909
+ if (
1910
+ await updateOpenApiRoute(targetDir, names, config, { ...options, mode })
1911
+ ) {
1415
1912
  updated.add(config.paths.openapiRoute);
1416
1913
  }
1417
1914
  const routesFile = await updateServerRoutes(
@@ -1457,7 +1954,7 @@ async function updatePortsIndex(
1457
1954
  targetDir: string,
1458
1955
  names: ResourceNames,
1459
1956
  config: ResolvedBeignetConfig,
1460
- options: { dryRun: boolean },
1957
+ options: { dryRun: boolean; generationOptions?: ResourceGenerationOptions },
1461
1958
  ): Promise<boolean> {
1462
1959
  const filePath = path.join(targetDir, config.paths.ports);
1463
1960
  const original = await readFile(filePath, "utf8");
@@ -1469,6 +1966,30 @@ async function updatePortsIndex(
1469
1966
  next = insertAfterImports(next, importLine);
1470
1967
  }
1471
1968
 
1969
+ if (options.generationOptions?.auth) {
1970
+ const policyPath = path.join(
1971
+ config.paths.features,
1972
+ names.pluralKebab,
1973
+ "policy.ts",
1974
+ );
1975
+ const policyImportLine = `import type { AuthorizationContext as ${names.singularPascal}AuthorizationContext, ${names.singularCamel}Policy } from "${relativeModule(config.paths.ports, policyPath)}";`;
1976
+ if (!next.includes(policyImportLine)) {
1977
+ next = insertAfterImports(next, policyImportLine);
1978
+ }
1979
+
1980
+ next = appendUnionTypeMember(
1981
+ next,
1982
+ "AppAuthorizationContext",
1983
+ `${names.singularPascal}AuthorizationContext`,
1984
+ );
1985
+ next = appendTupleTypeMember(
1986
+ next,
1987
+ "AppGate",
1988
+ `typeof ${names.singularCamel}Policy`,
1989
+ );
1990
+ next = appendGatePolicyMember(next, `typeof ${names.singularCamel}Policy`);
1991
+ }
1992
+
1472
1993
  const entry = `\t${names.pluralCamel}: ${names.singularPascal}Repository;`;
1473
1994
  if (!next.includes(entry.trim())) {
1474
1995
  next = insertTypeProperty(
@@ -1625,7 +2146,7 @@ async function updateInfrastructurePorts(
1625
2146
  if (!next.includes(repositoryConst)) {
1626
2147
  next = replaceRequired(
1627
2148
  next,
1628
- /(\nexport const appPorts = definePorts(?:<[^>]+>)?\(\{)/,
2149
+ /(\nexport const appPorts = definePorts(?:<[^>]+>)?\((?:\)\()?\{)/,
1629
2150
  `\n${repositoryConst}\n$1`,
1630
2151
  `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add ${repositoryConst} manually, or restore the generated infrastructure ports file before running make resource.`,
1631
2152
  );
@@ -1662,6 +2183,37 @@ async function updateInfrastructurePorts(
1662
2183
  return true;
1663
2184
  }
1664
2185
 
2186
+ async function updateInfrastructureGatePolicies(
2187
+ targetDir: string,
2188
+ names: ResourceNames,
2189
+ config: ResolvedBeignetConfig,
2190
+ options: { dryRun: boolean },
2191
+ ): Promise<boolean> {
2192
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2193
+ const original = await readFile(filePath, "utf8");
2194
+ let next = original;
2195
+ const policyPath = path.join(
2196
+ config.paths.features,
2197
+ names.pluralKebab,
2198
+ "policy.ts",
2199
+ );
2200
+ const importLine = `import { ${names.singularCamel}Policy } from "${relativeModule(config.paths.infrastructurePorts, policyPath)}";`;
2201
+
2202
+ if (!next.includes(importLine)) {
2203
+ next = insertAfterImports(next, importLine);
2204
+ }
2205
+
2206
+ next = appendCreateGatePolicy(
2207
+ next,
2208
+ `${names.singularCamel}Policy`,
2209
+ `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.`,
2210
+ );
2211
+
2212
+ if (next === original) return false;
2213
+ if (!options.dryRun) await writeFile(filePath, next);
2214
+ return true;
2215
+ }
2216
+
1665
2217
  async function updateDrizzleSchemaIndex(
1666
2218
  targetDir: string,
1667
2219
  names: ResourceNames,
@@ -1719,6 +2271,24 @@ async function updateDrizzleRepositories(
1719
2271
  return true;
1720
2272
  }
1721
2273
 
2274
+ async function updateInfrastructureDeferredPorts(
2275
+ targetDir: string,
2276
+ names: ResourceNames,
2277
+ config: ResolvedBeignetConfig,
2278
+ options: { dryRun: boolean },
2279
+ ): Promise<boolean> {
2280
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2281
+ const original = await readFile(filePath, "utf8");
2282
+ // Drizzle resources are provided by the app database provider at startup,
2283
+ // so deferred-form ports files defer the new repository key. Identity-form
2284
+ // ports files are left untouched.
2285
+ const next = appendDeferredPortKey(original, names.pluralCamel);
2286
+
2287
+ if (next === original) return false;
2288
+ if (!options.dryRun) await writeFile(filePath, next);
2289
+ return true;
2290
+ }
2291
+
1722
2292
  async function updateServerTransactionPorts(
1723
2293
  targetDir: string,
1724
2294
  names: ResourceNames,
@@ -1759,7 +2329,7 @@ async function updateOpenApiRoute(
1759
2329
  targetDir: string,
1760
2330
  names: ResourceNames,
1761
2331
  config: ResolvedBeignetConfig,
1762
- options: { dryRun: boolean },
2332
+ options: { dryRun: boolean; mode: ResourceGenerationMode },
1763
2333
  ): Promise<boolean> {
1764
2334
  const filePath = path.join(targetDir, config.paths.openapiRoute);
1765
2335
  if (!(await fileExists(filePath))) return false;
@@ -1768,15 +2338,14 @@ async function updateOpenApiRoute(
1768
2338
  const firstArg = firstCreateOpenAPIHandlerArgInfo(original);
1769
2339
  if (!firstArg?.text.startsWith("[")) return false;
1770
2340
 
1771
- const listName = `list${names.pluralPascal}`;
1772
- const createName = `create${names.singularPascal}`;
2341
+ const contractNames = resourceContractNames(names, options.mode);
1773
2342
  const listedContracts = identifiersFromArrayExpression(firstArg.text);
1774
- const missingContracts = [listName, createName].filter(
2343
+ const missingContracts = contractNames.filter(
1775
2344
  (contractName) => !listedContracts.has(contractName),
1776
2345
  );
1777
2346
  if (missingContracts.length === 0) return false;
1778
2347
 
1779
- const importLine = `import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
2348
+ const importLine = `import { ${contractNames.join(", ")} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
1780
2349
  const nextArray = appendToArrayExpression(firstArg.text, missingContracts);
1781
2350
  let next = `${original.slice(0, firstArg.start)}${nextArray}${original.slice(
1782
2351
  firstArg.end,
@@ -1791,6 +2360,74 @@ async function updateOpenApiRoute(
1791
2360
  return true;
1792
2361
  }
1793
2362
 
2363
+ async function updateSharedErrors(
2364
+ targetDir: string,
2365
+ names: ResourceNames,
2366
+ config: ResolvedBeignetConfig,
2367
+ options: { dryRun: boolean; mode: ResourceGenerationMode },
2368
+ ): Promise<boolean> {
2369
+ if (options.mode !== "resource") return false;
2370
+
2371
+ const filePath = path.join(targetDir, resourceSharedErrorsPath(config));
2372
+ if (!(await fileExists(filePath))) return false;
2373
+
2374
+ const original = await readFile(filePath, "utf8");
2375
+ const notFoundErrorName = `${names.singularPascal}NotFound`;
2376
+ const conflictErrorName = `${names.singularPascal}Conflict`;
2377
+ const missingErrorNames = [notFoundErrorName, conflictErrorName].filter(
2378
+ (errorName) => !new RegExp(`\\b${errorName}\\b`).test(original),
2379
+ );
2380
+ if (missingErrorNames.length === 0) return false;
2381
+ const expectedErrorNames = missingErrorNames.join(" and ");
2382
+
2383
+ const defineErrorsStart = original.indexOf("defineErrors(");
2384
+ if (defineErrorsStart === -1) {
2385
+ throw new Error(
2386
+ `Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`,
2387
+ );
2388
+ }
2389
+
2390
+ const openBrace = original.indexOf("{", defineErrorsStart);
2391
+ if (openBrace === -1) {
2392
+ throw new Error(
2393
+ `Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`,
2394
+ );
2395
+ }
2396
+
2397
+ const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
2398
+ if (closeBrace === -1) {
2399
+ throw new Error(
2400
+ `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.`,
2401
+ );
2402
+ }
2403
+
2404
+ const entries = [];
2405
+ if (missingErrorNames.includes(notFoundErrorName)) {
2406
+ entries.push(`${notFoundErrorName}: {
2407
+ \t\tcode: "${constantCase(names.singularKebab)}_NOT_FOUND",
2408
+ \t\tstatus: 404,
2409
+ \t\tmessage: "${names.singularPascal} not found",
2410
+ \t},`);
2411
+ }
2412
+ if (missingErrorNames.includes(conflictErrorName)) {
2413
+ entries.push(`${conflictErrorName}: {
2414
+ \t\tcode: "${constantCase(names.singularKebab)}_CONFLICT",
2415
+ \t\tstatus: 409,
2416
+ \t\tmessage: "${names.singularPascal} was changed by another request",
2417
+ \t},`);
2418
+ }
2419
+ const next = insertBeforeClosingBrace(
2420
+ original,
2421
+ openBrace,
2422
+ closeBrace,
2423
+ entries.join("\n"),
2424
+ );
2425
+
2426
+ if (next === original) return false;
2427
+ if (!options.dryRun) await writeFile(filePath, next);
2428
+ return true;
2429
+ }
2430
+
1794
2431
  async function updateServerRoutes(
1795
2432
  targetDir: string,
1796
2433
  names: ResourceNames,
@@ -2184,6 +2821,98 @@ function typeBodySource(source: string, typeName: string): string | undefined {
2184
2821
  return source.slice(openBrace + 1, closeBrace);
2185
2822
  }
2186
2823
 
2824
+ function appendUnionTypeMember(
2825
+ source: string,
2826
+ typeName: string,
2827
+ member: string,
2828
+ ): string {
2829
+ const match = new RegExp(
2830
+ `(export\\s+type\\s+${typeName}\\s*=)([\\s\\S]*?);`,
2831
+ ).exec(source);
2832
+ if (!match) return source;
2833
+
2834
+ const declaration = match[0];
2835
+ if (new RegExp(`\\b${member}\\b`).test(declaration)) return source;
2836
+
2837
+ const nextDeclaration = declaration.replace(/;\s*$/, ` | ${member};`);
2838
+ return `${source.slice(0, match.index)}${nextDeclaration}${source.slice(
2839
+ match.index + declaration.length,
2840
+ )}`;
2841
+ }
2842
+
2843
+ function appendTupleTypeMember(
2844
+ source: string,
2845
+ typeName: string,
2846
+ member: string,
2847
+ ): string {
2848
+ const match = new RegExp(`export\\s+type\\s+${typeName}\\s*=\\s*`).exec(
2849
+ source,
2850
+ );
2851
+ if (!match) return source;
2852
+
2853
+ const tupleStart = source.indexOf("[", match.index);
2854
+ if (tupleStart === -1) return source;
2855
+
2856
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
2857
+ if (tupleEnd === -1) return source;
2858
+
2859
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
2860
+ if (tuple.includes(member)) return source;
2861
+
2862
+ const nextTuple = appendToArrayExpression(tuple, [member]);
2863
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(
2864
+ tupleEnd + 1,
2865
+ )}`;
2866
+ }
2867
+
2868
+ function appendGatePolicyMember(source: string, member: string): string {
2869
+ const gateMatch = /\bGatePort\s*</.exec(source);
2870
+ if (!gateMatch) return source;
2871
+
2872
+ const tupleStart = source.indexOf("[", gateMatch.index);
2873
+ if (tupleStart === -1) return source;
2874
+
2875
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
2876
+ if (tupleEnd === -1) return source;
2877
+
2878
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
2879
+ if (tuple.includes(member)) return source;
2880
+
2881
+ const nextTuple = appendToArrayExpression(tuple, [member]);
2882
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(
2883
+ tupleEnd + 1,
2884
+ )}`;
2885
+ }
2886
+
2887
+ function appendCreateGatePolicy(
2888
+ source: string,
2889
+ policy: string,
2890
+ failureMessage: string,
2891
+ ): string {
2892
+ const policyMatch = /\bpolicies\s*:\s*\[/.exec(source);
2893
+ if (!policyMatch) {
2894
+ throw new Error(failureMessage);
2895
+ }
2896
+
2897
+ const arrayStart = source.indexOf("[", policyMatch.index);
2898
+ if (arrayStart === -1) {
2899
+ throw new Error(failureMessage);
2900
+ }
2901
+
2902
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
2903
+ if (arrayEnd === -1) {
2904
+ throw new Error(failureMessage);
2905
+ }
2906
+
2907
+ const policies = source.slice(arrayStart, arrayEnd + 1);
2908
+ if (policies.includes(policy)) return source;
2909
+
2910
+ const nextPolicies = appendToArrayExpression(policies, [policy]);
2911
+ return `${source.slice(0, arrayStart)}${nextPolicies}${source.slice(
2912
+ arrayEnd + 1,
2913
+ )}`;
2914
+ }
2915
+
2187
2916
  function insertDefinePortsProperty(
2188
2917
  source: string,
2189
2918
  property: string,
@@ -2281,7 +3010,20 @@ function definePortsBodySource(source: string): string | undefined {
2281
3010
  function definePortsBodyRange(
2282
3011
  source: string,
2283
3012
  ): { openBrace: number; closeBrace: number } | undefined {
2284
- const match = /definePorts(?:<[^>]+>)?\(\s*\{/.exec(source);
3013
+ const outer = definePortsOuterBodyRange(source);
3014
+ if (!outer) return undefined;
3015
+
3016
+ // The curried `definePorts<AppPorts>()({ bound, deferred })` form binds app
3017
+ // ports inside `bound`; generated bindings belong there.
3018
+ return (
3019
+ topLevelBoundObjectRange(source, outer.openBrace, outer.closeBrace) ?? outer
3020
+ );
3021
+ }
3022
+
3023
+ function definePortsOuterBodyRange(
3024
+ source: string,
3025
+ ): { openBrace: number; closeBrace: number } | undefined {
3026
+ const match = /definePorts(?:<[^>]+>)?\(\s*(?:\)\s*\(\s*)?\{/.exec(source);
2285
3027
  if (!match) return undefined;
2286
3028
 
2287
3029
  const openBrace = source.indexOf("{", match.index);
@@ -2291,6 +3033,50 @@ function definePortsBodyRange(
2291
3033
  return { openBrace, closeBrace };
2292
3034
  }
2293
3035
 
3036
+ function appendDeferredPortKey(source: string, portKey: string): string {
3037
+ const outer = definePortsOuterBodyRange(source);
3038
+ if (!outer) return source;
3039
+
3040
+ const body = source.slice(outer.openBrace + 1, outer.closeBrace);
3041
+ if (!hasTopLevelObjectProperty(body, "deferred")) return source;
3042
+
3043
+ const deferredMatch = /\bdeferred\s*:\s*\[/.exec(body);
3044
+ if (!deferredMatch) return source;
3045
+
3046
+ const arrayStart = source.indexOf(
3047
+ "[",
3048
+ outer.openBrace + 1 + deferredMatch.index,
3049
+ );
3050
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
3051
+ if (arrayEnd === -1) return source;
3052
+
3053
+ const tuple = source.slice(arrayStart, arrayEnd + 1);
3054
+ if (new RegExp(`["']${portKey}["']`).test(tuple)) return source;
3055
+
3056
+ const nextTuple = appendToArrayExpression(tuple, [`"${portKey}"`]);
3057
+ return `${source.slice(0, arrayStart)}${nextTuple}${source.slice(
3058
+ arrayEnd + 1,
3059
+ )}`;
3060
+ }
3061
+
3062
+ function topLevelBoundObjectRange(
3063
+ source: string,
3064
+ openBrace: number,
3065
+ closeBrace: number,
3066
+ ): { openBrace: number; closeBrace: number } | undefined {
3067
+ const body = source.slice(openBrace + 1, closeBrace);
3068
+ if (!hasTopLevelObjectProperty(body, "bound")) return undefined;
3069
+
3070
+ const boundMatch = /\bbound\s*:\s*\{/.exec(body);
3071
+ if (!boundMatch) return undefined;
3072
+
3073
+ const boundOpen = source.indexOf("{", openBrace + 1 + boundMatch.index);
3074
+ const boundClose = matchingBraceIndex(source, boundOpen);
3075
+ if (boundClose === -1) return undefined;
3076
+
3077
+ return { openBrace: boundOpen, closeBrace: boundClose };
3078
+ }
3079
+
2294
3080
  function hasTopLevelObjectProperty(
2295
3081
  source: string,
2296
3082
  propertyName: string,
@@ -2593,6 +3379,21 @@ function resourceNames(input: string): ResourceNames {
2593
3379
  return names;
2594
3380
  }
2595
3381
 
3382
+ function featureUiNames(input: string): FeatureUiNames {
3383
+ const names = resourceNames(input);
3384
+ const resolvedNames = {
3385
+ ...names,
3386
+ componentExportName: `${names.pluralPascal}Panel`,
3387
+ componentFileName: `${names.pluralKebab}-panel`,
3388
+ };
3389
+
3390
+ assertGeneratedIdentifiers(input, "Feature UI name", [
3391
+ resolvedNames.componentExportName,
3392
+ ]);
3393
+
3394
+ return resolvedNames;
3395
+ }
3396
+
2596
3397
  function featureResourceNames(names: FeatureArtifactNames): ResourceNames {
2597
3398
  return resourceNames(names.feature.kebab);
2598
3399
  }
@@ -2737,6 +3538,21 @@ function jobNames(input: string): JobNames {
2737
3538
  };
2738
3539
  }
2739
3540
 
3541
+ function taskNames(input: string): TaskNames {
3542
+ const names = featureArtifactNames(input, "Task");
3543
+ const taskExportName = `${names.artifact.camel}Task`;
3544
+
3545
+ assertGeneratedIdentifiers(input, "Task name", [taskExportName]);
3546
+
3547
+ return {
3548
+ ...names,
3549
+ taskName: names.stableName,
3550
+ taskExportName,
3551
+ inputSchemaName: `${names.artifact.pascal}TaskInputSchema`,
3552
+ inputTypeName: `${names.artifact.pascal}TaskInput`,
3553
+ };
3554
+ }
3555
+
2740
3556
  function factoryNames(input: string): FactoryNames {
2741
3557
  const names = featureArtifactNames(input, "Factory");
2742
3558
  const factoryExportName = `${names.artifact.camel}Factory`;
@@ -2986,10 +3802,21 @@ function pascalCase(value: string): string {
2986
3802
  return `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
2987
3803
  }
2988
3804
 
3805
+ function constantCase(value: string): string {
3806
+ return value.replaceAll("-", "_").toUpperCase();
3807
+ }
3808
+
2989
3809
  function resourceFiles(
2990
3810
  names: ResourceNames,
2991
3811
  config: ResolvedBeignetConfig,
2992
3812
  persistence: ResourcePersistence = "memory",
3813
+ mode: ResourceGenerationMode = "feature",
3814
+ options: ResourceGenerationOptions = {
3815
+ auth: false,
3816
+ tenant: false,
3817
+ events: false,
3818
+ softDelete: false,
3819
+ },
2993
3820
  ): GeneratedFile[] {
2994
3821
  const useCaseDir = resourceUseCaseDir(names, config);
2995
3822
  const infraDir = infrastructureDir(config);
@@ -2997,27 +3824,63 @@ function resourceFiles(
2997
3824
  const files: GeneratedFile[] = [
2998
3825
  {
2999
3826
  path: resourceContractFilePath(names, config),
3000
- content: contractFile(names, config),
3827
+ content: contractFile(names, config, mode, options),
3001
3828
  },
3002
3829
  {
3003
- path: path.join(useCaseDir, "schemas.ts"),
3004
- content: schemasFile(names),
3830
+ path: path.join(featureDir, "schemas.ts"),
3831
+ content: schemasFile(names, mode, options),
3005
3832
  },
3006
3833
  {
3007
3834
  path: path.join(useCaseDir, `list-${names.pluralKebab}.ts`),
3008
- content: listUseCaseFile(names, config),
3835
+ content: listUseCaseFile(names, config, options),
3009
3836
  },
3010
3837
  {
3011
3838
  path: path.join(useCaseDir, `create-${names.singularKebab}.ts`),
3012
- content: createUseCaseFile(names, config),
3839
+ content: createUseCaseFile(names, config, options),
3013
3840
  },
3841
+ ...(mode === "resource"
3842
+ ? [
3843
+ {
3844
+ path: path.join(useCaseDir, `get-${names.singularKebab}.ts`),
3845
+ content: getUseCaseFile(names, config, options),
3846
+ },
3847
+ {
3848
+ path: path.join(useCaseDir, `update-${names.singularKebab}.ts`),
3849
+ content: updateUseCaseFile(names, config, options),
3850
+ },
3851
+ {
3852
+ path: path.join(useCaseDir, `delete-${names.singularKebab}.ts`),
3853
+ content: deleteUseCaseFile(names, config, options),
3854
+ },
3855
+ {
3856
+ path: path.join(featureDir, "policy.ts"),
3857
+ content: resourcePolicyFile(names, config, options),
3858
+ },
3859
+ ]
3860
+ : []),
3861
+ ...(mode === "resource" && options.events
3862
+ ? [
3863
+ {
3864
+ path: path.join(featureDir, "domain/events/index.ts"),
3865
+ content: resourceEventsFile(names),
3866
+ },
3867
+ ]
3868
+ : []),
3869
+ ...(mode === "resource" && options.auth
3870
+ ? [
3871
+ {
3872
+ path: path.join(featureDir, "tests/policy.test.ts"),
3873
+ content: resourcePolicyTestFile(names, options),
3874
+ },
3875
+ ]
3876
+ : []),
3014
3877
  {
3015
3878
  path: path.join(useCaseDir, "index.ts"),
3016
- content: useCasesIndexFile(names),
3879
+ content: useCasesIndexFile(names, mode),
3017
3880
  },
3018
3881
  {
3019
3882
  path: resourcePortFilePath(names, config),
3020
- content: repositoryPortFile(names, config),
3883
+ content: repositoryPortFile(names, config, mode, options),
3021
3884
  },
3022
3885
  {
3023
3886
  path: path.join(
@@ -3025,15 +3888,15 @@ function resourceFiles(
3025
3888
  names.pluralKebab,
3026
3889
  `in-memory-${names.singularKebab}-repository.ts`,
3027
3890
  ),
3028
- content: inMemoryRepositoryFile(names, config),
3891
+ content: inMemoryRepositoryFile(names, config, mode, options),
3029
3892
  },
3030
3893
  {
3031
3894
  path: path.join(featureDir, "routes.ts"),
3032
- content: routeGroupFile(names, config),
3895
+ content: routeGroupFile(names, config, mode, options),
3033
3896
  },
3034
3897
  {
3035
3898
  path: resourceTestFilePath(names, config),
3036
- content: testFile(names, config),
3899
+ content: testFile(names, config, mode, options),
3037
3900
  },
3038
3901
  ];
3039
3902
 
@@ -3041,11 +3904,11 @@ function resourceFiles(
3041
3904
  files.push(
3042
3905
  {
3043
3906
  path: drizzleResourceSchemaFilePath(names, config),
3044
- content: drizzleSchemaFile(names),
3907
+ content: drizzleSchemaFile(names, options),
3045
3908
  },
3046
3909
  {
3047
3910
  path: drizzleResourceRepositoryFilePath(names, config),
3048
- content: drizzleRepositoryFile(names, config),
3911
+ content: drizzleRepositoryFile(names, config, mode, options),
3049
3912
  },
3050
3913
  );
3051
3914
  }
@@ -3053,6 +3916,31 @@ function resourceFiles(
3053
3916
  return files;
3054
3917
  }
3055
3918
 
3919
+ function featureUiComponentFiles(
3920
+ names: FeatureUiNames,
3921
+ config: ResolvedBeignetConfig,
3922
+ ): GeneratedFile[] {
3923
+ const componentPath = featureUiComponentFilePath(names, config);
3924
+
3925
+ return [
3926
+ {
3927
+ path: componentPath,
3928
+ content: featureUiComponentFile(names, config),
3929
+ },
3930
+ ];
3931
+ }
3932
+
3933
+ function featureUiIndexFile(
3934
+ names: FeatureUiNames,
3935
+ config: ResolvedBeignetConfig,
3936
+ ): GeneratedFile {
3937
+ return {
3938
+ path: featureUiIndexFilePath(names, config),
3939
+ content: `export { ${names.componentExportName} } from "./${names.componentFileName}";
3940
+ `,
3941
+ };
3942
+ }
3943
+
3056
3944
  function contractFiles(
3057
3945
  names: ResourceNames,
3058
3946
  config: ResolvedBeignetConfig,
@@ -3151,6 +4039,18 @@ function jobFiles(
3151
4039
  ];
3152
4040
  }
3153
4041
 
4042
+ function taskFiles(
4043
+ names: TaskNames,
4044
+ config: ResolvedBeignetConfig,
4045
+ ): GeneratedFile[] {
4046
+ return [
4047
+ {
4048
+ path: taskFilePath(names, config),
4049
+ content: taskFile(names, config),
4050
+ },
4051
+ ];
4052
+ }
4053
+
3154
4054
  function notificationFiles(
3155
4055
  names: NotificationNames,
3156
4056
  config: ResolvedBeignetConfig,
@@ -3283,14 +4183,38 @@ function drizzleResourceRepositoryFilePath(
3283
4183
  );
3284
4184
  }
3285
4185
 
3286
- async function detectResourcePersistence(
3287
- targetDir: string,
3288
- config: ResolvedBeignetConfig,
3289
- ): Promise<ResourcePersistence> {
3290
- return (await fileExists(
3291
- path.join(targetDir, drizzleRepositoriesPath(config)),
3292
- ))
3293
- ? "drizzle"
4186
+ function resourceSharedErrorsPath(config: ResolvedBeignetConfig): string {
4187
+ return path.join(config.paths.features, "shared", "errors.ts");
4188
+ }
4189
+
4190
+ function resourceContractNames(
4191
+ names: ResourceNames,
4192
+ mode: ResourceGenerationMode,
4193
+ ): string[] {
4194
+ const contractNames = [
4195
+ `list${names.pluralPascal}`,
4196
+ `create${names.singularPascal}`,
4197
+ ];
4198
+
4199
+ if (mode === "resource") {
4200
+ contractNames.push(
4201
+ `get${names.singularPascal}`,
4202
+ `update${names.singularPascal}`,
4203
+ `delete${names.singularPascal}`,
4204
+ );
4205
+ }
4206
+
4207
+ return contractNames;
4208
+ }
4209
+
4210
+ async function detectResourcePersistence(
4211
+ targetDir: string,
4212
+ config: ResolvedBeignetConfig,
4213
+ ): Promise<ResourcePersistence> {
4214
+ return (await fileExists(
4215
+ path.join(targetDir, drizzleRepositoriesPath(config)),
4216
+ ))
4217
+ ? "drizzle"
3294
4218
  : "memory";
3295
4219
  }
3296
4220
 
@@ -3301,6 +4225,36 @@ function resourceFeatureDir(
3301
4225
  return path.join(config.paths.features, names.pluralKebab);
3302
4226
  }
3303
4227
 
4228
+ function featureUiDir(
4229
+ names: FeatureUiNames,
4230
+ config: ResolvedBeignetConfig,
4231
+ ): string {
4232
+ return path.join(resourceFeatureDir(names, config), "components");
4233
+ }
4234
+
4235
+ function featureUiComponentFilePath(
4236
+ names: FeatureUiNames,
4237
+ config: ResolvedBeignetConfig,
4238
+ ): string {
4239
+ return path.join(
4240
+ featureUiDir(names, config),
4241
+ `${names.componentFileName}.tsx`,
4242
+ );
4243
+ }
4244
+
4245
+ function featureUiIndexFilePath(
4246
+ names: FeatureUiNames,
4247
+ config: ResolvedBeignetConfig,
4248
+ ): string {
4249
+ return path.join(featureUiDir(names, config), "index.ts");
4250
+ }
4251
+
4252
+ function clientIndexPath(config: ResolvedBeignetConfig): string {
4253
+ const appRoot = path.posix.dirname(config.paths.appContext);
4254
+
4255
+ return path.join(appRoot === "." ? "" : appRoot, "client/index.ts");
4256
+ }
4257
+
3304
4258
  function usesFeatureOwnedContracts(config: ResolvedBeignetConfig): boolean {
3305
4259
  return (
3306
4260
  directoryPath(config.paths.contracts) ===
@@ -3365,6 +4319,13 @@ function resourceUseCaseIndexPath(
3365
4319
  return path.join(resourceUseCaseDir(names, config), "index.ts");
3366
4320
  }
3367
4321
 
4322
+ function resourceSchemaFilePath(
4323
+ names: ResourceNames,
4324
+ config: ResolvedBeignetConfig,
4325
+ ): string {
4326
+ return path.join(resourceFeatureDir(names, config), "schemas.ts");
4327
+ }
4328
+
3368
4329
  function resourcePortFilePath(
3369
4330
  names: ResourceNames,
3370
4331
  config: ResolvedBeignetConfig,
@@ -3430,6 +4391,7 @@ function featureArtifactDir(
3430
4391
  kind:
3431
4392
  | "events"
3432
4393
  | "factories"
4394
+ | "tasks"
3433
4395
  | "jobs"
3434
4396
  | "notifications"
3435
4397
  | "listeners"
@@ -3467,6 +4429,13 @@ function jobFilePath(names: JobNames, config: ResolvedBeignetConfig): string {
3467
4429
  );
3468
4430
  }
3469
4431
 
4432
+ function taskFilePath(names: TaskNames, config: ResolvedBeignetConfig): string {
4433
+ return path.join(
4434
+ featureArtifactDir(names, "tasks", config),
4435
+ `${names.artifact.kebab}.ts`,
4436
+ );
4437
+ }
4438
+
3470
4439
  function factoryFilePath(
3471
4440
  names: FactoryNames,
3472
4441
  config: ResolvedBeignetConfig,
@@ -3585,11 +4554,36 @@ async function updateUseCaseIndex(
3585
4554
  return "updated";
3586
4555
  }
3587
4556
 
4557
+ async function updateFeatureUiIndex(
4558
+ targetDir: string,
4559
+ file: GeneratedFile,
4560
+ options: { dryRun: boolean },
4561
+ ): Promise<WriteGeneratedFileResult> {
4562
+ const destination = path.join(targetDir, file.path);
4563
+ const existing = await readOptionalFile(destination);
4564
+ if (existing === undefined) {
4565
+ if (!options.dryRun) {
4566
+ await mkdir(path.dirname(destination), { recursive: true });
4567
+ await writeFile(destination, file.content);
4568
+ }
4569
+ return "created";
4570
+ }
4571
+
4572
+ if (existing.includes(file.content.trim())) {
4573
+ return "skipped";
4574
+ }
4575
+
4576
+ const next = `${existing.trimEnd()}\n${file.content}`;
4577
+ if (!options.dryRun) await writeFile(destination, next);
4578
+ return "updated";
4579
+ }
4580
+
3588
4581
  type FeatureArtifactIndexFile = {
3589
4582
  path: string;
3590
4583
  kind:
3591
4584
  | "event"
3592
4585
  | "job"
4586
+ | "task"
3593
4587
  | "factory"
3594
4588
  | "seed"
3595
4589
  | "notification"
@@ -3606,6 +4600,7 @@ function featureArtifactIndexFile(
3606
4600
  kind:
3607
4601
  | "event"
3608
4602
  | "job"
4603
+ | "task"
3609
4604
  | "factory"
3610
4605
  | "seed"
3611
4606
  | "notification"
@@ -3615,6 +4610,7 @@ function featureArtifactIndexFile(
3615
4610
  names:
3616
4611
  | EventNames
3617
4612
  | JobNames
4613
+ | TaskNames
3618
4614
  | FactoryNames
3619
4615
  | SeedNames
3620
4616
  | NotificationNames
@@ -3628,49 +4624,55 @@ function featureArtifactIndexFile(
3628
4624
  ? eventFilePath(names as EventNames, config)
3629
4625
  : kind === "job"
3630
4626
  ? jobFilePath(names as JobNames, config)
3631
- : kind === "factory"
3632
- ? factoryFilePath(names as FactoryNames, config)
3633
- : kind === "seed"
3634
- ? seedFilePath(names as SeedNames, config)
3635
- : kind === "notification"
3636
- ? notificationFilePath(names as NotificationNames, config)
3637
- : kind === "listener"
3638
- ? listenerFilePath(names as ListenerNames, config)
3639
- : kind === "schedule"
3640
- ? scheduleFilePath(names as ScheduleNames, config)
3641
- : uploadFilePath(names as UploadNames, config);
4627
+ : kind === "task"
4628
+ ? taskFilePath(names as TaskNames, config)
4629
+ : kind === "factory"
4630
+ ? factoryFilePath(names as FactoryNames, config)
4631
+ : kind === "seed"
4632
+ ? seedFilePath(names as SeedNames, config)
4633
+ : kind === "notification"
4634
+ ? notificationFilePath(names as NotificationNames, config)
4635
+ : kind === "listener"
4636
+ ? listenerFilePath(names as ListenerNames, config)
4637
+ : kind === "schedule"
4638
+ ? scheduleFilePath(names as ScheduleNames, config)
4639
+ : uploadFilePath(names as UploadNames, config);
3642
4640
  const registrySuffix =
3643
4641
  kind === "event"
3644
4642
  ? "Events"
3645
4643
  : kind === "job"
3646
4644
  ? "Jobs"
3647
- : kind === "factory"
3648
- ? "Factories"
3649
- : kind === "seed"
3650
- ? "Seeds"
3651
- : kind === "notification"
3652
- ? "Notifications"
3653
- : kind === "listener"
3654
- ? "Listeners"
3655
- : kind === "schedule"
3656
- ? "Schedules"
3657
- : "Uploads";
4645
+ : kind === "task"
4646
+ ? "Tasks"
4647
+ : kind === "factory"
4648
+ ? "Factories"
4649
+ : kind === "seed"
4650
+ ? "Seeds"
4651
+ : kind === "notification"
4652
+ ? "Notifications"
4653
+ : kind === "listener"
4654
+ ? "Listeners"
4655
+ : kind === "schedule"
4656
+ ? "Schedules"
4657
+ : "Uploads";
3658
4658
  const importName =
3659
4659
  kind === "event"
3660
4660
  ? (names as EventNames).eventExportName
3661
4661
  : kind === "job"
3662
4662
  ? (names as JobNames).jobExportName
3663
- : kind === "factory"
3664
- ? (names as FactoryNames).factoryExportName
3665
- : kind === "seed"
3666
- ? (names as SeedNames).seedExportName
3667
- : kind === "notification"
3668
- ? (names as NotificationNames).notificationExportName
3669
- : kind === "listener"
3670
- ? (names as ListenerNames).listenerExportName
3671
- : kind === "schedule"
3672
- ? (names as ScheduleNames).scheduleExportName
3673
- : (names as UploadNames).uploadExportName;
4663
+ : kind === "task"
4664
+ ? (names as TaskNames).taskExportName
4665
+ : kind === "factory"
4666
+ ? (names as FactoryNames).factoryExportName
4667
+ : kind === "seed"
4668
+ ? (names as SeedNames).seedExportName
4669
+ : kind === "notification"
4670
+ ? (names as NotificationNames).notificationExportName
4671
+ : kind === "listener"
4672
+ ? (names as ListenerNames).listenerExportName
4673
+ : kind === "schedule"
4674
+ ? (names as ScheduleNames).scheduleExportName
4675
+ : (names as UploadNames).uploadExportName;
3674
4676
 
3675
4677
  return {
3676
4678
  path: path.join(path.dirname(artifactPath), "index.ts"),
@@ -3786,6 +4788,150 @@ export const ${file.registryName} = [${file.importName}] as const;
3786
4788
  return "updated";
3787
4789
  }
3788
4790
 
4791
+ async function updateTaskRegistry(
4792
+ targetDir: string,
4793
+ names: TaskNames,
4794
+ config: ResolvedBeignetConfig,
4795
+ options: { dryRun: boolean },
4796
+ ): Promise<WriteGeneratedFileResult> {
4797
+ const destination = path.join(targetDir, config.paths.tasks);
4798
+ const existing = await readOptionalFile(destination);
4799
+ const featureTasksPath = path.join(
4800
+ featureArtifactDir(names, "tasks", config),
4801
+ "index.ts",
4802
+ );
4803
+ const registryName = `${names.featureSingularCamel}Tasks`;
4804
+ const importLine = `import { ${registryName} } from "${relativeModule(
4805
+ config.paths.tasks,
4806
+ featureTasksPath,
4807
+ )}";`;
4808
+ const defineTasksImport = `import { defineTasks } from "@beignet/core/tasks";`;
4809
+ const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
4810
+ const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
4811
+ const serverImport = `import { server } from "${relativeModule(
4812
+ config.paths.tasks,
4813
+ config.paths.server,
4814
+ )}";`;
4815
+ const registryEntry = `...${registryName}`;
4816
+
4817
+ if (existing === undefined) {
4818
+ const content = `${defineTasksImport}
4819
+ ${serviceActorImport}
4820
+ ${appContextImport}
4821
+ ${importLine}
4822
+ ${serverImport}
4823
+
4824
+ export const tasks = defineTasks([
4825
+ \t${registryEntry},
4826
+ ] as const);
4827
+
4828
+ export async function createTaskContext(): Promise<AppContext> {
4829
+ \treturn server.createServiceContext({
4830
+ \t\tactor: createServiceActor("beignet-cli"),
4831
+ \t});
4832
+ }
4833
+
4834
+ export async function stopTaskContext(): Promise<void> {
4835
+ \tawait server.stop();
4836
+ }
4837
+ `;
4838
+ if (!options.dryRun) {
4839
+ await mkdir(path.dirname(destination), { recursive: true });
4840
+ await writeFile(destination, content);
4841
+ }
4842
+ return "created";
4843
+ }
4844
+
4845
+ let next = existing;
4846
+ if (!next.includes(defineTasksImport)) {
4847
+ next = insertAfterImports(next, defineTasksImport);
4848
+ }
4849
+ if (!next.includes(importLine)) {
4850
+ next = insertAfterImports(next, importLine);
4851
+ }
4852
+
4853
+ const registry = arrayInitializerInfo(next, "tasks");
4854
+ if (!registry) {
4855
+ next = `${next.trimEnd()}\n\nexport const tasks = defineTasks([\n\t${registryEntry},\n] as const);\n`;
4856
+ } else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
4857
+ const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
4858
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(
4859
+ registry.end,
4860
+ )}`;
4861
+ }
4862
+
4863
+ if (next === existing) return "skipped";
4864
+ if (!options.dryRun) await writeFile(destination, next);
4865
+ return "updated";
4866
+ }
4867
+
4868
+ async function updateScheduleRegistry(
4869
+ targetDir: string,
4870
+ names: ScheduleNames,
4871
+ config: ResolvedBeignetConfig,
4872
+ options: { dryRun: boolean },
4873
+ ): Promise<WriteGeneratedFileResult> {
4874
+ const destination = path.join(targetDir, config.paths.schedules);
4875
+ const existing = await readOptionalFile(destination);
4876
+ const featureSchedulesPath = path.join(
4877
+ featureArtifactDir(names, "schedules", config),
4878
+ "index.ts",
4879
+ );
4880
+ const registryName = `${names.featureSingularCamel}Schedules`;
4881
+ const importLine = `import { ${registryName} } from "${aliasModule(featureSchedulesPath)}";`;
4882
+ const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
4883
+ const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
4884
+ const serverImport = `import { server } from "${relativeModule(
4885
+ config.paths.schedules,
4886
+ config.paths.server,
4887
+ )}";`;
4888
+ const registryEntry = `...${registryName}`;
4889
+
4890
+ if (existing === undefined) {
4891
+ const content = `${serviceActorImport}
4892
+ ${appContextImport}
4893
+ ${importLine}
4894
+ ${serverImport}
4895
+
4896
+ export const schedules = [${registryEntry}] as const;
4897
+
4898
+ export async function createScheduleContext(): Promise<AppContext> {
4899
+ \treturn server.createServiceContext({
4900
+ \t\tactor: createServiceActor("beignet-schedule"),
4901
+ \t});
4902
+ }
4903
+
4904
+ export async function stopScheduleContext(): Promise<void> {
4905
+ \tawait server.stop();
4906
+ }
4907
+ `;
4908
+ if (!options.dryRun) {
4909
+ await mkdir(path.dirname(destination), { recursive: true });
4910
+ await writeFile(destination, content);
4911
+ }
4912
+ return "created";
4913
+ }
4914
+
4915
+ let next = existing;
4916
+ if (!next.includes(importLine)) {
4917
+ next = insertAfterImports(next, importLine);
4918
+ }
4919
+
4920
+ const registry = arrayInitializerInfo(next, "schedules");
4921
+ if (!registry) {
4922
+ next = `${next.trimEnd()}\n\nexport const schedules = [${registryEntry}] as const;\n`;
4923
+ } else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
4924
+ const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
4925
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(
4926
+ registry.end,
4927
+ )}`;
4928
+ }
4929
+
4930
+ if (next === existing) return "skipped";
4931
+ if (!options.dryRun) await writeFile(destination, next);
4932
+ return "updated";
4933
+ }
4934
+
3789
4935
  function arrayInitializerInfo(
3790
4936
  source: string,
3791
4937
  constName: string,
@@ -3828,27 +4974,110 @@ function defineUploadsInitializerInfo(
3828
4974
  };
3829
4975
  }
3830
4976
 
3831
- function schemasFile(names: ResourceNames): string {
4977
+ function schemasFile(
4978
+ names: ResourceNames,
4979
+ mode: ResourceGenerationMode,
4980
+ options: ResourceGenerationOptions,
4981
+ ): string {
3832
4982
  return `import { z } from "zod";
3833
4983
 
3834
4984
  export const ${names.singularPascal}Schema = z.object({
3835
4985
  id: z.string().uuid(),
3836
- name: z.string().min(1),
4986
+ ${options.tenant ? `\ttenantId: z.string().min(1),\n` : ""} name: z.string().min(1),
4987
+ version: z.number().int().min(1),
3837
4988
  createdAt: z.string().datetime(),
4989
+ updatedAt: z.string().datetime(),
3838
4990
  });
3839
4991
 
3840
- export const List${names.pluralPascal}InputSchema = z.object({
3841
- limit: z.coerce.number().int().min(1).max(100).default(20),
3842
- offset: z.coerce.number().int().min(0).default(0),
4992
+ export const ${names.singularPascal}SortBySchema = z.enum(["createdAt", "name"]);
4993
+ export const ${names.singularPascal}SortDirectionSchema = z.enum(["asc", "desc"]);
4994
+
4995
+ const ${names.singularPascal}CursorPayloadSchema = z.object({
4996
+ sortBy: ${names.singularPascal}SortBySchema,
4997
+ sortDirection: ${names.singularPascal}SortDirectionSchema,
4998
+ sortValue: z.string(),
4999
+ id: z.string().uuid(),
3843
5000
  });
3844
5001
 
5002
+ export type ${names.singularPascal}SortBy = z.infer<
5003
+ typeof ${names.singularPascal}SortBySchema
5004
+ >;
5005
+ export type ${names.singularPascal}SortDirection = z.infer<
5006
+ typeof ${names.singularPascal}SortDirectionSchema
5007
+ >;
5008
+ export type ${names.singularPascal}Cursor = z.infer<
5009
+ typeof ${names.singularPascal}CursorPayloadSchema
5010
+ >;
5011
+
5012
+ function encodeBase64Url(value: string): string {
5013
+ const bytes = new TextEncoder().encode(value);
5014
+ let binary = "";
5015
+ for (const byte of bytes) binary += String.fromCharCode(byte);
5016
+
5017
+ return btoa(binary)
5018
+ .replaceAll("+", "-")
5019
+ .replaceAll("/", "_")
5020
+ .replaceAll("=", "");
5021
+ }
5022
+
5023
+ function decodeBase64Url(value: string): string {
5024
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
5025
+ const padding = "=".repeat((4 - (base64.length % 4)) % 4);
5026
+ const binary = atob(base64 + padding);
5027
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
5028
+
5029
+ return new TextDecoder().decode(bytes);
5030
+ }
5031
+
5032
+ export function encode${names.singularPascal}Cursor(cursor: ${names.singularPascal}Cursor): string {
5033
+ return encodeBase64Url(JSON.stringify(cursor));
5034
+ }
5035
+
5036
+ export function decode${names.singularPascal}Cursor(cursor: string): ${names.singularPascal}Cursor {
5037
+ return ${names.singularPascal}CursorPayloadSchema.parse(
5038
+ JSON.parse(decodeBase64Url(cursor)),
5039
+ );
5040
+ }
5041
+
5042
+ export const List${names.pluralPascal}InputSchema = z
5043
+ .object({
5044
+ limit: z.coerce.number().int().min(1).max(100).default(20),
5045
+ cursor: z.string().nullable().default(null),
5046
+ name: z.string().trim().min(1).max(120).optional(),
5047
+ sortBy: ${names.singularPascal}SortBySchema.default("createdAt"),
5048
+ sortDirection: ${names.singularPascal}SortDirectionSchema.default("desc"),
5049
+ })
5050
+ .superRefine((input, ctx) => {
5051
+ if (!input.cursor) return;
5052
+
5053
+ try {
5054
+ const cursor = decode${names.singularPascal}Cursor(input.cursor);
5055
+ if (
5056
+ cursor.sortBy !== input.sortBy ||
5057
+ cursor.sortDirection !== input.sortDirection
5058
+ ) {
5059
+ ctx.addIssue({
5060
+ code: z.ZodIssueCode.custom,
5061
+ message: "cursor does not match the requested sort.",
5062
+ path: ["cursor"],
5063
+ });
5064
+ }
5065
+ } catch {
5066
+ ctx.addIssue({
5067
+ code: z.ZodIssueCode.custom,
5068
+ message: "cursor is invalid.",
5069
+ path: ["cursor"],
5070
+ });
5071
+ }
5072
+ });
5073
+
3845
5074
  export const List${names.pluralPascal}OutputSchema = z.object({
3846
5075
  items: z.array(${names.singularPascal}Schema),
3847
5076
  page: z.object({
3848
- kind: z.literal("offset"),
5077
+ kind: z.literal("cursor"),
3849
5078
  limit: z.number().int().min(1),
3850
- offset: z.number().int().min(0),
3851
- total: z.number().int().min(0),
5079
+ cursor: z.string().nullable(),
5080
+ nextCursor: z.string().nullable(),
3852
5081
  hasMore: z.boolean(),
3853
5082
  }),
3854
5083
  });
@@ -3856,11 +5085,42 @@ export const List${names.pluralPascal}OutputSchema = z.object({
3856
5085
  export const Create${names.singularPascal}InputSchema = z.object({
3857
5086
  name: z.string().min(1).max(120),
3858
5087
  });
5088
+ ${
5089
+ mode === "resource"
5090
+ ? `
5091
+ export const ${names.singularPascal}IdInputSchema = z.object({
5092
+ id: z.string().uuid(),
5093
+ });
5094
+
5095
+ export const Update${names.singularPascal}BodySchema = z.object({
5096
+ name: z.string().min(1).max(120),
5097
+ version: z.number().int().min(1),
5098
+ });
3859
5099
 
5100
+ export const Update${names.singularPascal}InputSchema =
5101
+ ${names.singularPascal}IdInputSchema.merge(Update${names.singularPascal}BodySchema);
5102
+ `
5103
+ : ""
5104
+ }
3860
5105
  export type ${names.singularPascal} = z.infer<typeof ${names.singularPascal}Schema>;
3861
5106
  export type Create${names.singularPascal}Input = z.infer<
3862
5107
  typeof Create${names.singularPascal}InputSchema
3863
5108
  >;
5109
+ ${
5110
+ mode === "resource"
5111
+ ? `
5112
+ export type ${names.singularPascal}IdInput = z.infer<
5113
+ typeof ${names.singularPascal}IdInputSchema
5114
+ >;
5115
+ export type Update${names.singularPascal}Body = z.infer<
5116
+ typeof Update${names.singularPascal}BodySchema
5117
+ >;
5118
+ export type Update${names.singularPascal}Input = z.infer<
5119
+ typeof Update${names.singularPascal}InputSchema
5120
+ >;
5121
+ `
5122
+ : ""
5123
+ }
3864
5124
  export type List${names.pluralPascal}Input = z.infer<
3865
5125
  typeof List${names.pluralPascal}InputSchema
3866
5126
  >;
@@ -3870,30 +5130,39 @@ export type List${names.pluralPascal}Input = z.infer<
3870
5130
  function listUseCaseFile(
3871
5131
  names: ResourceNames,
3872
5132
  config: ResolvedBeignetConfig,
5133
+ options: ResourceGenerationOptions,
3873
5134
  ): string {
3874
5135
  const filePath = path.join(
3875
5136
  resourceUseCaseDir(names, config),
3876
5137
  `list-${names.pluralKebab}.ts`,
3877
5138
  );
3878
5139
 
3879
- return `import { normalizeOffsetPage } from "@beignet/core/pagination";
5140
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5141
+ import { normalizeCursorPage } from "@beignet/core/pagination";
3880
5142
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3881
5143
  import {
5144
+ decode${names.singularPascal}Cursor,
3882
5145
  List${names.pluralPascal}InputSchema,
3883
5146
  List${names.pluralPascal}OutputSchema,
3884
- } from "./schemas";
5147
+ } from "../schemas";
3885
5148
 
3886
5149
  export const list${names.pluralPascal}UseCase = useCase
3887
5150
  .query("${names.pluralCamel}.list")
3888
5151
  .input(List${names.pluralPascal}InputSchema)
3889
5152
  .output(List${names.pluralPascal}OutputSchema)
3890
5153
  .run(async ({ ctx, input }) => {
3891
- const page = normalizeOffsetPage(input, {
5154
+ ${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, {
3892
5155
  defaultLimit: 20,
3893
5156
  maxLimit: 100,
3894
5157
  });
3895
5158
 
3896
- return ctx.ports.${names.pluralCamel}.list(page);
5159
+ return ctx.ports.${names.pluralCamel}.list({
5160
+ page,
5161
+ cursor: page.cursor ? decode${names.singularPascal}Cursor(page.cursor) : null,
5162
+ name: input.name,
5163
+ sortBy: input.sortBy,
5164
+ sortDirection: input.sortDirection,
5165
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
3897
5166
  });
3898
5167
  `;
3899
5168
  }
@@ -3901,134 +5170,430 @@ export const list${names.pluralPascal}UseCase = useCase
3901
5170
  function createUseCaseFile(
3902
5171
  names: ResourceNames,
3903
5172
  config: ResolvedBeignetConfig,
5173
+ options: ResourceGenerationOptions,
3904
5174
  ): string {
3905
5175
  const filePath = path.join(
3906
5176
  resourceUseCaseDir(names, config),
3907
5177
  `create-${names.singularKebab}.ts`,
3908
5178
  );
3909
5179
 
3910
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
5180
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5181
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3911
5182
  import {
3912
5183
  Create${names.singularPascal}InputSchema,
3913
5184
  ${names.singularPascal}Schema,
3914
- } from "./schemas";
5185
+ } from "../schemas";
5186
+ ${options.events ? `import { ${names.singularPascal}Created } from "../domain/events";\n` : ""}
3915
5187
 
3916
5188
  export const create${names.singularPascal}UseCase = useCase
3917
5189
  .command("${names.pluralCamel}.create")
3918
5190
  .input(Create${names.singularPascal}InputSchema)
3919
5191
  .output(${names.singularPascal}Schema)
3920
- .run(async ({ ctx, input }) => ctx.ports.${names.pluralCamel}.create(input));
5192
+ .run(async ({ ctx, input }) => {
5193
+ ${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({
5194
+ ...input,
5195
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
5196
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Created, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
5197
+ return ${names.singularCamel};
5198
+ });
5199
+ `;
5200
+ }
5201
+
5202
+ function getUseCaseFile(
5203
+ names: ResourceNames,
5204
+ config: ResolvedBeignetConfig,
5205
+ options: ResourceGenerationOptions,
5206
+ ): string {
5207
+ const filePath = path.join(
5208
+ resourceUseCaseDir(names, config),
5209
+ `get-${names.singularKebab}.ts`,
5210
+ );
5211
+
5212
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5213
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
5214
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
5215
+ import {
5216
+ ${names.singularPascal}IdInputSchema,
5217
+ ${names.singularPascal}Schema,
5218
+ } from "../schemas";
5219
+
5220
+ export const get${names.singularPascal}UseCase = useCase
5221
+ .query("${names.pluralCamel}.get")
5222
+ .input(${names.singularPascal}IdInputSchema)
5223
+ .output(${names.singularPascal}Schema)
5224
+ .run(async ({ ctx, input }) => {
5225
+ ${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 }" : ""});
5226
+ if (!${names.singularCamel}) {
5227
+ throw appError("${names.singularPascal}NotFound", {
5228
+ details: { id: input.id },
5229
+ });
5230
+ }
5231
+
5232
+ return ${names.singularCamel};
5233
+ });
5234
+ `;
5235
+ }
5236
+
5237
+ function updateUseCaseFile(
5238
+ names: ResourceNames,
5239
+ config: ResolvedBeignetConfig,
5240
+ options: ResourceGenerationOptions,
5241
+ ): string {
5242
+ const filePath = path.join(
5243
+ resourceUseCaseDir(names, config),
5244
+ `update-${names.singularKebab}.ts`,
5245
+ );
5246
+
5247
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5248
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
5249
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
5250
+ ${options.events ? `import { ${names.singularPascal}Updated } from "../domain/events";\n` : ""}import {
5251
+ Update${names.singularPascal}InputSchema,
5252
+ ${names.singularPascal}Schema,
5253
+ } from "../schemas";
5254
+
5255
+ export const update${names.singularPascal}UseCase = useCase
5256
+ .command("${names.pluralCamel}.update")
5257
+ .input(Update${names.singularPascal}InputSchema)
5258
+ .output(${names.singularPascal}Schema)
5259
+ .run(async ({ ctx, input }) => {
5260
+ ${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 }" : ""});
5261
+ if (!existing) {
5262
+ throw appError("${names.singularPascal}NotFound", {
5263
+ details: { id: input.id },
5264
+ });
5265
+ }
5266
+ ${options.auth ? `\t\tawait ctx.gate.authorize("${names.pluralCamel}.update", existing);\n\n` : ""} if (existing.version !== input.version) {
5267
+ throw appError("${names.singularPascal}Conflict", {
5268
+ details: { id: input.id, expectedVersion: existing.version },
5269
+ });
5270
+ }
5271
+
5272
+ const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.update({
5273
+ ...input,
5274
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
5275
+ if (!${names.singularCamel}) {
5276
+ throw appError("${names.singularPascal}Conflict", {
5277
+ details: { id: input.id, expectedVersion: existing.version },
5278
+ });
5279
+ }
5280
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Updated, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
5281
+ return ${names.singularCamel};
5282
+ });
5283
+ `;
5284
+ }
5285
+
5286
+ function deleteUseCaseFile(
5287
+ names: ResourceNames,
5288
+ config: ResolvedBeignetConfig,
5289
+ options: ResourceGenerationOptions,
5290
+ ): string {
5291
+ const filePath = path.join(
5292
+ resourceUseCaseDir(names, config),
5293
+ `delete-${names.singularKebab}.ts`,
5294
+ );
5295
+
5296
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5297
+ import { z } from "zod";
5298
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
5299
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
5300
+ ${options.events ? `import { ${names.singularPascal}Deleted } from "../domain/events";\n` : ""}import { ${names.singularPascal}IdInputSchema } from "../schemas";
5301
+
5302
+ export const delete${names.singularPascal}UseCase = useCase
5303
+ .command("${names.pluralCamel}.delete")
5304
+ .input(${names.singularPascal}IdInputSchema)
5305
+ .output(z.void())
5306
+ .run(async ({ ctx, input }) => {
5307
+ ${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 }" : ""});
5308
+ if (!deleted) {
5309
+ throw appError("${names.singularPascal}NotFound", {
5310
+ details: { id: input.id },
5311
+ });
5312
+ }
5313
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Deleted, {\n\t\t\tid: input.id,\n\t\t});\n` : ""} });
3921
5314
  `;
3922
5315
  }
3923
5316
 
3924
- function useCasesIndexFile(names: ResourceNames): string {
5317
+ function useCasesIndexFile(
5318
+ names: ResourceNames,
5319
+ mode: ResourceGenerationMode,
5320
+ ): string {
3925
5321
  return `export { create${names.singularPascal}UseCase } from "./create-${names.singularKebab}";
3926
- export { list${names.pluralPascal}UseCase } from "./list-${names.pluralKebab}";
3927
- export {
5322
+ ${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}";
5323
+ ${mode === "resource" ? `export { update${names.singularPascal}UseCase } from "./update-${names.singularKebab}";\n` : ""}export {
3928
5324
  Create${names.singularPascal}InputSchema,
3929
- List${names.pluralPascal}InputSchema,
5325
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n` : ""} List${names.pluralPascal}InputSchema,
3930
5326
  List${names.pluralPascal}OutputSchema,
3931
- ${names.singularPascal}Schema,
5327
+ ${mode === "resource" ? ` Update${names.singularPascal}BodySchema,\n Update${names.singularPascal}InputSchema,\n` : ""} ${names.singularPascal}Schema,
3932
5328
  type Create${names.singularPascal}Input,
3933
- type List${names.pluralPascal}Input,
5329
+ ${mode === "resource" ? ` type ${names.singularPascal}IdInput,\n type Update${names.singularPascal}Body,\n type Update${names.singularPascal}Input,\n` : ""} type List${names.pluralPascal}Input,
3934
5330
  type ${names.singularPascal},
3935
- } from "./schemas";
5331
+ } from "../schemas";
3936
5332
  `;
3937
5333
  }
3938
5334
 
3939
5335
  function repositoryPortFile(
3940
5336
  names: ResourceNames,
3941
5337
  config: ResolvedBeignetConfig,
5338
+ mode: ResourceGenerationMode,
5339
+ options: ResourceGenerationOptions,
3942
5340
  ): string {
3943
5341
  return `import type {
3944
- OffsetPage,
3945
- OffsetPageInfo,
5342
+ CursorPage,
5343
+ CursorPageInfo,
3946
5344
  PageResult,
5345
+ SortDirection,
3947
5346
  } from "@beignet/core/pagination";
3948
5347
  import type {
3949
5348
  Create${names.singularPascal}Input,
3950
- ${names.singularPascal},
3951
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3952
-
3953
- export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, OffsetPageInfo>;
5349
+ ${names.singularPascal}Cursor,
5350
+ ${names.singularPascal}SortBy,
5351
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
5352
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
5353
+
5354
+ export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, CursorPageInfo>;
5355
+ export type List${names.pluralPascal}Query = {
5356
+ page: CursorPage;
5357
+ cursor: ${names.singularPascal}Cursor | null;
5358
+ name?: string;
5359
+ sortBy: ${names.singularPascal}SortBy;
5360
+ sortDirection: SortDirection;
5361
+ ${options.tenant ? `\ttenantId: string;\n` : ""}};
5362
+ ${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` : ""}` : ""}
3954
5363
 
3955
5364
  export interface ${names.singularPascal}Repository {
3956
- list(page: OffsetPage): Promise<List${names.pluralPascal}Result>;
3957
- create(input: Create${names.singularPascal}Input): Promise<${names.singularPascal}>;
3958
- }
5365
+ list(query: List${names.pluralPascal}Query): Promise<List${names.pluralPascal}Result>;
5366
+ create(input: ${options.tenant ? `Create${names.singularPascal}RepositoryInput` : `Create${names.singularPascal}Input`}): Promise<${names.singularPascal}>;
5367
+ ${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` : ""}}
3959
5368
  `;
3960
5369
  }
3961
5370
 
3962
5371
  function inMemoryRepositoryFile(
3963
5372
  names: ResourceNames,
3964
5373
  config: ResolvedBeignetConfig,
5374
+ mode: ResourceGenerationMode,
5375
+ options: ResourceGenerationOptions,
3965
5376
  ): string {
3966
5377
  const repositoryPortPath = resourcePortFilePath(names, config);
5378
+ const recordType = options.softDelete
5379
+ ? `type ${names.singularPascal}Record = ${names.singularPascal} & {
5380
+ \tdeletedAt: string | null;
5381
+ };
3967
5382
 
3968
- return `import { offsetPageResult } from "@beignet/core/pagination";
5383
+ `
5384
+ : "";
5385
+ const mapValueType = options.softDelete
5386
+ ? `${names.singularPascal}Record`
5387
+ : names.singularPascal;
5388
+ const activeFilter = options.softDelete
5389
+ ? `\t\t\t\t.filter((${names.singularCamel}) => ${names.singularCamel}.deletedAt === null)\n`
5390
+ : "";
5391
+ const newDeletedAtField = options.softDelete
5392
+ ? `\t\t\t\tdeletedAt: null,\n`
5393
+ : "";
5394
+ const findDeletedGuard = options.softDelete
5395
+ ? `\t\t\tif (${names.singularCamel}?.deletedAt !== null) return null;\n`
5396
+ : "";
5397
+ const updateDeletedGuard = options.softDelete
5398
+ ? " || existing.deletedAt !== null"
5399
+ : "";
5400
+ const deleteImplementation = options.softDelete
5401
+ ? `${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();
5402
+ ${names.pluralCamel}.set(id, { ...existing, deletedAt: now, updatedAt: now });
5403
+ return true;`
5404
+ : `${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);`;
5405
+
5406
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5407
+ import { cursorPageResult } from "@beignet/core/pagination";
3969
5408
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
5409
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
3970
5410
  import type {
3971
5411
  Create${names.singularPascal}Input,
3972
- ${names.singularPascal},
3973
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
5412
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
5413
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
5414
+
5415
+ ${recordType}function to${names.singularPascal}(${names.singularCamel}: ${mapValueType}): ${names.singularPascal} {
5416
+ return {
5417
+ id: ${names.singularCamel}.id,
5418
+ ${options.tenant ? `\t\ttenantId: ${names.singularCamel}.tenantId,\n` : ""} name: ${names.singularCamel}.name,
5419
+ version: ${names.singularCamel}.version,
5420
+ createdAt: ${names.singularCamel}.createdAt,
5421
+ updatedAt: ${names.singularCamel}.updatedAt,
5422
+ };
5423
+ }
5424
+
5425
+ function compare${names.pluralPascal}(
5426
+ left: ${mapValueType},
5427
+ right: ${mapValueType},
5428
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
5429
+ ): number {
5430
+ const leftValue = left[query.sortBy];
5431
+ const rightValue = right[query.sortBy];
5432
+ const fieldComparison =
5433
+ leftValue === rightValue ? left.id.localeCompare(right.id) : leftValue.localeCompare(rightValue);
5434
+
5435
+ return query.sortDirection === "asc" ? fieldComparison : -fieldComparison;
5436
+ }
5437
+
5438
+ function isAfter${names.singularPascal}Cursor(
5439
+ ${names.singularCamel}: ${mapValueType},
5440
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
5441
+ ): boolean {
5442
+ if (!query.cursor) return true;
5443
+
5444
+ const sortValue = ${names.singularCamel}[query.sortBy];
5445
+ const fieldComparison =
5446
+ sortValue === query.cursor.sortValue
5447
+ ? ${names.singularCamel}.id.localeCompare(query.cursor.id)
5448
+ : sortValue.localeCompare(query.cursor.sortValue);
5449
+
5450
+ return query.sortDirection === "asc" ? fieldComparison > 0 : fieldComparison < 0;
5451
+ }
5452
+
5453
+ function cursorFor${names.singularPascal}(
5454
+ ${names.singularCamel}: ${mapValueType},
5455
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
5456
+ ): string {
5457
+ return encode${names.singularPascal}Cursor({
5458
+ sortBy: query.sortBy,
5459
+ sortDirection: query.sortDirection,
5460
+ sortValue: ${names.singularCamel}[query.sortBy],
5461
+ id: ${names.singularCamel}.id,
5462
+ });
5463
+ }
3974
5464
 
3975
5465
  export function createInMemory${names.singularPascal}Repository(
3976
5466
  seed: ${names.singularPascal}[] = [],
3977
5467
  ): ${names.singularPascal}Repository {
3978
- const ${names.pluralCamel} = new Map(seed.map((${names.singularCamel}) => [${names.singularCamel}.id, ${names.singularCamel}]));
5468
+ const ${names.pluralCamel} = new Map<string, ${mapValueType}>(
5469
+ seed.map((${names.singularCamel}) => [
5470
+ ${names.singularCamel}.id,
5471
+ { ...${names.singularCamel}${options.softDelete ? ", deletedAt: null" : ""} },
5472
+ ]),
5473
+ );
3979
5474
 
3980
5475
  return {
3981
- async list(page) {
3982
- const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values()).sort(
3983
- (left, right) => right.createdAt.localeCompare(left.createdAt),
3984
- );
3985
-
3986
- return offsetPageResult(
3987
- all${names.pluralPascal}.slice(page.offset, page.offset + page.limit),
3988
- page,
3989
- all${names.pluralPascal}.length,
5476
+ async list(query) {
5477
+ const name = query.name?.toLocaleLowerCase();
5478
+ const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values())
5479
+ ${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))
5480
+ .sort((left, right) => compare${names.pluralPascal}(left, right, query))
5481
+ .filter((${names.singularCamel}) => isAfter${names.singularPascal}Cursor(${names.singularCamel}, query));
5482
+ const pageItems = all${names.pluralPascal}.slice(0, query.page.limit);
5483
+ const nextCursor =
5484
+ all${names.pluralPascal}.length > query.page.limit && pageItems.length > 0
5485
+ ? cursorFor${names.singularPascal}(pageItems[pageItems.length - 1]!, query)
5486
+ : null;
5487
+
5488
+ return cursorPageResult(
5489
+ pageItems.map(to${names.singularPascal}),
5490
+ query.page,
5491
+ nextCursor,
3990
5492
  );
3991
5493
  },
3992
- async create(input: Create${names.singularPascal}Input) {
5494
+ async create(input) {
5495
+ const now = new Date().toISOString();
3993
5496
  const ${names.singularCamel}: ${names.singularPascal} = {
3994
5497
  id: crypto.randomUUID(),
3995
- name: input.name,
3996
- createdAt: new Date().toISOString(),
5498
+ ${options.tenant ? `\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
5499
+ version: 1,
5500
+ createdAt: now,
5501
+ updatedAt: now,
3997
5502
  };
3998
- ${names.pluralCamel}.set(${names.singularCamel}.id, ${names.singularCamel});
5503
+ ${names.pluralCamel}.set(${names.singularCamel}.id, {
5504
+ ...${names.singularCamel},
5505
+ ${newDeletedAtField} });
3999
5506
  return ${names.singularCamel};
4000
5507
  },
5508
+ ${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` : ""}
4001
5509
  };
4002
5510
  }
4003
5511
  `;
4004
5512
  }
4005
5513
 
4006
- function drizzleSchemaFile(names: ResourceNames): string {
4007
- return `import { sqliteTable, text } from "drizzle-orm/sqlite-core";
5514
+ function drizzleSchemaFile(
5515
+ names: ResourceNames,
5516
+ options: ResourceGenerationOptions,
5517
+ ): string {
5518
+ return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
4008
5519
 
4009
5520
  export const ${names.pluralCamel} = sqliteTable("${names.pluralKebab.replaceAll("-", "_")}", {
4010
5521
  id: text("id").primaryKey(),
4011
- name: text("name").notNull(),
5522
+ ${options.tenant ? `\ttenantId: text("tenant_id").notNull(),\n` : ""} name: text("name").notNull(),
5523
+ version: integer("version").notNull(),
4012
5524
  createdAt: text("created_at").notNull(),
4013
- });
5525
+ updatedAt: text("updated_at").notNull(),
5526
+ ${options.softDelete ? `\tdeletedAt: text("deleted_at"),\n` : ""}});
4014
5527
  `;
4015
5528
  }
4016
5529
 
5530
+ function drizzleResourceWhere(
5531
+ names: ResourceNames,
5532
+ options: ResourceGenerationOptions,
5533
+ predicates: readonly string[],
5534
+ ): string {
5535
+ const allPredicates = [
5536
+ ...predicates,
5537
+ ...(options.softDelete
5538
+ ? [`isNull(schema.${names.pluralCamel}.deletedAt)`]
5539
+ : []),
5540
+ ];
5541
+
5542
+ if (allPredicates.length === 0) return "";
5543
+ if (allPredicates.length === 1) return allPredicates[0] ?? "";
5544
+ return `and(${allPredicates.join(", ")})`;
5545
+ }
5546
+
4017
5547
  function drizzleRepositoryFile(
4018
5548
  names: ResourceNames,
4019
5549
  config: ResolvedBeignetConfig,
5550
+ mode: ResourceGenerationMode,
5551
+ options: ResourceGenerationOptions,
4020
5552
  ): string {
4021
5553
  const repositoryPortPath = resourcePortFilePath(names, config);
4022
5554
  const schemaPath = drizzleSchemaIndexPath(config);
4023
-
4024
- return `import { offsetPageResult } from "@beignet/core/pagination";
5555
+ const findWhere = drizzleResourceWhere(names, options, [
5556
+ `eq(schema.${names.pluralCamel}.id, id)`,
5557
+ ...(options.tenant
5558
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
5559
+ : []),
5560
+ ]);
5561
+ const updateWhere = drizzleResourceWhere(names, options, [
5562
+ `eq(schema.${names.pluralCamel}.id, input.id)`,
5563
+ `eq(schema.${names.pluralCamel}.version, input.version)`,
5564
+ ...(options.tenant
5565
+ ? [`eq(schema.${names.pluralCamel}.tenantId, input.tenantId)`]
5566
+ : []),
5567
+ ]);
5568
+ const deleteWhere = drizzleResourceWhere(names, options, [
5569
+ `eq(schema.${names.pluralCamel}.id, id)`,
5570
+ ...(options.tenant
5571
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
5572
+ : []),
5573
+ ]);
5574
+ const drizzleImports = [
5575
+ "and",
5576
+ "asc",
5577
+ "desc",
5578
+ "eq",
5579
+ "gt",
5580
+ options.softDelete ? "isNull" : undefined,
5581
+ "lt",
5582
+ "or",
5583
+ "sql",
5584
+ "type SQL",
5585
+ ].filter((name): name is string => Boolean(name));
5586
+
5587
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5588
+ import { cursorPageResult } from "@beignet/core/pagination";
4025
5589
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
4026
- import { count, desc } from "drizzle-orm";
5590
+ import { ${drizzleImports.join(", ")} } from "drizzle-orm";
4027
5591
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
5592
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
4028
5593
  import type {
4029
5594
  Create${names.singularPascal}Input,
4030
- ${names.singularPascal},
4031
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
5595
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
5596
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
4032
5597
  import * as schema from "${aliasModule(schemaPath)}";
4033
5598
 
4034
5599
  type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelect;
@@ -4036,35 +5601,104 @@ type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelec
4036
5601
  function to${names.singularPascal}(row: ${names.singularPascal}Row): ${names.singularPascal} {
4037
5602
  return {
4038
5603
  id: row.id,
4039
- name: row.name,
5604
+ ${options.tenant ? `\t\ttenantId: row.tenantId,\n` : ""} name: row.name,
5605
+ version: row.version,
4040
5606
  createdAt: row.createdAt,
5607
+ updatedAt: row.updatedAt,
4041
5608
  };
4042
5609
  }
4043
5610
 
5611
+ type List${names.pluralPascal}Query = Parameters<${names.singularPascal}Repository["list"]>[0];
5612
+
5613
+ function ${names.singularCamel}SortColumn(query: List${names.pluralPascal}Query) {
5614
+ return query.sortBy === "name" ? schema.${names.pluralCamel}.name : schema.${names.pluralCamel}.createdAt;
5615
+ }
5616
+
5617
+ function ${names.singularCamel}CursorFilter(
5618
+ query: List${names.pluralPascal}Query,
5619
+ ): SQL<unknown> | undefined {
5620
+ if (!query.cursor) return undefined;
5621
+
5622
+ const column = ${names.singularCamel}SortColumn(query);
5623
+ const compare = query.sortDirection === "asc" ? gt : lt;
5624
+
5625
+ return or(
5626
+ compare(column, query.cursor.sortValue),
5627
+ and(
5628
+ eq(column, query.cursor.sortValue),
5629
+ compare(schema.${names.pluralCamel}.id, query.cursor.id),
5630
+ ),
5631
+ ) as SQL<unknown>;
5632
+ }
5633
+
5634
+ function ${names.singularCamel}ListWhere(
5635
+ query: List${names.pluralPascal}Query,
5636
+ ): SQL<unknown> | undefined {
5637
+ const filters: SQL<unknown>[] = [
5638
+ ${options.tenant ? `\t\teq(schema.${names.pluralCamel}.tenantId, query.tenantId),\n` : ""}${options.softDelete ? `\t\tisNull(schema.${names.pluralCamel}.deletedAt),\n` : ""} ];
5639
+ const cursor = ${names.singularCamel}CursorFilter(query);
5640
+
5641
+ if (query.name) {
5642
+ const pattern = \`%\${query.name.toLocaleLowerCase()}%\`;
5643
+ filters.push(sql\`lower(\${schema.${names.pluralCamel}.name}) like \${pattern}\`);
5644
+ }
5645
+ if (cursor) filters.push(cursor);
5646
+
5647
+ return filters.length > 0 ? and(...filters) : undefined;
5648
+ }
5649
+
5650
+ function ${names.singularCamel}OrderBy(query: List${names.pluralPascal}Query) {
5651
+ const order = query.sortDirection === "asc" ? asc : desc;
5652
+
5653
+ return [order(${names.singularCamel}SortColumn(query)), order(schema.${names.pluralCamel}.id)] as const;
5654
+ }
5655
+
5656
+ function cursorFor${names.singularPascal}(
5657
+ row: ${names.singularPascal}Row,
5658
+ query: List${names.pluralPascal}Query,
5659
+ ): string {
5660
+ return encode${names.singularPascal}Cursor({
5661
+ sortBy: query.sortBy,
5662
+ sortDirection: query.sortDirection,
5663
+ sortValue: row[query.sortBy],
5664
+ id: row.id,
5665
+ });
5666
+ }
5667
+
4044
5668
  export function createDrizzle${names.singularPascal}Repository(
4045
5669
  db: DrizzleTursoDatabase<typeof schema>,
4046
5670
  ): ${names.singularPascal}Repository {
4047
5671
  return {
4048
- async list(page) {
5672
+ async list(query) {
5673
+ const where = ${names.singularCamel}ListWhere(query);
4049
5674
  const rows = await db
4050
5675
  .select()
4051
5676
  .from(schema.${names.pluralCamel})
4052
- .orderBy(desc(schema.${names.pluralCamel}.createdAt))
4053
- .limit(page.limit)
4054
- .offset(page.offset);
4055
- const [{ total }] = await db
4056
- .select({ total: count() })
4057
- .from(schema.${names.pluralCamel});
4058
-
4059
- return offsetPageResult(rows.map(to${names.singularPascal}), page, total);
5677
+ .where(where)
5678
+ .orderBy(...${names.singularCamel}OrderBy(query))
5679
+ .limit(query.page.limit + 1);
5680
+ const pageRows = rows.slice(0, query.page.limit);
5681
+ const nextCursor =
5682
+ rows.length > query.page.limit && pageRows.length > 0
5683
+ ? cursorFor${names.singularPascal}(pageRows[pageRows.length - 1]!, query)
5684
+ : null;
5685
+
5686
+ return cursorPageResult(
5687
+ pageRows.map(to${names.singularPascal}),
5688
+ query.page,
5689
+ nextCursor,
5690
+ );
4060
5691
  },
4061
- async create(input: Create${names.singularPascal}Input) {
5692
+ async create(input) {
5693
+ const now = new Date().toISOString();
4062
5694
  const [row] = await db
4063
5695
  .insert(schema.${names.pluralCamel})
4064
5696
  .values({
4065
5697
  id: crypto.randomUUID(),
4066
- name: input.name,
4067
- createdAt: new Date().toISOString(),
5698
+ ${options.tenant ? `\t\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
5699
+ version: 1,
5700
+ createdAt: now,
5701
+ updatedAt: now,
4068
5702
  })
4069
5703
  .returning();
4070
5704
 
@@ -4074,6 +5708,7 @@ export function createDrizzle${names.singularPascal}Repository(
4074
5708
 
4075
5709
  return to${names.singularPascal}(row);
4076
5710
  },
5711
+ ${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` : ""}
4077
5712
  };
4078
5713
  }
4079
5714
  `;
@@ -4082,13 +5717,17 @@ export function createDrizzle${names.singularPascal}Repository(
4082
5717
  function contractFile(
4083
5718
  names: ResourceNames,
4084
5719
  config: ResolvedBeignetConfig,
5720
+ mode: ResourceGenerationMode,
5721
+ options: ResourceGenerationOptions,
4085
5722
  ): string {
4086
- return `import { createContractGroup } from "@beignet/core/contracts";
5723
+ return `import { defineContractGroup } from "@beignet/core/contracts";
4087
5724
  import { z } from "zod";
4088
- import {
4089
- create${names.singularPascal}UseCase,
4090
- list${names.pluralPascal}UseCase,
4091
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
5725
+ ${mode === "resource" ? `import { errors } from "${aliasModule(resourceSharedErrorsPath(config))}";\n` : ""}import {
5726
+ Create${names.singularPascal}InputSchema,
5727
+ List${names.pluralPascal}InputSchema,
5728
+ List${names.pluralPascal}OutputSchema,
5729
+ ${names.singularPascal}Schema,
5730
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n Update${names.singularPascal}BodySchema,\n` : ""}} from "${aliasModule(resourceSchemaFilePath(names, config))}";
4092
5731
 
4093
5732
  const ErrorResponseSchema = z.object({
4094
5733
  code: z.string(),
@@ -4096,7 +5735,7 @@ const ErrorResponseSchema = z.object({
4096
5735
  requestId: z.string().optional(),
4097
5736
  });
4098
5737
 
4099
- const ${names.pluralCamel} = createContractGroup()
5738
+ const ${names.pluralCamel} = defineContractGroup()
4100
5739
  .namespace("${names.pluralCamel}")
4101
5740
  .responses({
4102
5741
  500: ErrorResponseSchema,
@@ -4104,22 +5743,52 @@ const ${names.pluralCamel} = createContractGroup()
4104
5743
 
4105
5744
  export const list${names.pluralPascal} = ${names.pluralCamel}
4106
5745
  .get("/api/${names.pluralKebab}")
4107
- .query(list${names.pluralPascal}UseCase.inputSchema)
5746
+ .query(List${names.pluralPascal}InputSchema)
4108
5747
  .responses({
4109
- 200: list${names.pluralPascal}UseCase.outputSchema,
5748
+ 200: List${names.pluralPascal}OutputSchema,
4110
5749
  });
4111
5750
 
4112
5751
  export const create${names.singularPascal} = ${names.pluralCamel}
4113
5752
  .post("/api/${names.pluralKebab}")
4114
- .body(create${names.singularPascal}UseCase.inputSchema)
5753
+ .body(Create${names.singularPascal}InputSchema)
5754
+ ${options.auth ? ` .meta({\n auth: "required",\n authorization: { ability: "${names.pluralCamel}.create" },\n })\n .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden })\n` : ""} .responses({
5755
+ 201: ${names.singularPascal}Schema,
5756
+ });
5757
+ ${
5758
+ mode === "resource"
5759
+ ? `
5760
+ export const get${names.singularPascal} = ${names.pluralCamel}
5761
+ .get("/api/${names.pluralKebab}/:id")
5762
+ .pathParams(${names.singularPascal}IdInputSchema)
5763
+ .errors({ ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound })
4115
5764
  .responses({
4116
- 201: create${names.singularPascal}UseCase.outputSchema,
5765
+ 200: ${names.singularPascal}Schema,
4117
5766
  });
5767
+
5768
+ export const update${names.singularPascal} = ${names.pluralCamel}
5769
+ .patch("/api/${names.pluralKebab}/:id")
5770
+ .pathParams(${names.singularPascal}IdInputSchema)
5771
+ .body(Update${names.singularPascal}BodySchema)
5772
+ ${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 })
5773
+ .responses({
5774
+ 200: ${names.singularPascal}Schema,
5775
+ });
5776
+
5777
+ export const delete${names.singularPascal} = ${names.pluralCamel}
5778
+ .delete("/api/${names.pluralKebab}/:id")
5779
+ .pathParams(${names.singularPascal}IdInputSchema)
5780
+ ${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 })
5781
+ .responses({
5782
+ 204: null,
5783
+ });
5784
+ `
5785
+ : ""
5786
+ }
4118
5787
  `;
4119
5788
  }
4120
5789
 
4121
5790
  function standaloneContractFile(names: ResourceNames): string {
4122
- return `import { createContractGroup } from "@beignet/core/contracts";
5791
+ return `import { defineContractGroup } from "@beignet/core/contracts";
4123
5792
  import { z } from "zod";
4124
5793
 
4125
5794
  const ErrorResponseSchema = z.object({
@@ -4128,7 +5797,7 @@ const ErrorResponseSchema = z.object({
4128
5797
  requestId: z.string().optional(),
4129
5798
  });
4130
5799
 
4131
- const ${names.pluralCamel} = createContractGroup()
5800
+ const ${names.pluralCamel} = defineContractGroup()
4132
5801
  .namespace("${names.pluralCamel}")
4133
5802
  .responses({
4134
5803
  500: ErrorResponseSchema,
@@ -4169,7 +5838,8 @@ function standaloneUseCaseFile(
4169
5838
  config: ResolvedBeignetConfig,
4170
5839
  filePath: string,
4171
5840
  ): string {
4172
- return `import { z } from "zod";
5841
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5842
+ import { z } from "zod";
4173
5843
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
4174
5844
 
4175
5845
  export const ${names.action.pascal}InputSchema = z.object({});
@@ -4202,29 +5872,27 @@ function useCaseTestFile(
4202
5872
  return `import { describe, expect, it } from "bun:test";
4203
5873
  import { createUseCaseTester } from "@beignet/core/application";
4204
5874
  import { createInMemoryDevtools } from "@beignet/devtools";
4205
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
5875
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
5876
+ import { createTestAnonymousActor } from "@beignet/core/ports/testing";
4206
5877
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4207
5878
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
4208
5879
  import { ${names.exportName} } from "${relativeModule(filePath, useCaseFilePath(names, config))}";
4209
5880
 
4210
5881
  describe("${names.exportName}", () => {
4211
5882
  it("runs ${names.action.camel}", async () => {
4212
- const testPorts = {
4213
- ...appPorts,
4214
- uow: createNoopUnitOfWork(
4215
- () => appPorts as unknown as AppContext["ports"],
4216
- ) as unknown as AppContext["ports"]["uow"],
4217
- devtools: createInMemoryDevtools(),
4218
- storage: createMemoryStorage(),
4219
- } as AppContext["ports"];
4220
- const actor = createAnonymousActor();
4221
- const tester = createUseCaseTester<AppContext>(() => ({
4222
- requestId: "test-request",
4223
- actor,
4224
- auth: null,
4225
- gate: testPorts.gate.bind({ actor, auth: null }),
4226
- ports: testPorts,
4227
- }));
5883
+ const testFixture = createTestPorts<AppContext["ports"]>({
5884
+ base: appPorts,
5885
+ overrides: {
5886
+ gate: appPorts.gate,
5887
+ devtools: createInMemoryDevtools(),
5888
+ },
5889
+ });
5890
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
5891
+ ports: testFixture.ports,
5892
+ actor: createTestAnonymousActor(),
5893
+ tenant: null,
5894
+ });
5895
+ const tester = createUseCaseTester<AppContext>(createTestContext);
4228
5896
 
4229
5897
  const result = await tester.run(${names.exportName}, {});
4230
5898
 
@@ -4290,6 +5958,144 @@ export const ${names.policyName} = definePolicy({
4290
5958
  `;
4291
5959
  }
4292
5960
 
5961
+ function resourcePolicyFile(
5962
+ names: ResourceNames,
5963
+ _config: ResolvedBeignetConfig,
5964
+ options: ResourceGenerationOptions,
5965
+ ): string {
5966
+ return `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
5967
+ import { definePolicy, deny } from "@beignet/core/ports";
5968
+
5969
+ export type AuthorizationContext = {
5970
+ actor: ActivityActor;
5971
+ tenant?: ActivityTenant;
5972
+ };
5973
+
5974
+ type ${names.singularPascal}PolicyResource = {
5975
+ id: string;
5976
+ tenantId?: string;
5977
+ };
5978
+
5979
+ function isAuthenticated(ctx: AuthorizationContext) {
5980
+ return ctx.actor.type === "user" && Boolean(ctx.actor.id);
5981
+ }
5982
+
5983
+ function canWrite(ctx: AuthorizationContext) {
5984
+ if (!isAuthenticated(ctx)) return deny("You must be signed in.");
5985
+ ${options.tenant ? `\tif (!ctx.tenant?.id) return deny("A tenant is required.");\n` : ""} return true;
5986
+ }
5987
+
5988
+ export const ${names.singularCamel}Policy = definePolicy({
5989
+ "${names.pluralCamel}.view": (_ctx: AuthorizationContext) => true,
5990
+ "${names.pluralCamel}.create": (ctx: AuthorizationContext) =>
5991
+ canWrite(ctx),
5992
+ "${names.pluralCamel}.update": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
5993
+ const decision = canWrite(ctx);
5994
+ if (decision !== true) return decision;
5995
+ ${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` : ""}
5996
+ return true;
5997
+ },
5998
+ "${names.pluralCamel}.delete": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
5999
+ const decision = canWrite(ctx);
6000
+ if (decision !== true) return decision;
6001
+ ${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` : ""}
6002
+ return true;
6003
+ },
6004
+ "${names.pluralCamel}.manage": (_ctx: AuthorizationContext) =>
6005
+ deny("You are not allowed to manage this ${names.singularKebab}."),
6006
+ });
6007
+ `;
6008
+ }
6009
+
6010
+ function resourceEventsFile(names: ResourceNames): string {
6011
+ return `import { defineEvent } from "@beignet/core/events";
6012
+ import { z } from "zod";
6013
+
6014
+ export const ${names.singularPascal}Created = defineEvent("${names.pluralCamel}.created", {
6015
+ payload: z.object({
6016
+ id: z.string().uuid(),
6017
+ }),
6018
+ });
6019
+
6020
+ export const ${names.singularPascal}Updated = defineEvent("${names.pluralCamel}.updated", {
6021
+ payload: z.object({
6022
+ id: z.string().uuid(),
6023
+ }),
6024
+ });
6025
+
6026
+ export const ${names.singularPascal}Deleted = defineEvent("${names.pluralCamel}.deleted", {
6027
+ payload: z.object({
6028
+ id: z.string().uuid(),
6029
+ }),
6030
+ });
6031
+
6032
+ export const ${names.singularCamel}Events = [
6033
+ ${names.singularPascal}Created,
6034
+ ${names.singularPascal}Updated,
6035
+ ${names.singularPascal}Deleted,
6036
+ ] as const;
6037
+ `;
6038
+ }
6039
+
6040
+ function resourcePolicyTestFile(
6041
+ names: ResourceNames,
6042
+ options: ResourceGenerationOptions,
6043
+ ): string {
6044
+ return `import { describe, expect, it } from "bun:test";
6045
+ import { createPolicyTester } from "@beignet/core/ports/testing";
6046
+ import { ${names.singularCamel}Policy } from "../policy";
6047
+ import type { ${names.singularPascal} } from "../schemas";
6048
+
6049
+ function create${names.singularPascal}(): ${names.singularPascal} {
6050
+ const now = new Date().toISOString();
6051
+ return {
6052
+ id: "00000000-0000-4000-8000-000000000001",
6053
+ ${options.tenant ? `\t\ttenantId: "tenant_test",\n` : ""} name: "Test ${names.singularPascal}",
6054
+ version: 1,
6055
+ createdAt: now,
6056
+ updatedAt: now,
6057
+ };
6058
+ }
6059
+
6060
+ describe("${names.singularCamel}Policy", () => {
6061
+ it("documents generated ${names.pluralKebab} authorization rules", async () => {
6062
+ const tester = createPolicyTester({
6063
+ policies: [${names.singularCamel}Policy],
6064
+ });
6065
+ const ${names.singularCamel} = create${names.singularPascal}();
6066
+ const ctx = {
6067
+ actor: { type: "user" as const, id: "user_test" },
6068
+ ${options.tenant ? `\t\t\ttenant: { id: "tenant_test" },\n` : ""} };
6069
+
6070
+ await expect(
6071
+ tester.assertMatrix([
6072
+ {
6073
+ name: "authenticated actor can create ${names.singularKebab}",
6074
+ ctx,
6075
+ ability: "${names.pluralCamel}.create",
6076
+ expected: "allow",
6077
+ },
6078
+ {
6079
+ name: "authenticated actor can update ${names.singularKebab}",
6080
+ ctx,
6081
+ ability: "${names.pluralCamel}.update",
6082
+ subject: ${names.singularCamel},
6083
+ expected: "allow",
6084
+ },
6085
+ {
6086
+ name: "authenticated actor can delete ${names.singularKebab}",
6087
+ ctx,
6088
+ ability: "${names.pluralCamel}.delete",
6089
+ subject: ${names.singularCamel},
6090
+ expected: "allow",
6091
+ },
6092
+ ]),
6093
+ ).resolves.toBeUndefined();
6094
+ });
6095
+ });
6096
+ `;
6097
+ }
6098
+
4293
6099
  function eventFile(names: EventNames): string {
4294
6100
  return `import { defineEvent } from "@beignet/core/events";
4295
6101
  import { z } from "zod";
@@ -4307,11 +6113,9 @@ export const ${names.eventExportName} = defineEvent("${names.eventName}", {
4307
6113
  }
4308
6114
 
4309
6115
  function jobFile(names: JobNames, config: ResolvedBeignetConfig): string {
4310
- return `import { createJobHandlers, retry } from "@beignet/core/jobs";
6116
+ return `import { retry } from "@beignet/core/jobs";
4311
6117
  import { z } from "zod";
4312
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4313
-
4314
- const jobs = createJobHandlers<AppContext>();
6118
+ import { defineJob } from "${aliasModule(config.paths.jobsBuilder)}";
4315
6119
 
4316
6120
  export const ${names.payloadSchemaName} = z.object({
4317
6121
  \tid: z.string().uuid(),
@@ -4319,7 +6123,7 @@ export const ${names.payloadSchemaName} = z.object({
4319
6123
 
4320
6124
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
4321
6125
 
4322
- export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
6126
+ export const ${names.jobExportName} = defineJob("${names.jobName}", {
4323
6127
  \tpayload: ${names.payloadSchemaName},
4324
6128
  \tretry: retry.exponential({
4325
6129
  \t\tattempts: 3,
@@ -4336,16 +6140,43 @@ export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
4336
6140
  `;
4337
6141
  }
4338
6142
 
6143
+ function taskFile(names: TaskNames, config: ResolvedBeignetConfig): string {
6144
+ return `import { z } from "zod";
6145
+ import { defineTask } from "${aliasModule(config.paths.tasksBuilder)}";
6146
+
6147
+ export const ${names.inputSchemaName} = z.object({
6148
+ \tdryRun: z.boolean().default(true),
6149
+ });
6150
+
6151
+ export type ${names.inputTypeName} = z.infer<typeof ${names.inputSchemaName}>;
6152
+
6153
+ export const ${names.taskExportName} = defineTask("${names.taskName}", {
6154
+ \tinput: ${names.inputSchemaName},
6155
+ \tdescription: "Operational ${names.feature.kebab} ${names.artifact.kebab} task.",
6156
+ \tasync handle({ input, ctx }) {
6157
+ \t\tctx.ports.logger.info("Task handled", {
6158
+ \t\t\ttaskName: "${names.taskName}",
6159
+ \t\t\tdryRun: input.dryRun,
6160
+ \t\t});
6161
+
6162
+ \t\treturn {
6163
+ \t\t\tdryRun: input.dryRun,
6164
+ \t\t};
6165
+ \t},
6166
+ });
6167
+ `;
6168
+ }
6169
+
4339
6170
  function factoryFile(
4340
6171
  names: FactoryNames,
4341
6172
  config: ResolvedBeignetConfig,
4342
6173
  ): string {
4343
6174
  const resource = featureResourceNames(names);
4344
6175
 
4345
- return `import { defineFactory } from "@beignet/core/testing";
6176
+ return `import { createFactory } from "@beignet/core/testing";
4346
6177
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4347
6178
 
4348
- export const ${names.factoryExportName} = defineFactory("${names.factoryName}", {
6179
+ export const ${names.factoryExportName} = createFactory("${names.factoryName}", {
4349
6180
  \tdefaults: ({ sequence }) => ({
4350
6181
  \t\tname: "${resource.singularPascal} " + sequence,
4351
6182
  \t}),
@@ -4375,14 +6206,9 @@ function notificationFile(
4375
6206
  names: NotificationNames,
4376
6207
  config: ResolvedBeignetConfig,
4377
6208
  ): string {
4378
- return `import {
4379
- \tcreateNotificationHandlers,
4380
- \tdefineMailNotificationChannel,
4381
- } from "@beignet/core/notifications";
6209
+ return `import { defineMailNotificationChannel } from "@beignet/core/notifications";
4382
6210
  import { z } from "zod";
4383
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4384
-
4385
- const notifications = createNotificationHandlers<AppContext>();
6211
+ import { defineNotification } from "${aliasModule(config.paths.notificationsBuilder)}";
4386
6212
 
4387
6213
  export const ${names.payloadSchemaName} = z.object({
4388
6214
  \tid: z.string().uuid(),
@@ -4392,7 +6218,7 @@ export const ${names.payloadSchemaName} = z.object({
4392
6218
 
4393
6219
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
4394
6220
 
4395
- export const ${names.notificationExportName} = notifications.defineNotification(
6221
+ export const ${names.notificationExportName} = defineNotification(
4396
6222
  \t"${names.notificationName}",
4397
6223
  \t{
4398
6224
  \t\tpayload: ${names.payloadSchemaName},
@@ -4414,13 +6240,10 @@ function listenerFile(
4414
6240
  ): string {
4415
6241
  const filePath = listenerFilePath(names, config);
4416
6242
 
4417
- return `import { createEventHandlers } from "@beignet/core/events";
4418
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6243
+ return `import { defineListener } from "${aliasModule(config.paths.listenersBuilder)}";
4419
6244
  import { ${names.event.eventExportName} } from "${relativeModule(filePath, eventFilePath(names.event, config))}";
4420
6245
 
4421
- const events = createEventHandlers<AppContext>();
4422
-
4423
- export const ${names.listenerExportName} = events.defineListener(${names.event.eventExportName}, {
6246
+ export const ${names.listenerExportName} = defineListener(${names.event.eventExportName}, {
4424
6247
  \tname: "${names.listenerName}",
4425
6248
  \tasync handle({ payload, ctx }) {
4426
6249
  \t\tctx.ports.logger.info("Listener handled", {
@@ -4436,11 +6259,8 @@ function scheduleFile(
4436
6259
  names: ScheduleNames,
4437
6260
  config: ResolvedBeignetConfig,
4438
6261
  ): string {
4439
- return `import { createScheduleHandlers } from "@beignet/core/schedules";
4440
- import { z } from "zod";
4441
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4442
-
4443
- const schedules = createScheduleHandlers<AppContext>();
6262
+ return `import { z } from "zod";
6263
+ import { defineSchedule } from "${aliasModule(config.paths.schedulesBuilder)}";
4444
6264
 
4445
6265
  export const ${names.payloadSchemaName} = z.object({
4446
6266
  \tdate: z.string(),
@@ -4448,7 +6268,7 @@ export const ${names.payloadSchemaName} = z.object({
4448
6268
 
4449
6269
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
4450
6270
 
4451
- export const ${names.scheduleExportName} = schedules.defineSchedule(
6271
+ export const ${names.scheduleExportName} = defineSchedule(
4452
6272
  \t"${names.scheduleName}",
4453
6273
  \t{
4454
6274
  \t\tcron: "${names.cron}",
@@ -4526,111 +6346,119 @@ export const ${names.uploadExportName} = defineUpload<
4526
6346
  `;
4527
6347
  }
4528
6348
 
6349
+ function featureUiComponentFile(
6350
+ names: FeatureUiNames,
6351
+ config: ResolvedBeignetConfig,
6352
+ ): string {
6353
+ return `"use client";
6354
+
6355
+ import { contractErrorMessage } from "@beignet/core/client";
6356
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6357
+ import { useState } from "react";
6358
+ import { rq } from "${aliasModule(clientIndexPath(config))}";
6359
+ import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(
6360
+ resourceContractFilePath(names, config),
6361
+ )}";
6362
+
6363
+ export function ${names.componentExportName}() {
6364
+ \tconst [name, setName] = useState("");
6365
+ \tconst queryClient = useQueryClient();
6366
+ \tconst ${names.pluralCamel}Query = useQuery(
6367
+ \t\trq(list${names.pluralPascal}).queryOptions({ query: {} }),
6368
+ \t);
6369
+ \tconst create${names.singularPascal}Mutation = useMutation(
6370
+ \t\trq(create${names.singularPascal}).mutationOptions({
6371
+ \t\t\tonSuccess: async () => {
6372
+ \t\t\t\tsetName("");
6373
+ \t\t\t\tawait rq(list${names.pluralPascal}).invalidate(queryClient);
6374
+ \t\t\t},
6375
+ \t\t}),
6376
+ \t);
6377
+
6378
+ \treturn (
6379
+ \t\t<section className="${names.pluralKebab}-panel">
6380
+ \t\t\t<form
6381
+ \t\t\t\tclassName="${names.pluralKebab}-composer"
6382
+ \t\t\t\tonSubmit={(event) => {
6383
+ \t\t\t\t\tevent.preventDefault();
6384
+ \t\t\t\t\tconst trimmedName = name.trim();
6385
+ \t\t\t\t\tif (!trimmedName) return;
6386
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.reset();
6387
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.mutate({
6388
+ \t\t\t\t\t\tbody: { name: trimmedName },
6389
+ \t\t\t\t\t});
6390
+ \t\t\t\t}}
6391
+ \t\t\t>
6392
+ \t\t\t\t<label htmlFor="${names.singularKebab}-name">New ${names.singularKebab}</label>
6393
+ \t\t\t\t<div className="${names.pluralKebab}-composer-row">
6394
+ \t\t\t\t\t<input
6395
+ \t\t\t\t\t\tid="${names.singularKebab}-name"
6396
+ \t\t\t\t\t\tvalue={name}
6397
+ \t\t\t\t\t\tonChange={(event) => setName(event.currentTarget.value)}
6398
+ \t\t\t\t\t\tplaceholder="Name"
6399
+ \t\t\t\t\t/>
6400
+ \t\t\t\t\t<button
6401
+ \t\t\t\t\t\ttype="submit"
6402
+ \t\t\t\t\t\tdisabled={!name.trim() || create${names.singularPascal}Mutation.isPending}
6403
+ \t\t\t\t\t>
6404
+ \t\t\t\t\t\t{create${names.singularPascal}Mutation.isPending ? "Creating" : "Create"}
6405
+ \t\t\t\t\t</button>
6406
+ \t\t\t\t</div>
6407
+ \t\t\t\t{create${names.singularPascal}Mutation.isError ? (
6408
+ \t\t\t\t\t<p role="alert">
6409
+ \t\t\t\t\t\t{contractErrorMessage(
6410
+ \t\t\t\t\t\t\tcreate${names.singularPascal}Mutation.error,
6411
+ \t\t\t\t\t\t\t"Could not create ${names.singularKebab}.",
6412
+ \t\t\t\t\t\t)}
6413
+ \t\t\t\t\t</p>
6414
+ \t\t\t\t) : null}
6415
+ \t\t\t</form>
6416
+
6417
+ \t\t\t<div className="${names.pluralKebab}-list">
6418
+ \t\t\t\t<div className="${names.pluralKebab}-list-heading">
6419
+ \t\t\t\t\t<h2>${names.pluralPascal}</h2>
6420
+ \t\t\t\t\t<span>{${names.pluralCamel}Query.data?.items.length ?? 0} shown</span>
6421
+ \t\t\t\t</div>
6422
+ \t\t\t\t{${names.pluralCamel}Query.isLoading ? <p>Loading...</p> : null}
6423
+ \t\t\t\t{${names.pluralCamel}Query.isError ? (
6424
+ \t\t\t\t\t<p role="alert">Could not load ${names.pluralKebab}.</p>
6425
+ \t\t\t\t) : null}
6426
+ \t\t\t\t{${names.pluralCamel}Query.data?.items.length === 0 ? (
6427
+ \t\t\t\t\t<p>No ${names.pluralKebab} yet.</p>
6428
+ \t\t\t\t) : null}
6429
+ \t\t\t\t<ul>
6430
+ \t\t\t\t\t{${names.pluralCamel}Query.data?.items.map((${names.singularCamel}) => (
6431
+ \t\t\t\t\t\t<li key={${names.singularCamel}.id}>
6432
+ \t\t\t\t\t\t\t<strong>{${names.singularCamel}.name}</strong>
6433
+ \t\t\t\t\t\t\t<small>{new Date(${names.singularCamel}.createdAt).toLocaleDateString()}</small>
6434
+ \t\t\t\t\t\t</li>
6435
+ \t\t\t\t\t))}
6436
+ \t\t\t\t</ul>
6437
+ \t\t\t</div>
6438
+ \t\t</section>
6439
+ \t);
6440
+ }
6441
+ `;
6442
+ }
6443
+
4529
6444
  function scheduleRouteFile(
4530
6445
  names: ScheduleNames,
4531
6446
  config: ResolvedBeignetConfig,
4532
6447
  ): string {
4533
- return `import { createInlineScheduleRunner } from "@beignet/core/schedules";
4534
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6448
+ return `import { createScheduleRoute } from "@beignet/next";
4535
6449
  import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
4536
6450
  import { env } from "@/lib/env";
4537
6451
  import { server } from "${aliasModule(config.paths.server)}";
4538
6452
 
4539
- async function run${names.artifact.pascal}(request: Request) {
4540
- \tconst cronSecret = env.CRON_SECRET;
6453
+ export const runtime = "nodejs";
4541
6454
 
4542
- \tif (!cronSecret) {
4543
- \t\treturn Response.json(
4544
- \t\t\t{
4545
- \t\t\t\tok: false,
4546
- \t\t\t\terror: "CRON_SECRET is not configured.",
4547
- \t\t\t\tscheduleName: ${names.scheduleExportName}.name,
4548
- \t\t\t},
4549
- \t\t\t{ status: 500 },
4550
- \t\t);
4551
- \t}
4552
-
4553
- \tif (
4554
- \t\trequest.headers.get("authorization") !== \`Bearer \${cronSecret}\`
4555
- \t) {
4556
- \t\treturn Response.json({ error: "Unauthorized" }, { status: 401 });
4557
- \t}
4558
-
4559
- \tconst ctx = await server.createContextFromNext();
4560
- \tconst runner = createInlineScheduleRunner<AppContext>({
4561
- \t\tctx,
4562
- \t\tonStart({ run, schedule }) {
4563
- \t\t\tctx.ports.devtools.record({
4564
- \t\t\t\ttype: "schedule",
4565
- \t\t\t\twatcher: "schedules",
4566
- \t\t\t\trequestId: ctx.requestId,
4567
- \t\t\t\tscheduleName: schedule.name,
4568
- \t\t\t\tstatus: "started",
4569
- \t\t\t\tcron: schedule.cron,
4570
- \t\t\t\ttimezone: schedule.timezone,
4571
- \t\t\t\tdetails: {
4572
- \t\t\t\t\tsource: run.source,
4573
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
4574
- \t\t\t\t},
4575
- \t\t\t});
4576
- \t\t},
4577
- \t\tonSuccess({ run, schedule }) {
4578
- \t\t\tctx.ports.devtools.record({
4579
- \t\t\t\ttype: "schedule",
4580
- \t\t\t\twatcher: "schedules",
4581
- \t\t\t\trequestId: ctx.requestId,
4582
- \t\t\t\tscheduleName: schedule.name,
4583
- \t\t\t\tstatus: "completed",
4584
- \t\t\t\tcron: schedule.cron,
4585
- \t\t\t\ttimezone: schedule.timezone,
4586
- \t\t\t\tdetails: {
4587
- \t\t\t\t\tsource: run.source,
4588
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
4589
- \t\t\t\t},
4590
- \t\t\t});
4591
- \t\t},
4592
- \t\tonError({ error, run, schedule }) {
4593
- \t\t\tctx.ports.devtools.record({
4594
- \t\t\t\ttype: "schedule",
4595
- \t\t\t\twatcher: "schedules",
4596
- \t\t\t\trequestId: ctx.requestId,
4597
- \t\t\t\tscheduleName: schedule.name,
4598
- \t\t\t\tstatus: "failed",
4599
- \t\t\t\tcron: schedule.cron,
4600
- \t\t\t\ttimezone: schedule.timezone,
4601
- \t\t\t\tdetails: {
4602
- \t\t\t\t\terror,
4603
- \t\t\t\t\tsource: run.source,
4604
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
4605
- \t\t\t\t},
4606
- \t\t\t});
4607
- \t\t\tctx.ports.logger.error("Schedule failed", {
4608
- \t\t\t\terror,
4609
- \t\t\t\tscheduleName: schedule.name,
4610
- \t\t\t});
4611
- \t\t},
4612
- \t\tonHookError({ error, hook, schedule }) {
4613
- \t\t\tctx.ports.logger.warn("Schedule lifecycle hook failed", {
4614
- \t\t\t\terror,
4615
- \t\t\t\thook,
4616
- \t\t\t\tscheduleName: schedule.name,
4617
- \t\t\t});
4618
- \t\t},
4619
- \t});
4620
-
4621
- \ttry {
4622
- \t\tawait runner.run(${names.scheduleExportName}, {
4623
- \t\t\tsource: "cron-route",
4624
- \t\t});
4625
- \t} catch {
4626
- \t\treturn Response.json({ error: "Schedule failed" }, { status: 500 });
4627
- \t}
4628
-
4629
- \treturn Response.json({ ok: true });
4630
- }
4631
-
4632
- export const GET = run${names.artifact.pascal};
4633
- export const POST = run${names.artifact.pascal};
6455
+ export const { GET, POST } = createScheduleRoute({
6456
+ \tserver,
6457
+ \tschedules: [${names.scheduleExportName}],
6458
+ \tschedule: ${names.scheduleExportName}.name,
6459
+ \tsecret: env.CRON_SECRET,
6460
+ \tsource: "cron-route",
6461
+ });
4634
6462
  `;
4635
6463
  }
4636
6464
 
@@ -4648,84 +6476,161 @@ function useCaseIndexExport(names: UseCaseNames): string {
4648
6476
  function routeGroupFile(
4649
6477
  names: ResourceNames,
4650
6478
  config: ResolvedBeignetConfig,
6479
+ mode: ResourceGenerationMode,
6480
+ _options: ResourceGenerationOptions,
4651
6481
  ): string {
4652
- return `import { defineRouteGroup } from "@beignet/next";
4653
- import type { AppContext } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), config.paths.appContext)}";
4654
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceContractFilePath(names, config))}";
6482
+ const routeFilePath = path.join(
6483
+ config.paths.features,
6484
+ names.pluralKebab,
6485
+ "routes.ts",
6486
+ );
6487
+ const contractsPath = resourceContractFilePath(names, config);
6488
+ const useCasesPath = resourceUseCaseIndexPath(names, config);
6489
+
6490
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
6491
+ import { defineRouteGroup } from "@beignet/next";
6492
+ import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
6493
+ import {
6494
+ create${names.singularPascal},
6495
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
6496
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${relativeModule(routeFilePath, contractsPath)}";
4655
6497
  import {
4656
6498
  create${names.singularPascal}UseCase,
4657
- list${names.pluralPascal}UseCase,
4658
- } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceUseCaseIndexPath(names, config))}";
6499
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
6500
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${relativeModule(routeFilePath, useCasesPath)}";
4659
6501
 
4660
6502
  export const ${names.singularCamel}Routes = defineRouteGroup<AppContext>({
4661
6503
  name: "${names.pluralKebab}",
4662
6504
  routes: [
4663
- {
4664
- contract: list${names.pluralPascal},
4665
- handle: async ({ ctx, query }) => ({
4666
- status: 200,
4667
- body: await list${names.pluralPascal}UseCase.run({ ctx, input: query }),
4668
- }),
4669
- },
4670
- {
4671
- contract: create${names.singularPascal},
4672
- handle: async ({ ctx, body }) => ({
4673
- status: 201,
4674
- body: await create${names.singularPascal}UseCase.run({ ctx, input: body }),
4675
- }),
4676
- },
4677
- ],
6505
+ { contract: list${names.pluralPascal}, useCase: list${names.pluralPascal}UseCase },
6506
+ { contract: create${names.singularPascal}, useCase: create${names.singularPascal}UseCase },
6507
+ ${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` : ""} ],
4678
6508
  });
4679
6509
  `;
4680
6510
  }
4681
6511
 
4682
- function testFile(names: ResourceNames, config: ResolvedBeignetConfig): string {
6512
+ function testFile(
6513
+ names: ResourceNames,
6514
+ config: ResolvedBeignetConfig,
6515
+ mode: ResourceGenerationMode,
6516
+ options: ResourceGenerationOptions,
6517
+ ): string {
4683
6518
  const repositoryPath = path.join(
4684
6519
  path.dirname(config.paths.infrastructurePorts),
4685
6520
  names.pluralKebab,
4686
6521
  `in-memory-${names.singularKebab}-repository.ts`,
4687
6522
  );
6523
+ const serverContextPath = path.join(
6524
+ path.dirname(config.paths.server),
6525
+ "context.ts",
6526
+ );
6527
+ const requestTarget = options.tenant ? "requester" : "app";
4688
6528
 
4689
6529
  return `import { describe, expect, it } from "bun:test";
4690
6530
  import { createUseCaseTester } from "@beignet/core/application";
4691
- import { defineRoutes } from "@beignet/web";
4692
- import { createTestApp } from "@beignet/web/testing";
6531
+ ${mode === "resource" ? `import { isAppError } from "@beignet/core/errors";\n` : ""}import { defineRoutes } from "@beignet/web";
6532
+ import { createTestApp${options.tenant ? ", createTestRequester" : ""} } from "@beignet/web/testing";
4693
6533
  import { createInMemoryDevtools } from "@beignet/devtools";
4694
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
6534
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
6535
+ import { ${options.auth ? "createTestUserActor" : "createTestAnonymousActor"}${options.tenant ? ", createTestTenant" : ""} } from "@beignet/core/ports/testing";
4695
6536
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4696
6537
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
4697
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
6538
+ import { appContext } from "${aliasModule(serverContextPath)}";
6539
+ import {
6540
+ create${names.singularPascal},
6541
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
6542
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${aliasModule(resourceContractFilePath(names, config))}";
4698
6543
  import { createInMemory${names.singularPascal}Repository } from "${aliasModule(repositoryPath)}";
4699
6544
  import { ${names.singularCamel}Routes } from "${aliasModule(path.join(resourceFeatureDir(names, config), "routes.ts"))}";
4700
6545
  import {
4701
6546
  create${names.singularPascal}UseCase,
4702
- list${names.pluralPascal}UseCase,
4703
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
6547
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
6548
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
4704
6549
 
4705
6550
  describe("${names.pluralCamel} resource", () => {
4706
- it("creates and lists ${names.pluralCamel}", async () => {
4707
- const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();
4708
- const testPorts = {
4709
- ...appPorts,
4710
- ${names.pluralCamel},
4711
- uow: createNoopUnitOfWork(() => ({
4712
- ...(appPorts as unknown as AppContext["ports"]),
6551
+ it("${mode === "resource" ? `exercises ${names.pluralCamel} CRUD` : `creates and lists ${names.pluralCamel}`}", async () => {
6552
+ const seed${names.pluralPascal} = [
6553
+ {
6554
+ id: "00000000-0000-4000-8000-000000000101",
6555
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Alpha ${names.singularPascal}",
6556
+ version: 1,
6557
+ createdAt: "2024-01-01T00:00:00.000Z",
6558
+ updatedAt: "2024-01-01T00:00:00.000Z",
6559
+ },
6560
+ {
6561
+ id: "00000000-0000-4000-8000-000000000102",
6562
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Beta ${names.singularPascal}",
6563
+ version: 1,
6564
+ createdAt: "2024-01-02T00:00:00.000Z",
6565
+ updatedAt: "2024-01-02T00:00:00.000Z",
6566
+ },
6567
+ {
6568
+ id: "00000000-0000-4000-8000-000000000103",
6569
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Gamma ${names.singularPascal}",
6570
+ version: 1,
6571
+ createdAt: "2024-01-03T00:00:00.000Z",
6572
+ updatedAt: "2024-01-03T00:00:00.000Z",
6573
+ },
6574
+ ${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` : ""} ];
6575
+ const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository(seed${names.pluralPascal});
6576
+ const testFixture = createTestPorts<AppContext["ports"]>({
6577
+ base: appPorts,
6578
+ overrides: {
4713
6579
  ${names.pluralCamel},
4714
- })) as unknown as AppContext["ports"]["uow"],
4715
- devtools: createInMemoryDevtools(),
4716
- storage: createMemoryStorage(),
4717
- } as AppContext["ports"];
4718
- const actor = createAnonymousActor();
4719
- const createTestContext = () => ({
4720
- requestId: "test-request",
4721
- actor,
4722
- auth: null,
4723
- gate: testPorts.gate.bind({ actor, auth: null }),
4724
- ports: testPorts,
6580
+ ${
6581
+ options.auth
6582
+ ? `\t\t\t\tauth: {
6583
+ getSession: async () => ({
6584
+ user: { id: "user_test", name: "Test User" },
6585
+ }),
6586
+ },
6587
+ gate: appPorts.gate,\n`
6588
+ : `\t\t\t\tauth: {
6589
+ getSession: async () => null,
6590
+ },\n`
6591
+ } devtools: createInMemoryDevtools(),
6592
+ },
6593
+ transaction: {
6594
+ ports: (ports) => ({
6595
+ ...ports,
6596
+ ${names.pluralCamel},
6597
+ }),
6598
+ },
6599
+ });
6600
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
6601
+ ports: testFixture.ports,
6602
+ actor: ${options.auth ? `createTestUserActor("user_test")` : "createTestAnonymousActor()"},
6603
+ tenant: ${options.tenant ? `createTestTenant("tenant_test")` : "null"},
4725
6604
  });
4726
6605
  const tester = createUseCaseTester<AppContext>(createTestContext);
4727
6606
 
4728
6607
  const ctx = await tester.ctx();
6608
+ const defaultPage = await tester.run(list${names.pluralPascal}UseCase, {}, { ctx });
6609
+ const filteredPage = await tester.run(
6610
+ list${names.pluralPascal}UseCase,
6611
+ { name: "alp" },
6612
+ { ctx },
6613
+ );
6614
+ const firstPage = await tester.run(
6615
+ list${names.pluralPascal}UseCase,
6616
+ { limit: 2 },
6617
+ { ctx },
6618
+ );
6619
+ const secondPage = await tester.run(
6620
+ list${names.pluralPascal}UseCase,
6621
+ { limit: 2, cursor: firstPage.page.nextCursor },
6622
+ { ctx },
6623
+ );
6624
+ const nameAscendingPage = await tester.run(
6625
+ list${names.pluralPascal}UseCase,
6626
+ { sortBy: "name", sortDirection: "asc" },
6627
+ { ctx },
6628
+ );
6629
+ const nameDescendingPage = await tester.run(
6630
+ list${names.pluralPascal}UseCase,
6631
+ { sortBy: "name", sortDirection: "desc" },
6632
+ { ctx },
6633
+ );
4729
6634
  const created = await tester.run(
4730
6635
  create${names.singularPascal}UseCase,
4731
6636
  { name: "First ${names.singularPascal}" },
@@ -4733,35 +6638,113 @@ describe("${names.pluralCamel} resource", () => {
4733
6638
  );
4734
6639
  const result = await tester.run(
4735
6640
  list${names.pluralPascal}UseCase,
4736
- { limit: 20, offset: 0 },
6641
+ { name: "First" },
4737
6642
  { ctx },
4738
6643
  );
4739
6644
 
6645
+ expect(defaultPage.page).toMatchObject({
6646
+ kind: "cursor",
6647
+ limit: 20,
6648
+ cursor: null,
6649
+ nextCursor: null,
6650
+ hasMore: false,
6651
+ });
6652
+ expect(defaultPage.items.map((item) => item.name)).toEqual([
6653
+ "Gamma ${names.singularPascal}",
6654
+ "Beta ${names.singularPascal}",
6655
+ "Alpha ${names.singularPascal}",
6656
+ ]);
6657
+ expect(filteredPage.items.map((item) => item.name)).toEqual([
6658
+ "Alpha ${names.singularPascal}",
6659
+ ]);
6660
+ expect(firstPage.items.map((item) => item.name)).toEqual([
6661
+ "Gamma ${names.singularPascal}",
6662
+ "Beta ${names.singularPascal}",
6663
+ ]);
6664
+ expect(firstPage.page.nextCursor).toEqual(expect.any(String));
6665
+ expect(secondPage.items.map((item) => item.name)).toEqual([
6666
+ "Alpha ${names.singularPascal}",
6667
+ ]);
6668
+ expect(secondPage.page.nextCursor).toBeNull();
6669
+ expect(nameAscendingPage.items.map((item) => item.name)).toEqual([
6670
+ "Alpha ${names.singularPascal}",
6671
+ "Beta ${names.singularPascal}",
6672
+ "Gamma ${names.singularPascal}",
6673
+ ]);
6674
+ expect(nameDescendingPage.items.map((item) => item.name)).toEqual([
6675
+ "Gamma ${names.singularPascal}",
6676
+ "Beta ${names.singularPascal}",
6677
+ "Alpha ${names.singularPascal}",
6678
+ ]);
4740
6679
  expect(created.name).toBe("First ${names.singularPascal}");
4741
- expect(result.page.total).toBe(1);
4742
6680
  expect(result.items).toEqual([created]);
6681
+ ${
6682
+ mode === "resource"
6683
+ ? `
6684
+ const fetched = await tester.run(
6685
+ get${names.singularPascal}UseCase,
6686
+ { id: created.id },
6687
+ { ctx },
6688
+ );
6689
+ const updated = await tester.run(
6690
+ update${names.singularPascal}UseCase,
6691
+ { id: created.id, name: "Updated ${names.singularPascal}", version: fetched.version },
6692
+ { ctx },
6693
+ );
6694
+ try {
6695
+ await tester.run(
6696
+ update${names.singularPascal}UseCase,
6697
+ { id: created.id, name: "Stale ${names.singularPascal}", version: fetched.version },
6698
+ { ctx },
6699
+ );
6700
+ throw new Error("Expected ${names.singularPascal}Conflict");
6701
+ } catch (error) {
6702
+ expect(isAppError(error)).toBe(true);
6703
+ if (isAppError(error)) {
6704
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_CONFLICT");
6705
+ }
6706
+ }
6707
+ await tester.run(delete${names.singularPascal}UseCase, { id: created.id }, { ctx });
6708
+ const afterDelete = await tester.run(
6709
+ list${names.pluralPascal}UseCase,
6710
+ { name: "Updated" },
6711
+ { ctx },
6712
+ );
4743
6713
 
4744
- const app = await createTestApp<AppContext, AppContext["ports"]>({
4745
- ports: testPorts,
6714
+ expect(fetched).toEqual(created);
6715
+ expect(updated.name).toBe("Updated ${names.singularPascal}");
6716
+ expect(updated.version).toBe(fetched.version + 1);
6717
+ expect(afterDelete.items).toEqual([]);
6718
+
6719
+ try {
6720
+ await tester.run(get${names.singularPascal}UseCase, { id: created.id }, { ctx });
6721
+ throw new Error("Expected ${names.singularPascal}NotFound");
6722
+ } catch (error) {
6723
+ expect(isAppError(error)).toBe(true);
6724
+ if (isAppError(error)) {
6725
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_NOT_FOUND");
6726
+ }
6727
+ }
6728
+ `
6729
+ : ""
6730
+ }
6731
+
6732
+ const app = await createTestApp({
6733
+ ports: testFixture.ports,
4746
6734
  routes: defineRoutes<AppContext>([${names.singularCamel}Routes]),
4747
- createContext: async () => createTestContext(),
4748
- mapUnhandledError: () => ({
4749
- status: 500,
4750
- body: { code: "INTERNAL_SERVER_ERROR", message: "Internal server error" },
4751
- }),
6735
+ context: appContext,
4752
6736
  });
4753
-
4754
- const createdViaRoute = await app.request(create${names.singularPascal}, {
6737
+ ${options.tenant ? `\t\tconst requester = createTestRequester(app, {\n\t\t\theaders: { "x-tenant-id": "tenant_test" },\n\t\t});\n` : ""}
6738
+ const createdViaRoute = await ${requestTarget}.request(create${names.singularPascal}, {
4755
6739
  body: { name: "Second ${names.singularPascal}" },
4756
6740
  });
4757
- const listedViaRoute = await app.request(list${names.pluralPascal}, {
4758
- query: { limit: 20, offset: 0 },
6741
+ ${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}, {
6742
+ query: { name: "${mode === "resource" ? "Updated via route" : "Second"}" },
4759
6743
  });
4760
- await app.stop();
6744
+ ${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();
4761
6745
 
4762
6746
  expect(createdViaRoute.name).toBe("Second ${names.singularPascal}");
4763
- expect(listedViaRoute.page.total).toBe(2);
4764
- expect(listedViaRoute.items).toContainEqual(createdViaRoute);
6747
+ ${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`}
4765
6748
  });
4766
6749
  });
4767
6750
  `;