@beignet/cli 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/README.md +379 -61
  3. package/dist/ansi.d.ts +10 -0
  4. package/dist/ansi.d.ts.map +1 -0
  5. package/dist/ansi.js +20 -0
  6. package/dist/ansi.js.map +1 -0
  7. package/dist/choices.d.ts +72 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +88 -0
  10. package/dist/choices.js.map +1 -0
  11. package/dist/completion.d.ts +47 -0
  12. package/dist/completion.d.ts.map +1 -0
  13. package/dist/completion.js +123 -0
  14. package/dist/completion.js.map +1 -0
  15. package/dist/config.d.ts +8 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +8 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/create-prompts.d.ts +42 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +136 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +4 -1
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +16 -26
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +1 -0
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +37 -2
  30. package/dist/db.js.map +1 -1
  31. package/dist/github-annotations.d.ts +18 -0
  32. package/dist/github-annotations.d.ts.map +1 -0
  33. package/dist/github-annotations.js +22 -0
  34. package/dist/github-annotations.js.map +1 -0
  35. package/dist/index.d.ts +1 -9
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +657 -588
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +21 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +1938 -131
  42. package/dist/inspect.js.map +1 -1
  43. package/dist/lib.d.ts +20 -0
  44. package/dist/lib.d.ts.map +1 -0
  45. package/dist/lib.js +17 -0
  46. package/dist/lib.js.map +1 -0
  47. package/dist/lint.d.ts +10 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +340 -33
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +20 -3
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +1756 -394
  54. package/dist/make.js.map +1 -1
  55. package/dist/outbox.d.ts +24 -0
  56. package/dist/outbox.d.ts.map +1 -0
  57. package/dist/outbox.js +138 -0
  58. package/dist/outbox.js.map +1 -0
  59. package/dist/schedule.d.ts +36 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +155 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/task.d.ts +26 -0
  64. package/dist/task.d.ts.map +1 -0
  65. package/dist/task.js +106 -0
  66. package/dist/task.js.map +1 -0
  67. package/dist/templates.d.ts +3 -32
  68. package/dist/templates.d.ts.map +1 -1
  69. package/dist/templates.js +1002 -527
  70. package/dist/templates.js.map +1 -1
  71. package/dist/version.d.ts +8 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +18 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +9 -8
  76. package/src/ansi.ts +30 -0
  77. package/src/choices.ts +137 -0
  78. package/src/completion.ts +169 -0
  79. package/src/config.ts +16 -0
  80. package/src/create-prompts.ts +182 -0
  81. package/src/create.ts +24 -31
  82. package/src/db.ts +60 -4
  83. package/src/github-annotations.ts +37 -0
  84. package/src/index.ts +1067 -803
  85. package/src/inspect.ts +2859 -115
  86. package/src/lib.ts +45 -0
  87. package/src/lint.ts +493 -39
  88. package/src/make.ts +2181 -405
  89. package/src/outbox.ts +249 -0
  90. package/src/schedule.ts +272 -0
  91. package/src/task.ts +169 -0
  92. package/src/templates.ts +1073 -567
  93. package/src/version.ts +20 -0
  94. package/dist/create-bin.d.ts +0 -3
  95. package/dist/create-bin.d.ts.map +0 -1
  96. package/dist/create-bin.js +0 -9
  97. package/dist/create-bin.js.map +0 -1
  98. package/src/create-bin.ts +0 -11
package/src/make.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import type { MakeFeatureAddon } from "./choices.js";
3
4
  import {
4
5
  type BeignetConfig,
5
6
  directoryPath,
@@ -15,27 +16,22 @@ type MakeResourceOptions = {
15
16
  force?: boolean;
16
17
  dryRun?: boolean;
17
18
  config?: BeignetConfig;
19
+ auth?: boolean;
20
+ tenant?: boolean;
21
+ events?: boolean;
22
+ softDelete?: boolean;
18
23
  };
19
24
 
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];
25
+ export type { MakeFeatureAddon } from "./choices.js";
26
+ export { makeFeatureAddonChoices } from "./choices.js";
33
27
 
34
28
  type NormalizedMakeFeatureAddon =
35
29
  | "policy"
30
+ | "task"
36
31
  | "event"
37
32
  | "job"
38
33
  | "notification"
34
+ | "ui"
39
35
  | "upload";
40
36
 
41
37
  type MakeFeatureOptions = MakeResourceOptions & {
@@ -58,6 +54,14 @@ type MakeUseCaseOptions = {
58
54
  config?: BeignetConfig;
59
55
  };
60
56
 
57
+ type MakeTaskOptions = {
58
+ name: string;
59
+ cwd?: string;
60
+ force?: boolean;
61
+ dryRun?: boolean;
62
+ config?: BeignetConfig;
63
+ };
64
+
61
65
  type MakeTestOptions = {
62
66
  name: string;
63
67
  cwd?: string;
@@ -159,6 +163,7 @@ type MakeUploadOptions = {
159
163
  };
160
164
 
161
165
  type MakeResult = {
166
+ schemaVersion: 1;
162
167
  name: string;
163
168
  targetDir: string;
164
169
  dryRun: boolean;
@@ -184,6 +189,10 @@ export type MakeContractResult = MakeResult;
184
189
  * Result returned by `beignet make use-case`.
185
190
  */
186
191
  export type MakeUseCaseResult = MakeResult;
192
+ /**
193
+ * Result returned by `beignet make task`.
194
+ */
195
+ export type MakeTaskResult = MakeResult;
187
196
  /**
188
197
  * Result returned by `beignet make test`.
189
198
  */
@@ -243,6 +252,15 @@ type ResourceNames = {
243
252
  pluralPascal: string;
244
253
  };
245
254
 
255
+ type ResourceGenerationMode = "feature" | "resource";
256
+
257
+ type ResourceGenerationOptions = {
258
+ auth: boolean;
259
+ tenant: boolean;
260
+ events: boolean;
261
+ softDelete: boolean;
262
+ };
263
+
246
264
  type GeneratedFile = {
247
265
  path: string;
248
266
  content: string;
@@ -300,6 +318,13 @@ type JobNames = FeatureArtifactNames & {
300
318
  payloadTypeName: string;
301
319
  };
302
320
 
321
+ type TaskNames = FeatureArtifactNames & {
322
+ taskName: string;
323
+ taskExportName: string;
324
+ inputSchemaName: string;
325
+ inputTypeName: string;
326
+ };
327
+
303
328
  type FactoryNames = FeatureArtifactNames & {
304
329
  factoryName: string;
305
330
  factoryExportName: string;
@@ -339,8 +364,51 @@ type UploadNames = FeatureArtifactNames & {
339
364
  metadataTypeName: string;
340
365
  };
341
366
 
367
+ type FeatureUiNames = ResourceNames & {
368
+ componentExportName: string;
369
+ componentFileName: string;
370
+ };
371
+
342
372
  export async function makeResource(
343
373
  options: MakeResourceOptions,
374
+ ): Promise<MakeResourceResult> {
375
+ return makeResourceSlice(options, "resource");
376
+ }
377
+
378
+ export async function makeFeature(
379
+ options: MakeFeatureOptions,
380
+ ): Promise<MakeFeatureResult> {
381
+ const addons = normalizeMakeFeatureAddons(options.with ?? []);
382
+ if (addons.length === 0) {
383
+ return makeFeatureResource(options);
384
+ }
385
+
386
+ if (options.dryRun) {
387
+ return makeFeatureSlice(options, addons);
388
+ }
389
+
390
+ await makeFeatureSlice({ ...options, dryRun: true }, addons);
391
+ return makeFeatureSlice(options, addons);
392
+ }
393
+
394
+ async function makeFeatureResource(
395
+ options: MakeResourceOptions,
396
+ ): Promise<MakeResourceResult> {
397
+ try {
398
+ return await makeResourceSlice(options, "feature");
399
+ } catch (error) {
400
+ if (error instanceof Error && error.message.includes("make resource")) {
401
+ throw new Error(
402
+ error.message.replaceAll("make resource", "make feature"),
403
+ );
404
+ }
405
+ throw error;
406
+ }
407
+ }
408
+
409
+ async function makeResourceSlice(
410
+ options: MakeResourceOptions,
411
+ mode: ResourceGenerationMode,
344
412
  ): Promise<MakeResourceResult> {
345
413
  const targetDir = path.resolve(options.cwd ?? process.cwd());
346
414
  const names = resourceNames(options.name);
@@ -349,9 +417,24 @@ export async function makeResource(
349
417
  : await loadBeignetConfig(targetDir);
350
418
 
351
419
  await assertStandardApp(targetDir, config);
420
+ if (
421
+ mode === "resource" &&
422
+ !(await fileExists(path.join(targetDir, resourceSharedErrorsPath(config))))
423
+ ) {
424
+ throw new Error(
425
+ `beignet make resource expects an app error catalog. Missing ${resourceSharedErrorsPath(config)}.`,
426
+ );
427
+ }
352
428
 
353
429
  const persistence = await detectResourcePersistence(targetDir, config);
354
- const generatedFiles = resourceFiles(names, config, persistence);
430
+ const generationOptions = resourceGenerationOptions(options, mode);
431
+ const generatedFiles = resourceFiles(
432
+ names,
433
+ config,
434
+ persistence,
435
+ mode,
436
+ generationOptions,
437
+ );
355
438
  const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
356
439
  force: Boolean(options.force),
357
440
  });
@@ -362,6 +445,8 @@ export async function makeResource(
362
445
  persistence,
363
446
  {
364
447
  dryRun: true,
448
+ mode,
449
+ generationOptions,
365
450
  },
366
451
  );
367
452
  const createdFiles = plannedFiles
@@ -385,11 +470,14 @@ export async function makeResource(
385
470
  ...(await updateResourceWiring(targetDir, names, config, {
386
471
  persistence,
387
472
  dryRun: false,
473
+ mode,
474
+ generationOptions,
388
475
  })),
389
476
  );
390
477
  }
391
478
 
392
479
  return {
480
+ schemaVersion: 1,
393
481
  name: names.pluralKebab,
394
482
  targetDir,
395
483
  dryRun: Boolean(options.dryRun),
@@ -400,35 +488,20 @@ export async function makeResource(
400
488
  };
401
489
  }
402
490
 
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(
491
+ function resourceGenerationOptions(
420
492
  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;
493
+ mode: ResourceGenerationMode,
494
+ ): ResourceGenerationOptions {
495
+ if (mode !== "resource") {
496
+ return { auth: false, tenant: false, events: false, softDelete: false };
431
497
  }
498
+
499
+ return {
500
+ auth: Boolean(options.auth),
501
+ tenant: Boolean(options.tenant),
502
+ events: Boolean(options.events),
503
+ softDelete: Boolean(options.softDelete),
504
+ };
432
505
  }
433
506
 
434
507
  async function makeFeatureSlice(
@@ -462,6 +535,9 @@ async function makeFeatureAddon(
462
535
  if (addon === "policy") {
463
536
  return makePolicy({ ...shared, name: feature });
464
537
  }
538
+ if (addon === "task") {
539
+ return makeTask({ ...shared, name: `${feature}/backfill` });
540
+ }
465
541
  if (addon === "event") {
466
542
  return makeEvent({ ...shared, name: `${feature}/created` });
467
543
  }
@@ -471,6 +547,9 @@ async function makeFeatureAddon(
471
547
  if (addon === "notification") {
472
548
  return makeNotification({ ...shared, name: `${feature}/created` });
473
549
  }
550
+ if (addon === "ui") {
551
+ return makeFeatureUi({ ...shared, name: feature });
552
+ }
474
553
 
475
554
  return makeUpload({ ...shared, name: `${feature}/attachment` });
476
555
  }
@@ -482,6 +561,7 @@ function normalizeMakeFeatureAddons(
482
561
 
483
562
  for (const addon of addons) {
484
563
  if (addon === "events") normalized.add("event");
564
+ else if (addon === "tasks") normalized.add("task");
485
565
  else if (addon === "jobs") normalized.add("job");
486
566
  else if (addon === "notifications") normalized.add("notification");
487
567
  else if (addon === "uploads") normalized.add("upload");
@@ -493,9 +573,11 @@ function normalizeMakeFeatureAddons(
493
573
 
494
574
  const featureAddonOrder: readonly NormalizedMakeFeatureAddon[] = [
495
575
  "policy",
576
+ "task",
496
577
  "event",
497
578
  "job",
498
579
  "notification",
580
+ "ui",
499
581
  "upload",
500
582
  ];
501
583
 
@@ -537,6 +619,7 @@ export async function makeContract(
537
619
  }
538
620
 
539
621
  return {
622
+ schemaVersion: 1,
540
623
  name: names.pluralKebab,
541
624
  targetDir,
542
625
  dryRun: Boolean(options.dryRun),
@@ -582,6 +665,7 @@ export async function makeUseCase(
582
665
  if (indexResult === "skipped") skippedFiles.push(indexFile.path);
583
666
 
584
667
  return {
668
+ schemaVersion: 1,
585
669
  name: `${names.feature.kebab}/${names.action.kebab}`,
586
670
  targetDir,
587
671
  dryRun: Boolean(options.dryRun),
@@ -627,6 +711,7 @@ export async function makeTest(
627
711
  }
628
712
 
629
713
  return {
714
+ schemaVersion: 1,
630
715
  name: `${names.feature.kebab}/${names.action.kebab}`,
631
716
  targetDir,
632
717
  dryRun: Boolean(options.dryRun),
@@ -689,6 +774,7 @@ export async function makePort(
689
774
  }
690
775
 
691
776
  return {
777
+ schemaVersion: 1,
692
778
  name: names.kebab,
693
779
  targetDir,
694
780
  dryRun: Boolean(options.dryRun),
@@ -734,6 +820,7 @@ export async function makeAdapter(
734
820
  }
735
821
 
736
822
  return {
823
+ schemaVersion: 1,
737
824
  name: names.kebab,
738
825
  targetDir,
739
826
  dryRun: Boolean(options.dryRun),
@@ -771,6 +858,7 @@ export async function makePolicy(
771
858
  }
772
859
 
773
860
  return {
861
+ schemaVersion: 1,
774
862
  name: names.kebab,
775
863
  targetDir,
776
864
  dryRun: Boolean(options.dryRun),
@@ -821,7 +909,10 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
821
909
  force: Boolean(options.force),
822
910
  dryRun: Boolean(options.dryRun),
823
911
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
824
- files: jobFiles(names, config),
912
+ files: [
913
+ ...(await missingCapabilityBuilderFiles(targetDir, "jobs", config)),
914
+ ...jobFiles(names, config),
915
+ ],
825
916
  index: featureArtifactIndexFile("job", names, config),
826
917
  dependencies: {
827
918
  "@beignet/core": beignetDependencyVersion,
@@ -829,6 +920,49 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
829
920
  });
830
921
  }
831
922
 
923
+ export async function makeTask(
924
+ options: MakeTaskOptions,
925
+ ): Promise<MakeTaskResult> {
926
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
927
+ const names = taskNames(options.name);
928
+ const config = options.config
929
+ ? resolveConfig(options.config)
930
+ : await loadBeignetConfig(targetDir);
931
+
932
+ await assertFeatureArtifactApp(targetDir, config, "task");
933
+
934
+ const result = await makeFeatureArtifact({
935
+ targetDir,
936
+ config,
937
+ force: Boolean(options.force),
938
+ dryRun: Boolean(options.dryRun),
939
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
940
+ files: [
941
+ ...(await missingCapabilityBuilderFiles(targetDir, "tasks", config)),
942
+ ...taskFiles(names, config),
943
+ ],
944
+ index: featureArtifactIndexFile("task", names, config),
945
+ dependencies: {
946
+ "@beignet/core": beignetDependencyVersion,
947
+ },
948
+ });
949
+
950
+ const registryResult = await updateTaskRegistry(targetDir, names, config, {
951
+ dryRun: Boolean(options.dryRun),
952
+ });
953
+ if (registryResult === "created")
954
+ result.createdFiles.push(config.paths.tasks);
955
+ if (registryResult === "updated")
956
+ result.updatedFiles.push(config.paths.tasks);
957
+ if (registryResult === "skipped")
958
+ result.skippedFiles.push(config.paths.tasks);
959
+ if (!result.files.includes(config.paths.tasks)) {
960
+ result.files.push(config.paths.tasks);
961
+ }
962
+
963
+ return result;
964
+ }
965
+
832
966
  export async function makeFactory(
833
967
  options: MakeFactoryOptions,
834
968
  ): Promise<MakeFactoryResult> {
@@ -896,7 +1030,14 @@ export async function makeNotification(
896
1030
  force: Boolean(options.force),
897
1031
  dryRun: Boolean(options.dryRun),
898
1032
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
899
- files: notificationFiles(names, config),
1033
+ files: [
1034
+ ...(await missingCapabilityBuilderFiles(
1035
+ targetDir,
1036
+ "notifications",
1037
+ config,
1038
+ )),
1039
+ ...notificationFiles(names, config),
1040
+ ],
900
1041
  index: featureArtifactIndexFile("notification", names, config),
901
1042
  dependencies: {
902
1043
  "@beignet/core": beignetDependencyVersion,
@@ -922,7 +1063,10 @@ export async function makeListener(
922
1063
  force: Boolean(options.force),
923
1064
  dryRun: Boolean(options.dryRun),
924
1065
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
925
- files: listenerFiles(names, config),
1066
+ files: [
1067
+ ...(await missingCapabilityBuilderFiles(targetDir, "listeners", config)),
1068
+ ...listenerFiles(names, config),
1069
+ ],
926
1070
  index: featureArtifactIndexFile("listener", names, config),
927
1071
  dependencies: {
928
1072
  "@beignet/core": beignetDependencyVersion,
@@ -962,7 +1106,10 @@ export async function makeSchedule(
962
1106
  requireServer: Boolean(options.route),
963
1107
  });
964
1108
 
965
- const files = scheduleFiles(names, config, { route: Boolean(options.route) });
1109
+ const files = [
1110
+ ...(await missingCapabilityBuilderFiles(targetDir, "schedules", config)),
1111
+ ...scheduleFiles(names, config, { route: Boolean(options.route) }),
1112
+ ];
966
1113
 
967
1114
  return makeFeatureArtifact({
968
1115
  targetDir,
@@ -1003,6 +1150,70 @@ export async function makeUpload(
1003
1150
  });
1004
1151
  }
1005
1152
 
1153
+ async function makeFeatureUi(
1154
+ options: MakeResourceOptions,
1155
+ ): Promise<MakeResult> {
1156
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
1157
+ const names = featureUiNames(options.name);
1158
+ const config = options.config
1159
+ ? resolveConfig(options.config)
1160
+ : await loadBeignetConfig(targetDir);
1161
+
1162
+ await assertFeatureUiApp(targetDir, config);
1163
+
1164
+ const componentFiles = featureUiComponentFiles(names, config);
1165
+ const indexFile = featureUiIndexFile(names, config);
1166
+ const plannedFiles = await planGeneratedFiles(targetDir, componentFiles, {
1167
+ force: Boolean(options.force),
1168
+ });
1169
+ const createdFiles = plannedFiles
1170
+ .filter((file) => file.result === "created")
1171
+ .map((file) => file.path);
1172
+ const updatedFiles = plannedFiles
1173
+ .filter((file) => file.result === "updated")
1174
+ .map((file) => file.path);
1175
+ const skippedFiles = plannedFiles
1176
+ .filter((file) => file.result === "skipped")
1177
+ .map((file) => file.path);
1178
+
1179
+ if (!options.dryRun) {
1180
+ for (const file of plannedFiles) {
1181
+ await writePlannedGeneratedFile(targetDir, file);
1182
+ }
1183
+ }
1184
+
1185
+ const indexResult = await updateFeatureUiIndex(targetDir, indexFile, {
1186
+ dryRun: Boolean(options.dryRun),
1187
+ });
1188
+ if (indexResult === "created") createdFiles.push(indexFile.path);
1189
+ if (indexResult === "updated") updatedFiles.push(indexFile.path);
1190
+ if (indexResult === "skipped") skippedFiles.push(indexFile.path);
1191
+
1192
+ if (
1193
+ await updatePackageDependencies(
1194
+ targetDir,
1195
+ {
1196
+ "@beignet/react-query": beignetDependencyVersion,
1197
+ "@tanstack/react-query": tanstackReactQueryDependencyVersion,
1198
+ },
1199
+ { dryRun: Boolean(options.dryRun) },
1200
+ )
1201
+ ) {
1202
+ updatedFiles.push("package.json");
1203
+ }
1204
+
1205
+ return {
1206
+ schemaVersion: 1,
1207
+ name: names.pluralKebab,
1208
+ targetDir,
1209
+ dryRun: Boolean(options.dryRun),
1210
+ files: [...componentFiles.map((file) => file.path), indexFile.path],
1211
+ createdFiles,
1212
+ updatedFiles,
1213
+ skippedFiles,
1214
+ };
1215
+ }
1216
+
1006
1217
  async function assertStandardApp(
1007
1218
  targetDir: string,
1008
1219
  config: ResolvedBeignetConfig,
@@ -1131,6 +1342,7 @@ async function assertFeatureArtifactApp(
1131
1342
  artifact:
1132
1343
  | "event"
1133
1344
  | "job"
1345
+ | "task"
1134
1346
  | "notification"
1135
1347
  | "listener"
1136
1348
  | "schedule"
@@ -1153,6 +1365,24 @@ async function assertFeatureArtifactApp(
1153
1365
  }
1154
1366
  }
1155
1367
 
1368
+ async function assertFeatureUiApp(
1369
+ targetDir: string,
1370
+ config: ResolvedBeignetConfig,
1371
+ ): Promise<void> {
1372
+ const clientPath = clientIndexPath(config);
1373
+ const requiredFiles = [config.paths.appContext, clientPath];
1374
+
1375
+ for (const file of requiredFiles) {
1376
+ try {
1377
+ await stat(path.join(targetDir, file));
1378
+ } catch {
1379
+ throw new Error(
1380
+ `beignet make feature --with ui expects a standard Beignet app with React Query client helpers. Missing ${file}.`,
1381
+ );
1382
+ }
1383
+ }
1384
+ }
1385
+
1156
1386
  async function assertPersistenceArtifactApp(
1157
1387
  targetDir: string,
1158
1388
  names: FactoryNames | SeedNames,
@@ -1264,6 +1494,83 @@ async function readOptionalFile(filePath: string): Promise<string | undefined> {
1264
1494
  }
1265
1495
  }
1266
1496
 
1497
+ type CapabilityBuilder =
1498
+ | "listeners"
1499
+ | "jobs"
1500
+ | "schedules"
1501
+ | "notifications"
1502
+ | "tasks";
1503
+
1504
+ const capabilityBuilders: Record<
1505
+ CapabilityBuilder,
1506
+ { factoryName: string; defineName: string; module: string }
1507
+ > = {
1508
+ listeners: {
1509
+ factoryName: "createListeners",
1510
+ defineName: "defineListener",
1511
+ module: "@beignet/core/events",
1512
+ },
1513
+ jobs: {
1514
+ factoryName: "createJobs",
1515
+ defineName: "defineJob",
1516
+ module: "@beignet/core/jobs",
1517
+ },
1518
+ schedules: {
1519
+ factoryName: "createSchedules",
1520
+ defineName: "defineSchedule",
1521
+ module: "@beignet/core/schedules",
1522
+ },
1523
+ notifications: {
1524
+ factoryName: "createNotifications",
1525
+ defineName: "defineNotification",
1526
+ module: "@beignet/core/notifications",
1527
+ },
1528
+ tasks: {
1529
+ factoryName: "createTasks",
1530
+ defineName: "defineTask",
1531
+ module: "@beignet/core/tasks",
1532
+ },
1533
+ };
1534
+
1535
+ function capabilityBuilderPath(
1536
+ capability: CapabilityBuilder,
1537
+ config: ResolvedBeignetConfig,
1538
+ ): string {
1539
+ if (capability === "listeners") return config.paths.listenersBuilder;
1540
+ if (capability === "jobs") return config.paths.jobsBuilder;
1541
+ if (capability === "schedules") return config.paths.schedulesBuilder;
1542
+ if (capability === "notifications") return config.paths.notificationsBuilder;
1543
+ return config.paths.tasksBuilder;
1544
+ }
1545
+
1546
+ function capabilityBuilderFile(
1547
+ capability: CapabilityBuilder,
1548
+ config: ResolvedBeignetConfig,
1549
+ ): GeneratedFile {
1550
+ const builder = capabilityBuilders[capability];
1551
+
1552
+ return {
1553
+ path: capabilityBuilderPath(capability, config),
1554
+ content: `import { ${builder.factoryName} } from "${builder.module}";
1555
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
1556
+
1557
+ export const { ${builder.defineName} } = ${builder.factoryName}<AppContext>();
1558
+ `,
1559
+ };
1560
+ }
1561
+
1562
+ async function missingCapabilityBuilderFiles(
1563
+ targetDir: string,
1564
+ capability: CapabilityBuilder,
1565
+ config: ResolvedBeignetConfig,
1566
+ ): Promise<GeneratedFile[]> {
1567
+ const file = capabilityBuilderFile(capability, config);
1568
+ if (await fileExists(path.join(targetDir, file.path))) {
1569
+ return [];
1570
+ }
1571
+ return [file];
1572
+ }
1573
+
1267
1574
  async function makeFeatureArtifact(options: {
1268
1575
  targetDir: string;
1269
1576
  config: ResolvedBeignetConfig;
@@ -1315,6 +1622,7 @@ async function makeFeatureArtifact(options: {
1315
1622
  }
1316
1623
 
1317
1624
  return {
1625
+ schemaVersion: 1,
1318
1626
  name: options.name,
1319
1627
  targetDir: options.targetDir,
1320
1628
  dryRun: options.dryRun,
@@ -1358,14 +1666,29 @@ function beignetDependencyVersion(packageJson: PackageJsonLike): string {
1358
1666
  );
1359
1667
  }
1360
1668
 
1669
+ function tanstackReactQueryDependencyVersion(
1670
+ packageJson: PackageJsonLike,
1671
+ ): string {
1672
+ return packageJson.dependencies?.["@tanstack/react-query"] ?? "^5.100.7";
1673
+ }
1674
+
1361
1675
  async function updateResourceWiring(
1362
1676
  targetDir: string,
1363
1677
  names: ResourceNames,
1364
1678
  config: ResolvedBeignetConfig,
1365
1679
  persistenceOrOptions:
1366
1680
  | ResourcePersistence
1367
- | { dryRun: boolean; persistence?: ResourcePersistence },
1368
- maybeOptions?: { dryRun: boolean },
1681
+ | {
1682
+ dryRun: boolean;
1683
+ persistence?: ResourcePersistence;
1684
+ mode?: ResourceGenerationMode;
1685
+ generationOptions?: ResourceGenerationOptions;
1686
+ },
1687
+ maybeOptions?: {
1688
+ dryRun: boolean;
1689
+ mode?: ResourceGenerationMode;
1690
+ generationOptions?: ResourceGenerationOptions;
1691
+ },
1369
1692
  ): Promise<string[]> {
1370
1693
  const persistence =
1371
1694
  typeof persistenceOrOptions === "string"
@@ -1375,6 +1698,16 @@ async function updateResourceWiring(
1375
1698
  typeof persistenceOrOptions === "string"
1376
1699
  ? (maybeOptions as { dryRun: boolean })
1377
1700
  : persistenceOrOptions;
1701
+ const mode =
1702
+ typeof persistenceOrOptions === "string"
1703
+ ? (maybeOptions?.mode ?? "feature")
1704
+ : (persistenceOrOptions.mode ?? "feature");
1705
+ const generationOptions =
1706
+ typeof persistenceOrOptions === "string"
1707
+ ? (maybeOptions?.generationOptions ??
1708
+ resourceGenerationOptions({ name: names.pluralKebab }, mode))
1709
+ : (persistenceOrOptions.generationOptions ??
1710
+ resourceGenerationOptions({ name: names.pluralKebab }, mode));
1378
1711
  const updated = new Set<string>();
1379
1712
  if (
1380
1713
  await updatePackageJson(targetDir, {
@@ -1384,7 +1717,12 @@ async function updateResourceWiring(
1384
1717
  ) {
1385
1718
  updated.add("package.json");
1386
1719
  }
1387
- if (await updatePortsIndex(targetDir, names, config, options)) {
1720
+ if (
1721
+ await updatePortsIndex(targetDir, names, config, {
1722
+ ...options,
1723
+ generationOptions,
1724
+ })
1725
+ ) {
1388
1726
  updated.add(config.paths.ports);
1389
1727
  }
1390
1728
  if (
@@ -1393,6 +1731,18 @@ async function updateResourceWiring(
1393
1731
  ) {
1394
1732
  updated.add(config.paths.infrastructurePorts);
1395
1733
  }
1734
+ if (
1735
+ generationOptions.auth &&
1736
+ (await updateInfrastructureGatePolicies(targetDir, names, config, options))
1737
+ ) {
1738
+ updated.add(config.paths.infrastructurePorts);
1739
+ }
1740
+ if (
1741
+ persistence === "drizzle" &&
1742
+ (await updateInfrastructureDeferredPorts(targetDir, names, config, options))
1743
+ ) {
1744
+ updated.add(config.paths.infrastructurePorts);
1745
+ }
1396
1746
  if (
1397
1747
  persistence === "drizzle" &&
1398
1748
  (await updateDrizzleSchemaIndex(targetDir, names, config, options))
@@ -1411,7 +1761,14 @@ async function updateResourceWiring(
1411
1761
  ) {
1412
1762
  updated.add(config.paths.server);
1413
1763
  }
1414
- if (await updateOpenApiRoute(targetDir, names, config, options)) {
1764
+ if (
1765
+ await updateSharedErrors(targetDir, names, config, { ...options, mode })
1766
+ ) {
1767
+ updated.add(resourceSharedErrorsPath(config));
1768
+ }
1769
+ if (
1770
+ await updateOpenApiRoute(targetDir, names, config, { ...options, mode })
1771
+ ) {
1415
1772
  updated.add(config.paths.openapiRoute);
1416
1773
  }
1417
1774
  const routesFile = await updateServerRoutes(
@@ -1457,7 +1814,7 @@ async function updatePortsIndex(
1457
1814
  targetDir: string,
1458
1815
  names: ResourceNames,
1459
1816
  config: ResolvedBeignetConfig,
1460
- options: { dryRun: boolean },
1817
+ options: { dryRun: boolean; generationOptions?: ResourceGenerationOptions },
1461
1818
  ): Promise<boolean> {
1462
1819
  const filePath = path.join(targetDir, config.paths.ports);
1463
1820
  const original = await readFile(filePath, "utf8");
@@ -1469,6 +1826,30 @@ async function updatePortsIndex(
1469
1826
  next = insertAfterImports(next, importLine);
1470
1827
  }
1471
1828
 
1829
+ if (options.generationOptions?.auth) {
1830
+ const policyPath = path.join(
1831
+ config.paths.features,
1832
+ names.pluralKebab,
1833
+ "policy.ts",
1834
+ );
1835
+ const policyImportLine = `import type { AuthorizationContext as ${names.singularPascal}AuthorizationContext, ${names.singularCamel}Policy } from "${relativeModule(config.paths.ports, policyPath)}";`;
1836
+ if (!next.includes(policyImportLine)) {
1837
+ next = insertAfterImports(next, policyImportLine);
1838
+ }
1839
+
1840
+ next = appendUnionTypeMember(
1841
+ next,
1842
+ "AppAuthorizationContext",
1843
+ `${names.singularPascal}AuthorizationContext`,
1844
+ );
1845
+ next = appendTupleTypeMember(
1846
+ next,
1847
+ "AppGate",
1848
+ `typeof ${names.singularCamel}Policy`,
1849
+ );
1850
+ next = appendGatePolicyMember(next, `typeof ${names.singularCamel}Policy`);
1851
+ }
1852
+
1472
1853
  const entry = `\t${names.pluralCamel}: ${names.singularPascal}Repository;`;
1473
1854
  if (!next.includes(entry.trim())) {
1474
1855
  next = insertTypeProperty(
@@ -1625,7 +2006,7 @@ async function updateInfrastructurePorts(
1625
2006
  if (!next.includes(repositoryConst)) {
1626
2007
  next = replaceRequired(
1627
2008
  next,
1628
- /(\nexport const appPorts = definePorts(?:<[^>]+>)?\(\{)/,
2009
+ /(\nexport const appPorts = definePorts(?:<[^>]+>)?\((?:\)\()?\{)/,
1629
2010
  `\n${repositoryConst}\n$1`,
1630
2011
  `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add ${repositoryConst} manually, or restore the generated infrastructure ports file before running make resource.`,
1631
2012
  );
@@ -1662,23 +2043,54 @@ async function updateInfrastructurePorts(
1662
2043
  return true;
1663
2044
  }
1664
2045
 
1665
- async function updateDrizzleSchemaIndex(
2046
+ async function updateInfrastructureGatePolicies(
1666
2047
  targetDir: string,
1667
2048
  names: ResourceNames,
1668
2049
  config: ResolvedBeignetConfig,
1669
2050
  options: { dryRun: boolean },
1670
2051
  ): Promise<boolean> {
1671
- const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
1672
- if (!(await fileExists(filePath))) return false;
1673
-
2052
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1674
2053
  const original = await readFile(filePath, "utf8");
1675
- const exportLine = `export { ${names.pluralCamel} } from "./${names.pluralKebab}";`;
1676
- if (original.includes(exportLine)) return false;
1677
-
1678
- const next = `${original.trimEnd()}\n${exportLine}\n`;
1679
- if (!options.dryRun) await writeFile(filePath, next);
1680
- return true;
1681
- }
2054
+ let next = original;
2055
+ const policyPath = path.join(
2056
+ config.paths.features,
2057
+ names.pluralKebab,
2058
+ "policy.ts",
2059
+ );
2060
+ const importLine = `import { ${names.singularCamel}Policy } from "${relativeModule(config.paths.infrastructurePorts, policyPath)}";`;
2061
+
2062
+ if (!next.includes(importLine)) {
2063
+ next = insertAfterImports(next, importLine);
2064
+ }
2065
+
2066
+ next = appendCreateGatePolicy(
2067
+ next,
2068
+ `${names.singularCamel}Policy`,
2069
+ `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.`,
2070
+ );
2071
+
2072
+ if (next === original) return false;
2073
+ if (!options.dryRun) await writeFile(filePath, next);
2074
+ return true;
2075
+ }
2076
+
2077
+ async function updateDrizzleSchemaIndex(
2078
+ targetDir: string,
2079
+ names: ResourceNames,
2080
+ config: ResolvedBeignetConfig,
2081
+ options: { dryRun: boolean },
2082
+ ): Promise<boolean> {
2083
+ const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
2084
+ if (!(await fileExists(filePath))) return false;
2085
+
2086
+ const original = await readFile(filePath, "utf8");
2087
+ const exportLine = `export { ${names.pluralCamel} } from "./${names.pluralKebab}";`;
2088
+ if (original.includes(exportLine)) return false;
2089
+
2090
+ const next = `${original.trimEnd()}\n${exportLine}\n`;
2091
+ if (!options.dryRun) await writeFile(filePath, next);
2092
+ return true;
2093
+ }
1682
2094
 
1683
2095
  async function updateDrizzleRepositories(
1684
2096
  targetDir: string,
@@ -1719,6 +2131,24 @@ async function updateDrizzleRepositories(
1719
2131
  return true;
1720
2132
  }
1721
2133
 
2134
+ async function updateInfrastructureDeferredPorts(
2135
+ targetDir: string,
2136
+ names: ResourceNames,
2137
+ config: ResolvedBeignetConfig,
2138
+ options: { dryRun: boolean },
2139
+ ): Promise<boolean> {
2140
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2141
+ const original = await readFile(filePath, "utf8");
2142
+ // Drizzle resources are provided by the app database provider at startup,
2143
+ // so deferred-form ports files defer the new repository key. Identity-form
2144
+ // ports files are left untouched.
2145
+ const next = appendDeferredPortKey(original, names.pluralCamel);
2146
+
2147
+ if (next === original) return false;
2148
+ if (!options.dryRun) await writeFile(filePath, next);
2149
+ return true;
2150
+ }
2151
+
1722
2152
  async function updateServerTransactionPorts(
1723
2153
  targetDir: string,
1724
2154
  names: ResourceNames,
@@ -1759,7 +2189,7 @@ async function updateOpenApiRoute(
1759
2189
  targetDir: string,
1760
2190
  names: ResourceNames,
1761
2191
  config: ResolvedBeignetConfig,
1762
- options: { dryRun: boolean },
2192
+ options: { dryRun: boolean; mode: ResourceGenerationMode },
1763
2193
  ): Promise<boolean> {
1764
2194
  const filePath = path.join(targetDir, config.paths.openapiRoute);
1765
2195
  if (!(await fileExists(filePath))) return false;
@@ -1768,15 +2198,14 @@ async function updateOpenApiRoute(
1768
2198
  const firstArg = firstCreateOpenAPIHandlerArgInfo(original);
1769
2199
  if (!firstArg?.text.startsWith("[")) return false;
1770
2200
 
1771
- const listName = `list${names.pluralPascal}`;
1772
- const createName = `create${names.singularPascal}`;
2201
+ const contractNames = resourceContractNames(names, options.mode);
1773
2202
  const listedContracts = identifiersFromArrayExpression(firstArg.text);
1774
- const missingContracts = [listName, createName].filter(
2203
+ const missingContracts = contractNames.filter(
1775
2204
  (contractName) => !listedContracts.has(contractName),
1776
2205
  );
1777
2206
  if (missingContracts.length === 0) return false;
1778
2207
 
1779
- const importLine = `import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
2208
+ const importLine = `import { ${contractNames.join(", ")} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
1780
2209
  const nextArray = appendToArrayExpression(firstArg.text, missingContracts);
1781
2210
  let next = `${original.slice(0, firstArg.start)}${nextArray}${original.slice(
1782
2211
  firstArg.end,
@@ -1791,6 +2220,74 @@ async function updateOpenApiRoute(
1791
2220
  return true;
1792
2221
  }
1793
2222
 
2223
+ async function updateSharedErrors(
2224
+ targetDir: string,
2225
+ names: ResourceNames,
2226
+ config: ResolvedBeignetConfig,
2227
+ options: { dryRun: boolean; mode: ResourceGenerationMode },
2228
+ ): Promise<boolean> {
2229
+ if (options.mode !== "resource") return false;
2230
+
2231
+ const filePath = path.join(targetDir, resourceSharedErrorsPath(config));
2232
+ if (!(await fileExists(filePath))) return false;
2233
+
2234
+ const original = await readFile(filePath, "utf8");
2235
+ const notFoundErrorName = `${names.singularPascal}NotFound`;
2236
+ const conflictErrorName = `${names.singularPascal}Conflict`;
2237
+ const missingErrorNames = [notFoundErrorName, conflictErrorName].filter(
2238
+ (errorName) => !new RegExp(`\\b${errorName}\\b`).test(original),
2239
+ );
2240
+ if (missingErrorNames.length === 0) return false;
2241
+ const expectedErrorNames = missingErrorNames.join(" and ");
2242
+
2243
+ const defineErrorsStart = original.indexOf("defineErrors(");
2244
+ if (defineErrorsStart === -1) {
2245
+ throw new Error(
2246
+ `Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`,
2247
+ );
2248
+ }
2249
+
2250
+ const openBrace = original.indexOf("{", defineErrorsStart);
2251
+ if (openBrace === -1) {
2252
+ throw new Error(
2253
+ `Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`,
2254
+ );
2255
+ }
2256
+
2257
+ const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
2258
+ if (closeBrace === -1) {
2259
+ throw new Error(
2260
+ `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.`,
2261
+ );
2262
+ }
2263
+
2264
+ const entries = [];
2265
+ if (missingErrorNames.includes(notFoundErrorName)) {
2266
+ entries.push(`${notFoundErrorName}: {
2267
+ \t\tcode: "${constantCase(names.singularKebab)}_NOT_FOUND",
2268
+ \t\tstatus: 404,
2269
+ \t\tmessage: "${names.singularPascal} not found",
2270
+ \t},`);
2271
+ }
2272
+ if (missingErrorNames.includes(conflictErrorName)) {
2273
+ entries.push(`${conflictErrorName}: {
2274
+ \t\tcode: "${constantCase(names.singularKebab)}_CONFLICT",
2275
+ \t\tstatus: 409,
2276
+ \t\tmessage: "${names.singularPascal} was changed by another request",
2277
+ \t},`);
2278
+ }
2279
+ const next = insertBeforeClosingBrace(
2280
+ original,
2281
+ openBrace,
2282
+ closeBrace,
2283
+ entries.join("\n"),
2284
+ );
2285
+
2286
+ if (next === original) return false;
2287
+ if (!options.dryRun) await writeFile(filePath, next);
2288
+ return true;
2289
+ }
2290
+
1794
2291
  async function updateServerRoutes(
1795
2292
  targetDir: string,
1796
2293
  names: ResourceNames,
@@ -2184,6 +2681,98 @@ function typeBodySource(source: string, typeName: string): string | undefined {
2184
2681
  return source.slice(openBrace + 1, closeBrace);
2185
2682
  }
2186
2683
 
2684
+ function appendUnionTypeMember(
2685
+ source: string,
2686
+ typeName: string,
2687
+ member: string,
2688
+ ): string {
2689
+ const match = new RegExp(
2690
+ `(export\\s+type\\s+${typeName}\\s*=)([\\s\\S]*?);`,
2691
+ ).exec(source);
2692
+ if (!match) return source;
2693
+
2694
+ const declaration = match[0];
2695
+ if (new RegExp(`\\b${member}\\b`).test(declaration)) return source;
2696
+
2697
+ const nextDeclaration = declaration.replace(/;\s*$/, ` | ${member};`);
2698
+ return `${source.slice(0, match.index)}${nextDeclaration}${source.slice(
2699
+ match.index + declaration.length,
2700
+ )}`;
2701
+ }
2702
+
2703
+ function appendTupleTypeMember(
2704
+ source: string,
2705
+ typeName: string,
2706
+ member: string,
2707
+ ): string {
2708
+ const match = new RegExp(`export\\s+type\\s+${typeName}\\s*=\\s*`).exec(
2709
+ source,
2710
+ );
2711
+ if (!match) return source;
2712
+
2713
+ const tupleStart = source.indexOf("[", match.index);
2714
+ if (tupleStart === -1) return source;
2715
+
2716
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
2717
+ if (tupleEnd === -1) return source;
2718
+
2719
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
2720
+ if (tuple.includes(member)) return source;
2721
+
2722
+ const nextTuple = appendToArrayExpression(tuple, [member]);
2723
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(
2724
+ tupleEnd + 1,
2725
+ )}`;
2726
+ }
2727
+
2728
+ function appendGatePolicyMember(source: string, member: string): string {
2729
+ const gateMatch = /\bGatePort\s*</.exec(source);
2730
+ if (!gateMatch) return source;
2731
+
2732
+ const tupleStart = source.indexOf("[", gateMatch.index);
2733
+ if (tupleStart === -1) return source;
2734
+
2735
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
2736
+ if (tupleEnd === -1) return source;
2737
+
2738
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
2739
+ if (tuple.includes(member)) return source;
2740
+
2741
+ const nextTuple = appendToArrayExpression(tuple, [member]);
2742
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(
2743
+ tupleEnd + 1,
2744
+ )}`;
2745
+ }
2746
+
2747
+ function appendCreateGatePolicy(
2748
+ source: string,
2749
+ policy: string,
2750
+ failureMessage: string,
2751
+ ): string {
2752
+ const policyMatch = /\bpolicies\s*:\s*\[/.exec(source);
2753
+ if (!policyMatch) {
2754
+ throw new Error(failureMessage);
2755
+ }
2756
+
2757
+ const arrayStart = source.indexOf("[", policyMatch.index);
2758
+ if (arrayStart === -1) {
2759
+ throw new Error(failureMessage);
2760
+ }
2761
+
2762
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
2763
+ if (arrayEnd === -1) {
2764
+ throw new Error(failureMessage);
2765
+ }
2766
+
2767
+ const policies = source.slice(arrayStart, arrayEnd + 1);
2768
+ if (policies.includes(policy)) return source;
2769
+
2770
+ const nextPolicies = appendToArrayExpression(policies, [policy]);
2771
+ return `${source.slice(0, arrayStart)}${nextPolicies}${source.slice(
2772
+ arrayEnd + 1,
2773
+ )}`;
2774
+ }
2775
+
2187
2776
  function insertDefinePortsProperty(
2188
2777
  source: string,
2189
2778
  property: string,
@@ -2281,7 +2870,20 @@ function definePortsBodySource(source: string): string | undefined {
2281
2870
  function definePortsBodyRange(
2282
2871
  source: string,
2283
2872
  ): { openBrace: number; closeBrace: number } | undefined {
2284
- const match = /definePorts(?:<[^>]+>)?\(\s*\{/.exec(source);
2873
+ const outer = definePortsOuterBodyRange(source);
2874
+ if (!outer) return undefined;
2875
+
2876
+ // The curried `definePorts<AppPorts>()({ bound, deferred })` form binds app
2877
+ // ports inside `bound`; generated bindings belong there.
2878
+ return (
2879
+ topLevelBoundObjectRange(source, outer.openBrace, outer.closeBrace) ?? outer
2880
+ );
2881
+ }
2882
+
2883
+ function definePortsOuterBodyRange(
2884
+ source: string,
2885
+ ): { openBrace: number; closeBrace: number } | undefined {
2886
+ const match = /definePorts(?:<[^>]+>)?\(\s*(?:\)\s*\(\s*)?\{/.exec(source);
2285
2887
  if (!match) return undefined;
2286
2888
 
2287
2889
  const openBrace = source.indexOf("{", match.index);
@@ -2291,6 +2893,50 @@ function definePortsBodyRange(
2291
2893
  return { openBrace, closeBrace };
2292
2894
  }
2293
2895
 
2896
+ function appendDeferredPortKey(source: string, portKey: string): string {
2897
+ const outer = definePortsOuterBodyRange(source);
2898
+ if (!outer) return source;
2899
+
2900
+ const body = source.slice(outer.openBrace + 1, outer.closeBrace);
2901
+ if (!hasTopLevelObjectProperty(body, "deferred")) return source;
2902
+
2903
+ const deferredMatch = /\bdeferred\s*:\s*\[/.exec(body);
2904
+ if (!deferredMatch) return source;
2905
+
2906
+ const arrayStart = source.indexOf(
2907
+ "[",
2908
+ outer.openBrace + 1 + deferredMatch.index,
2909
+ );
2910
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
2911
+ if (arrayEnd === -1) return source;
2912
+
2913
+ const tuple = source.slice(arrayStart, arrayEnd + 1);
2914
+ if (new RegExp(`["']${portKey}["']`).test(tuple)) return source;
2915
+
2916
+ const nextTuple = appendToArrayExpression(tuple, [`"${portKey}"`]);
2917
+ return `${source.slice(0, arrayStart)}${nextTuple}${source.slice(
2918
+ arrayEnd + 1,
2919
+ )}`;
2920
+ }
2921
+
2922
+ function topLevelBoundObjectRange(
2923
+ source: string,
2924
+ openBrace: number,
2925
+ closeBrace: number,
2926
+ ): { openBrace: number; closeBrace: number } | undefined {
2927
+ const body = source.slice(openBrace + 1, closeBrace);
2928
+ if (!hasTopLevelObjectProperty(body, "bound")) return undefined;
2929
+
2930
+ const boundMatch = /\bbound\s*:\s*\{/.exec(body);
2931
+ if (!boundMatch) return undefined;
2932
+
2933
+ const boundOpen = source.indexOf("{", openBrace + 1 + boundMatch.index);
2934
+ const boundClose = matchingBraceIndex(source, boundOpen);
2935
+ if (boundClose === -1) return undefined;
2936
+
2937
+ return { openBrace: boundOpen, closeBrace: boundClose };
2938
+ }
2939
+
2294
2940
  function hasTopLevelObjectProperty(
2295
2941
  source: string,
2296
2942
  propertyName: string,
@@ -2593,6 +3239,21 @@ function resourceNames(input: string): ResourceNames {
2593
3239
  return names;
2594
3240
  }
2595
3241
 
3242
+ function featureUiNames(input: string): FeatureUiNames {
3243
+ const names = resourceNames(input);
3244
+ const resolvedNames = {
3245
+ ...names,
3246
+ componentExportName: `${names.pluralPascal}Panel`,
3247
+ componentFileName: `${names.pluralKebab}-panel`,
3248
+ };
3249
+
3250
+ assertGeneratedIdentifiers(input, "Feature UI name", [
3251
+ resolvedNames.componentExportName,
3252
+ ]);
3253
+
3254
+ return resolvedNames;
3255
+ }
3256
+
2596
3257
  function featureResourceNames(names: FeatureArtifactNames): ResourceNames {
2597
3258
  return resourceNames(names.feature.kebab);
2598
3259
  }
@@ -2737,6 +3398,21 @@ function jobNames(input: string): JobNames {
2737
3398
  };
2738
3399
  }
2739
3400
 
3401
+ function taskNames(input: string): TaskNames {
3402
+ const names = featureArtifactNames(input, "Task");
3403
+ const taskExportName = `${names.artifact.camel}Task`;
3404
+
3405
+ assertGeneratedIdentifiers(input, "Task name", [taskExportName]);
3406
+
3407
+ return {
3408
+ ...names,
3409
+ taskName: names.stableName,
3410
+ taskExportName,
3411
+ inputSchemaName: `${names.artifact.pascal}TaskInputSchema`,
3412
+ inputTypeName: `${names.artifact.pascal}TaskInput`,
3413
+ };
3414
+ }
3415
+
2740
3416
  function factoryNames(input: string): FactoryNames {
2741
3417
  const names = featureArtifactNames(input, "Factory");
2742
3418
  const factoryExportName = `${names.artifact.camel}Factory`;
@@ -2986,10 +3662,21 @@ function pascalCase(value: string): string {
2986
3662
  return `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
2987
3663
  }
2988
3664
 
3665
+ function constantCase(value: string): string {
3666
+ return value.replaceAll("-", "_").toUpperCase();
3667
+ }
3668
+
2989
3669
  function resourceFiles(
2990
3670
  names: ResourceNames,
2991
3671
  config: ResolvedBeignetConfig,
2992
3672
  persistence: ResourcePersistence = "memory",
3673
+ mode: ResourceGenerationMode = "feature",
3674
+ options: ResourceGenerationOptions = {
3675
+ auth: false,
3676
+ tenant: false,
3677
+ events: false,
3678
+ softDelete: false,
3679
+ },
2993
3680
  ): GeneratedFile[] {
2994
3681
  const useCaseDir = resourceUseCaseDir(names, config);
2995
3682
  const infraDir = infrastructureDir(config);
@@ -2997,27 +3684,63 @@ function resourceFiles(
2997
3684
  const files: GeneratedFile[] = [
2998
3685
  {
2999
3686
  path: resourceContractFilePath(names, config),
3000
- content: contractFile(names, config),
3687
+ content: contractFile(names, config, mode, options),
3001
3688
  },
3002
3689
  {
3003
- path: path.join(useCaseDir, "schemas.ts"),
3004
- content: schemasFile(names),
3690
+ path: path.join(featureDir, "schemas.ts"),
3691
+ content: schemasFile(names, mode, options),
3005
3692
  },
3006
3693
  {
3007
3694
  path: path.join(useCaseDir, `list-${names.pluralKebab}.ts`),
3008
- content: listUseCaseFile(names, config),
3695
+ content: listUseCaseFile(names, config, options),
3009
3696
  },
3010
3697
  {
3011
3698
  path: path.join(useCaseDir, `create-${names.singularKebab}.ts`),
3012
- content: createUseCaseFile(names, config),
3699
+ content: createUseCaseFile(names, config, options),
3013
3700
  },
3701
+ ...(mode === "resource"
3702
+ ? [
3703
+ {
3704
+ path: path.join(useCaseDir, `get-${names.singularKebab}.ts`),
3705
+ content: getUseCaseFile(names, config, options),
3706
+ },
3707
+ {
3708
+ path: path.join(useCaseDir, `update-${names.singularKebab}.ts`),
3709
+ content: updateUseCaseFile(names, config, options),
3710
+ },
3711
+ {
3712
+ path: path.join(useCaseDir, `delete-${names.singularKebab}.ts`),
3713
+ content: deleteUseCaseFile(names, config, options),
3714
+ },
3715
+ {
3716
+ path: path.join(featureDir, "policy.ts"),
3717
+ content: resourcePolicyFile(names, config, options),
3718
+ },
3719
+ ]
3720
+ : []),
3721
+ ...(mode === "resource" && options.events
3722
+ ? [
3723
+ {
3724
+ path: path.join(featureDir, "domain/events/index.ts"),
3725
+ content: resourceEventsFile(names),
3726
+ },
3727
+ ]
3728
+ : []),
3729
+ ...(mode === "resource" && options.auth
3730
+ ? [
3731
+ {
3732
+ path: path.join(featureDir, "tests/policy.test.ts"),
3733
+ content: resourcePolicyTestFile(names, options),
3734
+ },
3735
+ ]
3736
+ : []),
3014
3737
  {
3015
3738
  path: path.join(useCaseDir, "index.ts"),
3016
- content: useCasesIndexFile(names),
3739
+ content: useCasesIndexFile(names, mode),
3017
3740
  },
3018
3741
  {
3019
3742
  path: resourcePortFilePath(names, config),
3020
- content: repositoryPortFile(names, config),
3743
+ content: repositoryPortFile(names, config, mode, options),
3021
3744
  },
3022
3745
  {
3023
3746
  path: path.join(
@@ -3025,15 +3748,15 @@ function resourceFiles(
3025
3748
  names.pluralKebab,
3026
3749
  `in-memory-${names.singularKebab}-repository.ts`,
3027
3750
  ),
3028
- content: inMemoryRepositoryFile(names, config),
3751
+ content: inMemoryRepositoryFile(names, config, mode, options),
3029
3752
  },
3030
3753
  {
3031
3754
  path: path.join(featureDir, "routes.ts"),
3032
- content: routeGroupFile(names, config),
3755
+ content: routeGroupFile(names, config, mode, options),
3033
3756
  },
3034
3757
  {
3035
3758
  path: resourceTestFilePath(names, config),
3036
- content: testFile(names, config),
3759
+ content: testFile(names, config, mode, options),
3037
3760
  },
3038
3761
  ];
3039
3762
 
@@ -3041,11 +3764,11 @@ function resourceFiles(
3041
3764
  files.push(
3042
3765
  {
3043
3766
  path: drizzleResourceSchemaFilePath(names, config),
3044
- content: drizzleSchemaFile(names),
3767
+ content: drizzleSchemaFile(names, options),
3045
3768
  },
3046
3769
  {
3047
3770
  path: drizzleResourceRepositoryFilePath(names, config),
3048
- content: drizzleRepositoryFile(names, config),
3771
+ content: drizzleRepositoryFile(names, config, mode, options),
3049
3772
  },
3050
3773
  );
3051
3774
  }
@@ -3053,6 +3776,31 @@ function resourceFiles(
3053
3776
  return files;
3054
3777
  }
3055
3778
 
3779
+ function featureUiComponentFiles(
3780
+ names: FeatureUiNames,
3781
+ config: ResolvedBeignetConfig,
3782
+ ): GeneratedFile[] {
3783
+ const componentPath = featureUiComponentFilePath(names, config);
3784
+
3785
+ return [
3786
+ {
3787
+ path: componentPath,
3788
+ content: featureUiComponentFile(names, config),
3789
+ },
3790
+ ];
3791
+ }
3792
+
3793
+ function featureUiIndexFile(
3794
+ names: FeatureUiNames,
3795
+ config: ResolvedBeignetConfig,
3796
+ ): GeneratedFile {
3797
+ return {
3798
+ path: featureUiIndexFilePath(names, config),
3799
+ content: `export { ${names.componentExportName} } from "./${names.componentFileName}";
3800
+ `,
3801
+ };
3802
+ }
3803
+
3056
3804
  function contractFiles(
3057
3805
  names: ResourceNames,
3058
3806
  config: ResolvedBeignetConfig,
@@ -3151,6 +3899,18 @@ function jobFiles(
3151
3899
  ];
3152
3900
  }
3153
3901
 
3902
+ function taskFiles(
3903
+ names: TaskNames,
3904
+ config: ResolvedBeignetConfig,
3905
+ ): GeneratedFile[] {
3906
+ return [
3907
+ {
3908
+ path: taskFilePath(names, config),
3909
+ content: taskFile(names, config),
3910
+ },
3911
+ ];
3912
+ }
3913
+
3154
3914
  function notificationFiles(
3155
3915
  names: NotificationNames,
3156
3916
  config: ResolvedBeignetConfig,
@@ -3283,6 +4043,30 @@ function drizzleResourceRepositoryFilePath(
3283
4043
  );
3284
4044
  }
3285
4045
 
4046
+ function resourceSharedErrorsPath(config: ResolvedBeignetConfig): string {
4047
+ return path.join(config.paths.features, "shared", "errors.ts");
4048
+ }
4049
+
4050
+ function resourceContractNames(
4051
+ names: ResourceNames,
4052
+ mode: ResourceGenerationMode,
4053
+ ): string[] {
4054
+ const contractNames = [
4055
+ `list${names.pluralPascal}`,
4056
+ `create${names.singularPascal}`,
4057
+ ];
4058
+
4059
+ if (mode === "resource") {
4060
+ contractNames.push(
4061
+ `get${names.singularPascal}`,
4062
+ `update${names.singularPascal}`,
4063
+ `delete${names.singularPascal}`,
4064
+ );
4065
+ }
4066
+
4067
+ return contractNames;
4068
+ }
4069
+
3286
4070
  async function detectResourcePersistence(
3287
4071
  targetDir: string,
3288
4072
  config: ResolvedBeignetConfig,
@@ -3301,6 +4085,36 @@ function resourceFeatureDir(
3301
4085
  return path.join(config.paths.features, names.pluralKebab);
3302
4086
  }
3303
4087
 
4088
+ function featureUiDir(
4089
+ names: FeatureUiNames,
4090
+ config: ResolvedBeignetConfig,
4091
+ ): string {
4092
+ return path.join(resourceFeatureDir(names, config), "components");
4093
+ }
4094
+
4095
+ function featureUiComponentFilePath(
4096
+ names: FeatureUiNames,
4097
+ config: ResolvedBeignetConfig,
4098
+ ): string {
4099
+ return path.join(
4100
+ featureUiDir(names, config),
4101
+ `${names.componentFileName}.tsx`,
4102
+ );
4103
+ }
4104
+
4105
+ function featureUiIndexFilePath(
4106
+ names: FeatureUiNames,
4107
+ config: ResolvedBeignetConfig,
4108
+ ): string {
4109
+ return path.join(featureUiDir(names, config), "index.ts");
4110
+ }
4111
+
4112
+ function clientIndexPath(config: ResolvedBeignetConfig): string {
4113
+ const appRoot = path.posix.dirname(config.paths.appContext);
4114
+
4115
+ return path.join(appRoot === "." ? "" : appRoot, "client/index.ts");
4116
+ }
4117
+
3304
4118
  function usesFeatureOwnedContracts(config: ResolvedBeignetConfig): boolean {
3305
4119
  return (
3306
4120
  directoryPath(config.paths.contracts) ===
@@ -3365,6 +4179,13 @@ function resourceUseCaseIndexPath(
3365
4179
  return path.join(resourceUseCaseDir(names, config), "index.ts");
3366
4180
  }
3367
4181
 
4182
+ function resourceSchemaFilePath(
4183
+ names: ResourceNames,
4184
+ config: ResolvedBeignetConfig,
4185
+ ): string {
4186
+ return path.join(resourceFeatureDir(names, config), "schemas.ts");
4187
+ }
4188
+
3368
4189
  function resourcePortFilePath(
3369
4190
  names: ResourceNames,
3370
4191
  config: ResolvedBeignetConfig,
@@ -3430,6 +4251,7 @@ function featureArtifactDir(
3430
4251
  kind:
3431
4252
  | "events"
3432
4253
  | "factories"
4254
+ | "tasks"
3433
4255
  | "jobs"
3434
4256
  | "notifications"
3435
4257
  | "listeners"
@@ -3467,6 +4289,13 @@ function jobFilePath(names: JobNames, config: ResolvedBeignetConfig): string {
3467
4289
  );
3468
4290
  }
3469
4291
 
4292
+ function taskFilePath(names: TaskNames, config: ResolvedBeignetConfig): string {
4293
+ return path.join(
4294
+ featureArtifactDir(names, "tasks", config),
4295
+ `${names.artifact.kebab}.ts`,
4296
+ );
4297
+ }
4298
+
3470
4299
  function factoryFilePath(
3471
4300
  names: FactoryNames,
3472
4301
  config: ResolvedBeignetConfig,
@@ -3585,11 +4414,36 @@ async function updateUseCaseIndex(
3585
4414
  return "updated";
3586
4415
  }
3587
4416
 
4417
+ async function updateFeatureUiIndex(
4418
+ targetDir: string,
4419
+ file: GeneratedFile,
4420
+ options: { dryRun: boolean },
4421
+ ): Promise<WriteGeneratedFileResult> {
4422
+ const destination = path.join(targetDir, file.path);
4423
+ const existing = await readOptionalFile(destination);
4424
+ if (existing === undefined) {
4425
+ if (!options.dryRun) {
4426
+ await mkdir(path.dirname(destination), { recursive: true });
4427
+ await writeFile(destination, file.content);
4428
+ }
4429
+ return "created";
4430
+ }
4431
+
4432
+ if (existing.includes(file.content.trim())) {
4433
+ return "skipped";
4434
+ }
4435
+
4436
+ const next = `${existing.trimEnd()}\n${file.content}`;
4437
+ if (!options.dryRun) await writeFile(destination, next);
4438
+ return "updated";
4439
+ }
4440
+
3588
4441
  type FeatureArtifactIndexFile = {
3589
4442
  path: string;
3590
4443
  kind:
3591
4444
  | "event"
3592
4445
  | "job"
4446
+ | "task"
3593
4447
  | "factory"
3594
4448
  | "seed"
3595
4449
  | "notification"
@@ -3606,6 +4460,7 @@ function featureArtifactIndexFile(
3606
4460
  kind:
3607
4461
  | "event"
3608
4462
  | "job"
4463
+ | "task"
3609
4464
  | "factory"
3610
4465
  | "seed"
3611
4466
  | "notification"
@@ -3615,6 +4470,7 @@ function featureArtifactIndexFile(
3615
4470
  names:
3616
4471
  | EventNames
3617
4472
  | JobNames
4473
+ | TaskNames
3618
4474
  | FactoryNames
3619
4475
  | SeedNames
3620
4476
  | NotificationNames
@@ -3628,49 +4484,55 @@ function featureArtifactIndexFile(
3628
4484
  ? eventFilePath(names as EventNames, config)
3629
4485
  : kind === "job"
3630
4486
  ? 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);
4487
+ : kind === "task"
4488
+ ? taskFilePath(names as TaskNames, config)
4489
+ : kind === "factory"
4490
+ ? factoryFilePath(names as FactoryNames, config)
4491
+ : kind === "seed"
4492
+ ? seedFilePath(names as SeedNames, config)
4493
+ : kind === "notification"
4494
+ ? notificationFilePath(names as NotificationNames, config)
4495
+ : kind === "listener"
4496
+ ? listenerFilePath(names as ListenerNames, config)
4497
+ : kind === "schedule"
4498
+ ? scheduleFilePath(names as ScheduleNames, config)
4499
+ : uploadFilePath(names as UploadNames, config);
3642
4500
  const registrySuffix =
3643
4501
  kind === "event"
3644
4502
  ? "Events"
3645
4503
  : kind === "job"
3646
4504
  ? "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";
4505
+ : kind === "task"
4506
+ ? "Tasks"
4507
+ : kind === "factory"
4508
+ ? "Factories"
4509
+ : kind === "seed"
4510
+ ? "Seeds"
4511
+ : kind === "notification"
4512
+ ? "Notifications"
4513
+ : kind === "listener"
4514
+ ? "Listeners"
4515
+ : kind === "schedule"
4516
+ ? "Schedules"
4517
+ : "Uploads";
3658
4518
  const importName =
3659
4519
  kind === "event"
3660
4520
  ? (names as EventNames).eventExportName
3661
4521
  : kind === "job"
3662
4522
  ? (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;
4523
+ : kind === "task"
4524
+ ? (names as TaskNames).taskExportName
4525
+ : kind === "factory"
4526
+ ? (names as FactoryNames).factoryExportName
4527
+ : kind === "seed"
4528
+ ? (names as SeedNames).seedExportName
4529
+ : kind === "notification"
4530
+ ? (names as NotificationNames).notificationExportName
4531
+ : kind === "listener"
4532
+ ? (names as ListenerNames).listenerExportName
4533
+ : kind === "schedule"
4534
+ ? (names as ScheduleNames).scheduleExportName
4535
+ : (names as UploadNames).uploadExportName;
3674
4536
 
3675
4537
  return {
3676
4538
  path: path.join(path.dirname(artifactPath), "index.ts"),
@@ -3786,15 +4648,92 @@ export const ${file.registryName} = [${file.importName}] as const;
3786
4648
  return "updated";
3787
4649
  }
3788
4650
 
3789
- function arrayInitializerInfo(
3790
- source: string,
3791
- constName: string,
3792
- ): { text: string; start: number; end: number } | undefined {
3793
- const match = new RegExp(`\\bconst\\s+${constName}\\s*=`).exec(source);
3794
- if (!match) return undefined;
3795
-
3796
- const openBracket = source.indexOf("[", match.index);
3797
- if (openBracket === -1) return undefined;
4651
+ async function updateTaskRegistry(
4652
+ targetDir: string,
4653
+ names: TaskNames,
4654
+ config: ResolvedBeignetConfig,
4655
+ options: { dryRun: boolean },
4656
+ ): Promise<WriteGeneratedFileResult> {
4657
+ const destination = path.join(targetDir, config.paths.tasks);
4658
+ const existing = await readOptionalFile(destination);
4659
+ const featureTasksPath = path.join(
4660
+ featureArtifactDir(names, "tasks", config),
4661
+ "index.ts",
4662
+ );
4663
+ const registryName = `${names.featureSingularCamel}Tasks`;
4664
+ const importLine = `import { ${registryName} } from "${relativeModule(
4665
+ config.paths.tasks,
4666
+ featureTasksPath,
4667
+ )}";`;
4668
+ const defineTasksImport = `import { defineTasks } from "@beignet/core/tasks";`;
4669
+ const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
4670
+ const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
4671
+ const serverImport = `import { server } from "${relativeModule(
4672
+ config.paths.tasks,
4673
+ config.paths.server,
4674
+ )}";`;
4675
+ const registryEntry = `...${registryName}`;
4676
+
4677
+ if (existing === undefined) {
4678
+ const content = `${defineTasksImport}
4679
+ ${serviceActorImport}
4680
+ ${appContextImport}
4681
+ ${importLine}
4682
+ ${serverImport}
4683
+
4684
+ export const tasks = defineTasks([
4685
+ \t${registryEntry},
4686
+ ] as const);
4687
+
4688
+ export async function createTaskContext(): Promise<AppContext> {
4689
+ \treturn server.createServiceContext({
4690
+ \t\tactor: createServiceActor("beignet-cli"),
4691
+ \t});
4692
+ }
4693
+
4694
+ export async function stopTaskContext(): Promise<void> {
4695
+ \tawait server.stop();
4696
+ }
4697
+ `;
4698
+ if (!options.dryRun) {
4699
+ await mkdir(path.dirname(destination), { recursive: true });
4700
+ await writeFile(destination, content);
4701
+ }
4702
+ return "created";
4703
+ }
4704
+
4705
+ let next = existing;
4706
+ if (!next.includes(defineTasksImport)) {
4707
+ next = insertAfterImports(next, defineTasksImport);
4708
+ }
4709
+ if (!next.includes(importLine)) {
4710
+ next = insertAfterImports(next, importLine);
4711
+ }
4712
+
4713
+ const registry = arrayInitializerInfo(next, "tasks");
4714
+ if (!registry) {
4715
+ next = `${next.trimEnd()}\n\nexport const tasks = defineTasks([\n\t${registryEntry},\n] as const);\n`;
4716
+ } else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
4717
+ const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
4718
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(
4719
+ registry.end,
4720
+ )}`;
4721
+ }
4722
+
4723
+ if (next === existing) return "skipped";
4724
+ if (!options.dryRun) await writeFile(destination, next);
4725
+ return "updated";
4726
+ }
4727
+
4728
+ function arrayInitializerInfo(
4729
+ source: string,
4730
+ constName: string,
4731
+ ): { text: string; start: number; end: number } | undefined {
4732
+ const match = new RegExp(`\\bconst\\s+${constName}\\s*=`).exec(source);
4733
+ if (!match) return undefined;
4734
+
4735
+ const openBracket = source.indexOf("[", match.index);
4736
+ if (openBracket === -1) return undefined;
3798
4737
 
3799
4738
  const closeBracket = matchingDelimiterIndex(source, openBracket, "[", "]");
3800
4739
  if (closeBracket === -1) return undefined;
@@ -3828,27 +4767,110 @@ function defineUploadsInitializerInfo(
3828
4767
  };
3829
4768
  }
3830
4769
 
3831
- function schemasFile(names: ResourceNames): string {
4770
+ function schemasFile(
4771
+ names: ResourceNames,
4772
+ mode: ResourceGenerationMode,
4773
+ options: ResourceGenerationOptions,
4774
+ ): string {
3832
4775
  return `import { z } from "zod";
3833
4776
 
3834
4777
  export const ${names.singularPascal}Schema = z.object({
3835
4778
  id: z.string().uuid(),
3836
- name: z.string().min(1),
4779
+ ${options.tenant ? `\ttenantId: z.string().min(1),\n` : ""} name: z.string().min(1),
4780
+ version: z.number().int().min(1),
3837
4781
  createdAt: z.string().datetime(),
4782
+ updatedAt: z.string().datetime(),
3838
4783
  });
3839
4784
 
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),
4785
+ export const ${names.singularPascal}SortBySchema = z.enum(["createdAt", "name"]);
4786
+ export const ${names.singularPascal}SortDirectionSchema = z.enum(["asc", "desc"]);
4787
+
4788
+ const ${names.singularPascal}CursorPayloadSchema = z.object({
4789
+ sortBy: ${names.singularPascal}SortBySchema,
4790
+ sortDirection: ${names.singularPascal}SortDirectionSchema,
4791
+ sortValue: z.string(),
4792
+ id: z.string().uuid(),
3843
4793
  });
3844
4794
 
4795
+ export type ${names.singularPascal}SortBy = z.infer<
4796
+ typeof ${names.singularPascal}SortBySchema
4797
+ >;
4798
+ export type ${names.singularPascal}SortDirection = z.infer<
4799
+ typeof ${names.singularPascal}SortDirectionSchema
4800
+ >;
4801
+ export type ${names.singularPascal}Cursor = z.infer<
4802
+ typeof ${names.singularPascal}CursorPayloadSchema
4803
+ >;
4804
+
4805
+ function encodeBase64Url(value: string): string {
4806
+ const bytes = new TextEncoder().encode(value);
4807
+ let binary = "";
4808
+ for (const byte of bytes) binary += String.fromCharCode(byte);
4809
+
4810
+ return btoa(binary)
4811
+ .replaceAll("+", "-")
4812
+ .replaceAll("/", "_")
4813
+ .replaceAll("=", "");
4814
+ }
4815
+
4816
+ function decodeBase64Url(value: string): string {
4817
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
4818
+ const padding = "=".repeat((4 - (base64.length % 4)) % 4);
4819
+ const binary = atob(base64 + padding);
4820
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
4821
+
4822
+ return new TextDecoder().decode(bytes);
4823
+ }
4824
+
4825
+ export function encode${names.singularPascal}Cursor(cursor: ${names.singularPascal}Cursor): string {
4826
+ return encodeBase64Url(JSON.stringify(cursor));
4827
+ }
4828
+
4829
+ export function decode${names.singularPascal}Cursor(cursor: string): ${names.singularPascal}Cursor {
4830
+ return ${names.singularPascal}CursorPayloadSchema.parse(
4831
+ JSON.parse(decodeBase64Url(cursor)),
4832
+ );
4833
+ }
4834
+
4835
+ export const List${names.pluralPascal}InputSchema = z
4836
+ .object({
4837
+ limit: z.coerce.number().int().min(1).max(100).default(20),
4838
+ cursor: z.string().nullable().default(null),
4839
+ name: z.string().trim().min(1).max(120).optional(),
4840
+ sortBy: ${names.singularPascal}SortBySchema.default("createdAt"),
4841
+ sortDirection: ${names.singularPascal}SortDirectionSchema.default("desc"),
4842
+ })
4843
+ .superRefine((input, ctx) => {
4844
+ if (!input.cursor) return;
4845
+
4846
+ try {
4847
+ const cursor = decode${names.singularPascal}Cursor(input.cursor);
4848
+ if (
4849
+ cursor.sortBy !== input.sortBy ||
4850
+ cursor.sortDirection !== input.sortDirection
4851
+ ) {
4852
+ ctx.addIssue({
4853
+ code: z.ZodIssueCode.custom,
4854
+ message: "cursor does not match the requested sort.",
4855
+ path: ["cursor"],
4856
+ });
4857
+ }
4858
+ } catch {
4859
+ ctx.addIssue({
4860
+ code: z.ZodIssueCode.custom,
4861
+ message: "cursor is invalid.",
4862
+ path: ["cursor"],
4863
+ });
4864
+ }
4865
+ });
4866
+
3845
4867
  export const List${names.pluralPascal}OutputSchema = z.object({
3846
4868
  items: z.array(${names.singularPascal}Schema),
3847
4869
  page: z.object({
3848
- kind: z.literal("offset"),
4870
+ kind: z.literal("cursor"),
3849
4871
  limit: z.number().int().min(1),
3850
- offset: z.number().int().min(0),
3851
- total: z.number().int().min(0),
4872
+ cursor: z.string().nullable(),
4873
+ nextCursor: z.string().nullable(),
3852
4874
  hasMore: z.boolean(),
3853
4875
  }),
3854
4876
  });
@@ -3856,11 +4878,42 @@ export const List${names.pluralPascal}OutputSchema = z.object({
3856
4878
  export const Create${names.singularPascal}InputSchema = z.object({
3857
4879
  name: z.string().min(1).max(120),
3858
4880
  });
4881
+ ${
4882
+ mode === "resource"
4883
+ ? `
4884
+ export const ${names.singularPascal}IdInputSchema = z.object({
4885
+ id: z.string().uuid(),
4886
+ });
4887
+
4888
+ export const Update${names.singularPascal}BodySchema = z.object({
4889
+ name: z.string().min(1).max(120),
4890
+ version: z.number().int().min(1),
4891
+ });
3859
4892
 
4893
+ export const Update${names.singularPascal}InputSchema =
4894
+ ${names.singularPascal}IdInputSchema.merge(Update${names.singularPascal}BodySchema);
4895
+ `
4896
+ : ""
4897
+ }
3860
4898
  export type ${names.singularPascal} = z.infer<typeof ${names.singularPascal}Schema>;
3861
4899
  export type Create${names.singularPascal}Input = z.infer<
3862
4900
  typeof Create${names.singularPascal}InputSchema
3863
4901
  >;
4902
+ ${
4903
+ mode === "resource"
4904
+ ? `
4905
+ export type ${names.singularPascal}IdInput = z.infer<
4906
+ typeof ${names.singularPascal}IdInputSchema
4907
+ >;
4908
+ export type Update${names.singularPascal}Body = z.infer<
4909
+ typeof Update${names.singularPascal}BodySchema
4910
+ >;
4911
+ export type Update${names.singularPascal}Input = z.infer<
4912
+ typeof Update${names.singularPascal}InputSchema
4913
+ >;
4914
+ `
4915
+ : ""
4916
+ }
3864
4917
  export type List${names.pluralPascal}Input = z.infer<
3865
4918
  typeof List${names.pluralPascal}InputSchema
3866
4919
  >;
@@ -3870,30 +4923,39 @@ export type List${names.pluralPascal}Input = z.infer<
3870
4923
  function listUseCaseFile(
3871
4924
  names: ResourceNames,
3872
4925
  config: ResolvedBeignetConfig,
4926
+ options: ResourceGenerationOptions,
3873
4927
  ): string {
3874
4928
  const filePath = path.join(
3875
4929
  resourceUseCaseDir(names, config),
3876
4930
  `list-${names.pluralKebab}.ts`,
3877
4931
  );
3878
4932
 
3879
- return `import { normalizeOffsetPage } from "@beignet/core/pagination";
4933
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
4934
+ import { normalizeCursorPage } from "@beignet/core/pagination";
3880
4935
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3881
4936
  import {
4937
+ decode${names.singularPascal}Cursor,
3882
4938
  List${names.pluralPascal}InputSchema,
3883
4939
  List${names.pluralPascal}OutputSchema,
3884
- } from "./schemas";
4940
+ } from "../schemas";
3885
4941
 
3886
4942
  export const list${names.pluralPascal}UseCase = useCase
3887
4943
  .query("${names.pluralCamel}.list")
3888
4944
  .input(List${names.pluralPascal}InputSchema)
3889
4945
  .output(List${names.pluralPascal}OutputSchema)
3890
4946
  .run(async ({ ctx, input }) => {
3891
- const page = normalizeOffsetPage(input, {
4947
+ ${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
4948
  defaultLimit: 20,
3893
4949
  maxLimit: 100,
3894
4950
  });
3895
4951
 
3896
- return ctx.ports.${names.pluralCamel}.list(page);
4952
+ return ctx.ports.${names.pluralCamel}.list({
4953
+ page,
4954
+ cursor: page.cursor ? decode${names.singularPascal}Cursor(page.cursor) : null,
4955
+ name: input.name,
4956
+ sortBy: input.sortBy,
4957
+ sortDirection: input.sortDirection,
4958
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
3897
4959
  });
3898
4960
  `;
3899
4961
  }
@@ -3901,134 +4963,430 @@ export const list${names.pluralPascal}UseCase = useCase
3901
4963
  function createUseCaseFile(
3902
4964
  names: ResourceNames,
3903
4965
  config: ResolvedBeignetConfig,
4966
+ options: ResourceGenerationOptions,
3904
4967
  ): string {
3905
4968
  const filePath = path.join(
3906
4969
  resourceUseCaseDir(names, config),
3907
4970
  `create-${names.singularKebab}.ts`,
3908
4971
  );
3909
4972
 
3910
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
4973
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
4974
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3911
4975
  import {
3912
4976
  Create${names.singularPascal}InputSchema,
3913
4977
  ${names.singularPascal}Schema,
3914
- } from "./schemas";
4978
+ } from "../schemas";
4979
+ ${options.events ? `import { ${names.singularPascal}Created } from "../domain/events";\n` : ""}
3915
4980
 
3916
4981
  export const create${names.singularPascal}UseCase = useCase
3917
4982
  .command("${names.pluralCamel}.create")
3918
4983
  .input(Create${names.singularPascal}InputSchema)
3919
4984
  .output(${names.singularPascal}Schema)
3920
- .run(async ({ ctx, input }) => ctx.ports.${names.pluralCamel}.create(input));
4985
+ .run(async ({ ctx, input }) => {
4986
+ ${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({
4987
+ ...input,
4988
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
4989
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Created, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
4990
+ return ${names.singularCamel};
4991
+ });
4992
+ `;
4993
+ }
4994
+
4995
+ function getUseCaseFile(
4996
+ names: ResourceNames,
4997
+ config: ResolvedBeignetConfig,
4998
+ options: ResourceGenerationOptions,
4999
+ ): string {
5000
+ const filePath = path.join(
5001
+ resourceUseCaseDir(names, config),
5002
+ `get-${names.singularKebab}.ts`,
5003
+ );
5004
+
5005
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5006
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
5007
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
5008
+ import {
5009
+ ${names.singularPascal}IdInputSchema,
5010
+ ${names.singularPascal}Schema,
5011
+ } from "../schemas";
5012
+
5013
+ export const get${names.singularPascal}UseCase = useCase
5014
+ .query("${names.pluralCamel}.get")
5015
+ .input(${names.singularPascal}IdInputSchema)
5016
+ .output(${names.singularPascal}Schema)
5017
+ .run(async ({ ctx, input }) => {
5018
+ ${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 }" : ""});
5019
+ if (!${names.singularCamel}) {
5020
+ throw appError("${names.singularPascal}NotFound", {
5021
+ details: { id: input.id },
5022
+ });
5023
+ }
5024
+
5025
+ return ${names.singularCamel};
5026
+ });
3921
5027
  `;
3922
5028
  }
3923
5029
 
3924
- function useCasesIndexFile(names: ResourceNames): string {
5030
+ function updateUseCaseFile(
5031
+ names: ResourceNames,
5032
+ config: ResolvedBeignetConfig,
5033
+ options: ResourceGenerationOptions,
5034
+ ): string {
5035
+ const filePath = path.join(
5036
+ resourceUseCaseDir(names, config),
5037
+ `update-${names.singularKebab}.ts`,
5038
+ );
5039
+
5040
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5041
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
5042
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
5043
+ ${options.events ? `import { ${names.singularPascal}Updated } from "../domain/events";\n` : ""}import {
5044
+ Update${names.singularPascal}InputSchema,
5045
+ ${names.singularPascal}Schema,
5046
+ } from "../schemas";
5047
+
5048
+ export const update${names.singularPascal}UseCase = useCase
5049
+ .command("${names.pluralCamel}.update")
5050
+ .input(Update${names.singularPascal}InputSchema)
5051
+ .output(${names.singularPascal}Schema)
5052
+ .run(async ({ ctx, input }) => {
5053
+ ${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 }" : ""});
5054
+ if (!existing) {
5055
+ throw appError("${names.singularPascal}NotFound", {
5056
+ details: { id: input.id },
5057
+ });
5058
+ }
5059
+ ${options.auth ? `\t\tawait ctx.gate.authorize("${names.pluralCamel}.update", existing);\n\n` : ""} if (existing.version !== input.version) {
5060
+ throw appError("${names.singularPascal}Conflict", {
5061
+ details: { id: input.id, expectedVersion: existing.version },
5062
+ });
5063
+ }
5064
+
5065
+ const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.update({
5066
+ ...input,
5067
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
5068
+ if (!${names.singularCamel}) {
5069
+ throw appError("${names.singularPascal}Conflict", {
5070
+ details: { id: input.id, expectedVersion: existing.version },
5071
+ });
5072
+ }
5073
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Updated, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
5074
+ return ${names.singularCamel};
5075
+ });
5076
+ `;
5077
+ }
5078
+
5079
+ function deleteUseCaseFile(
5080
+ names: ResourceNames,
5081
+ config: ResolvedBeignetConfig,
5082
+ options: ResourceGenerationOptions,
5083
+ ): string {
5084
+ const filePath = path.join(
5085
+ resourceUseCaseDir(names, config),
5086
+ `delete-${names.singularKebab}.ts`,
5087
+ );
5088
+
5089
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5090
+ import { z } from "zod";
5091
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
5092
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
5093
+ ${options.events ? `import { ${names.singularPascal}Deleted } from "../domain/events";\n` : ""}import { ${names.singularPascal}IdInputSchema } from "../schemas";
5094
+
5095
+ export const delete${names.singularPascal}UseCase = useCase
5096
+ .command("${names.pluralCamel}.delete")
5097
+ .input(${names.singularPascal}IdInputSchema)
5098
+ .output(z.void())
5099
+ .run(async ({ ctx, input }) => {
5100
+ ${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 }" : ""});
5101
+ if (!deleted) {
5102
+ throw appError("${names.singularPascal}NotFound", {
5103
+ details: { id: input.id },
5104
+ });
5105
+ }
5106
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Deleted, {\n\t\t\tid: input.id,\n\t\t});\n` : ""} });
5107
+ `;
5108
+ }
5109
+
5110
+ function useCasesIndexFile(
5111
+ names: ResourceNames,
5112
+ mode: ResourceGenerationMode,
5113
+ ): string {
3925
5114
  return `export { create${names.singularPascal}UseCase } from "./create-${names.singularKebab}";
3926
- export { list${names.pluralPascal}UseCase } from "./list-${names.pluralKebab}";
3927
- export {
5115
+ ${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}";
5116
+ ${mode === "resource" ? `export { update${names.singularPascal}UseCase } from "./update-${names.singularKebab}";\n` : ""}export {
3928
5117
  Create${names.singularPascal}InputSchema,
3929
- List${names.pluralPascal}InputSchema,
5118
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n` : ""} List${names.pluralPascal}InputSchema,
3930
5119
  List${names.pluralPascal}OutputSchema,
3931
- ${names.singularPascal}Schema,
5120
+ ${mode === "resource" ? ` Update${names.singularPascal}BodySchema,\n Update${names.singularPascal}InputSchema,\n` : ""} ${names.singularPascal}Schema,
3932
5121
  type Create${names.singularPascal}Input,
3933
- type List${names.pluralPascal}Input,
5122
+ ${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
5123
  type ${names.singularPascal},
3935
- } from "./schemas";
5124
+ } from "../schemas";
3936
5125
  `;
3937
5126
  }
3938
5127
 
3939
5128
  function repositoryPortFile(
3940
5129
  names: ResourceNames,
3941
5130
  config: ResolvedBeignetConfig,
5131
+ mode: ResourceGenerationMode,
5132
+ options: ResourceGenerationOptions,
3942
5133
  ): string {
3943
5134
  return `import type {
3944
- OffsetPage,
3945
- OffsetPageInfo,
5135
+ CursorPage,
5136
+ CursorPageInfo,
3946
5137
  PageResult,
5138
+ SortDirection,
3947
5139
  } from "@beignet/core/pagination";
3948
5140
  import type {
3949
5141
  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>;
5142
+ ${names.singularPascal}Cursor,
5143
+ ${names.singularPascal}SortBy,
5144
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
5145
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
5146
+
5147
+ export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, CursorPageInfo>;
5148
+ export type List${names.pluralPascal}Query = {
5149
+ page: CursorPage;
5150
+ cursor: ${names.singularPascal}Cursor | null;
5151
+ name?: string;
5152
+ sortBy: ${names.singularPascal}SortBy;
5153
+ sortDirection: SortDirection;
5154
+ ${options.tenant ? `\ttenantId: string;\n` : ""}};
5155
+ ${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
5156
 
3955
5157
  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
- }
5158
+ list(query: List${names.pluralPascal}Query): Promise<List${names.pluralPascal}Result>;
5159
+ create(input: ${options.tenant ? `Create${names.singularPascal}RepositoryInput` : `Create${names.singularPascal}Input`}): Promise<${names.singularPascal}>;
5160
+ ${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
5161
  `;
3960
5162
  }
3961
5163
 
3962
5164
  function inMemoryRepositoryFile(
3963
5165
  names: ResourceNames,
3964
5166
  config: ResolvedBeignetConfig,
5167
+ mode: ResourceGenerationMode,
5168
+ options: ResourceGenerationOptions,
3965
5169
  ): string {
3966
5170
  const repositoryPortPath = resourcePortFilePath(names, config);
5171
+ const recordType = options.softDelete
5172
+ ? `type ${names.singularPascal}Record = ${names.singularPascal} & {
5173
+ \tdeletedAt: string | null;
5174
+ };
3967
5175
 
3968
- return `import { offsetPageResult } from "@beignet/core/pagination";
5176
+ `
5177
+ : "";
5178
+ const mapValueType = options.softDelete
5179
+ ? `${names.singularPascal}Record`
5180
+ : names.singularPascal;
5181
+ const activeFilter = options.softDelete
5182
+ ? `\t\t\t\t.filter((${names.singularCamel}) => ${names.singularCamel}.deletedAt === null)\n`
5183
+ : "";
5184
+ const newDeletedAtField = options.softDelete
5185
+ ? `\t\t\t\tdeletedAt: null,\n`
5186
+ : "";
5187
+ const findDeletedGuard = options.softDelete
5188
+ ? `\t\t\tif (${names.singularCamel}?.deletedAt !== null) return null;\n`
5189
+ : "";
5190
+ const updateDeletedGuard = options.softDelete
5191
+ ? " || existing.deletedAt !== null"
5192
+ : "";
5193
+ const deleteImplementation = options.softDelete
5194
+ ? `${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();
5195
+ ${names.pluralCamel}.set(id, { ...existing, deletedAt: now, updatedAt: now });
5196
+ return true;`
5197
+ : `${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);`;
5198
+
5199
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5200
+ import { cursorPageResult } from "@beignet/core/pagination";
3969
5201
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
5202
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
3970
5203
  import type {
3971
5204
  Create${names.singularPascal}Input,
3972
- ${names.singularPascal},
3973
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
5205
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
5206
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
5207
+
5208
+ ${recordType}function to${names.singularPascal}(${names.singularCamel}: ${mapValueType}): ${names.singularPascal} {
5209
+ return {
5210
+ id: ${names.singularCamel}.id,
5211
+ ${options.tenant ? `\t\ttenantId: ${names.singularCamel}.tenantId,\n` : ""} name: ${names.singularCamel}.name,
5212
+ version: ${names.singularCamel}.version,
5213
+ createdAt: ${names.singularCamel}.createdAt,
5214
+ updatedAt: ${names.singularCamel}.updatedAt,
5215
+ };
5216
+ }
5217
+
5218
+ function compare${names.pluralPascal}(
5219
+ left: ${mapValueType},
5220
+ right: ${mapValueType},
5221
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
5222
+ ): number {
5223
+ const leftValue = left[query.sortBy];
5224
+ const rightValue = right[query.sortBy];
5225
+ const fieldComparison =
5226
+ leftValue === rightValue ? left.id.localeCompare(right.id) : leftValue.localeCompare(rightValue);
5227
+
5228
+ return query.sortDirection === "asc" ? fieldComparison : -fieldComparison;
5229
+ }
5230
+
5231
+ function isAfter${names.singularPascal}Cursor(
5232
+ ${names.singularCamel}: ${mapValueType},
5233
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
5234
+ ): boolean {
5235
+ if (!query.cursor) return true;
5236
+
5237
+ const sortValue = ${names.singularCamel}[query.sortBy];
5238
+ const fieldComparison =
5239
+ sortValue === query.cursor.sortValue
5240
+ ? ${names.singularCamel}.id.localeCompare(query.cursor.id)
5241
+ : sortValue.localeCompare(query.cursor.sortValue);
5242
+
5243
+ return query.sortDirection === "asc" ? fieldComparison > 0 : fieldComparison < 0;
5244
+ }
5245
+
5246
+ function cursorFor${names.singularPascal}(
5247
+ ${names.singularCamel}: ${mapValueType},
5248
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
5249
+ ): string {
5250
+ return encode${names.singularPascal}Cursor({
5251
+ sortBy: query.sortBy,
5252
+ sortDirection: query.sortDirection,
5253
+ sortValue: ${names.singularCamel}[query.sortBy],
5254
+ id: ${names.singularCamel}.id,
5255
+ });
5256
+ }
3974
5257
 
3975
5258
  export function createInMemory${names.singularPascal}Repository(
3976
5259
  seed: ${names.singularPascal}[] = [],
3977
5260
  ): ${names.singularPascal}Repository {
3978
- const ${names.pluralCamel} = new Map(seed.map((${names.singularCamel}) => [${names.singularCamel}.id, ${names.singularCamel}]));
5261
+ const ${names.pluralCamel} = new Map<string, ${mapValueType}>(
5262
+ seed.map((${names.singularCamel}) => [
5263
+ ${names.singularCamel}.id,
5264
+ { ...${names.singularCamel}${options.softDelete ? ", deletedAt: null" : ""} },
5265
+ ]),
5266
+ );
3979
5267
 
3980
5268
  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,
5269
+ async list(query) {
5270
+ const name = query.name?.toLocaleLowerCase();
5271
+ const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values())
5272
+ ${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))
5273
+ .sort((left, right) => compare${names.pluralPascal}(left, right, query))
5274
+ .filter((${names.singularCamel}) => isAfter${names.singularPascal}Cursor(${names.singularCamel}, query));
5275
+ const pageItems = all${names.pluralPascal}.slice(0, query.page.limit);
5276
+ const nextCursor =
5277
+ all${names.pluralPascal}.length > query.page.limit && pageItems.length > 0
5278
+ ? cursorFor${names.singularPascal}(pageItems[pageItems.length - 1]!, query)
5279
+ : null;
5280
+
5281
+ return cursorPageResult(
5282
+ pageItems.map(to${names.singularPascal}),
5283
+ query.page,
5284
+ nextCursor,
3990
5285
  );
3991
5286
  },
3992
- async create(input: Create${names.singularPascal}Input) {
5287
+ async create(input) {
5288
+ const now = new Date().toISOString();
3993
5289
  const ${names.singularCamel}: ${names.singularPascal} = {
3994
5290
  id: crypto.randomUUID(),
3995
- name: input.name,
3996
- createdAt: new Date().toISOString(),
5291
+ ${options.tenant ? `\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
5292
+ version: 1,
5293
+ createdAt: now,
5294
+ updatedAt: now,
3997
5295
  };
3998
- ${names.pluralCamel}.set(${names.singularCamel}.id, ${names.singularCamel});
5296
+ ${names.pluralCamel}.set(${names.singularCamel}.id, {
5297
+ ...${names.singularCamel},
5298
+ ${newDeletedAtField} });
3999
5299
  return ${names.singularCamel};
4000
5300
  },
5301
+ ${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
5302
  };
4002
5303
  }
4003
5304
  `;
4004
5305
  }
4005
5306
 
4006
- function drizzleSchemaFile(names: ResourceNames): string {
4007
- return `import { sqliteTable, text } from "drizzle-orm/sqlite-core";
5307
+ function drizzleSchemaFile(
5308
+ names: ResourceNames,
5309
+ options: ResourceGenerationOptions,
5310
+ ): string {
5311
+ return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
4008
5312
 
4009
5313
  export const ${names.pluralCamel} = sqliteTable("${names.pluralKebab.replaceAll("-", "_")}", {
4010
5314
  id: text("id").primaryKey(),
4011
- name: text("name").notNull(),
5315
+ ${options.tenant ? `\ttenantId: text("tenant_id").notNull(),\n` : ""} name: text("name").notNull(),
5316
+ version: integer("version").notNull(),
4012
5317
  createdAt: text("created_at").notNull(),
4013
- });
5318
+ updatedAt: text("updated_at").notNull(),
5319
+ ${options.softDelete ? `\tdeletedAt: text("deleted_at"),\n` : ""}});
4014
5320
  `;
4015
5321
  }
4016
5322
 
5323
+ function drizzleResourceWhere(
5324
+ names: ResourceNames,
5325
+ options: ResourceGenerationOptions,
5326
+ predicates: readonly string[],
5327
+ ): string {
5328
+ const allPredicates = [
5329
+ ...predicates,
5330
+ ...(options.softDelete
5331
+ ? [`isNull(schema.${names.pluralCamel}.deletedAt)`]
5332
+ : []),
5333
+ ];
5334
+
5335
+ if (allPredicates.length === 0) return "";
5336
+ if (allPredicates.length === 1) return allPredicates[0] ?? "";
5337
+ return `and(${allPredicates.join(", ")})`;
5338
+ }
5339
+
4017
5340
  function drizzleRepositoryFile(
4018
5341
  names: ResourceNames,
4019
5342
  config: ResolvedBeignetConfig,
5343
+ mode: ResourceGenerationMode,
5344
+ options: ResourceGenerationOptions,
4020
5345
  ): string {
4021
5346
  const repositoryPortPath = resourcePortFilePath(names, config);
4022
5347
  const schemaPath = drizzleSchemaIndexPath(config);
4023
-
4024
- return `import { offsetPageResult } from "@beignet/core/pagination";
5348
+ const findWhere = drizzleResourceWhere(names, options, [
5349
+ `eq(schema.${names.pluralCamel}.id, id)`,
5350
+ ...(options.tenant
5351
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
5352
+ : []),
5353
+ ]);
5354
+ const updateWhere = drizzleResourceWhere(names, options, [
5355
+ `eq(schema.${names.pluralCamel}.id, input.id)`,
5356
+ `eq(schema.${names.pluralCamel}.version, input.version)`,
5357
+ ...(options.tenant
5358
+ ? [`eq(schema.${names.pluralCamel}.tenantId, input.tenantId)`]
5359
+ : []),
5360
+ ]);
5361
+ const deleteWhere = drizzleResourceWhere(names, options, [
5362
+ `eq(schema.${names.pluralCamel}.id, id)`,
5363
+ ...(options.tenant
5364
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
5365
+ : []),
5366
+ ]);
5367
+ const drizzleImports = [
5368
+ "and",
5369
+ "asc",
5370
+ "desc",
5371
+ "eq",
5372
+ "gt",
5373
+ options.softDelete ? "isNull" : undefined,
5374
+ "lt",
5375
+ "or",
5376
+ "sql",
5377
+ "type SQL",
5378
+ ].filter((name): name is string => Boolean(name));
5379
+
5380
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5381
+ import { cursorPageResult } from "@beignet/core/pagination";
4025
5382
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
4026
- import { count, desc } from "drizzle-orm";
5383
+ import { ${drizzleImports.join(", ")} } from "drizzle-orm";
4027
5384
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
5385
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
4028
5386
  import type {
4029
5387
  Create${names.singularPascal}Input,
4030
- ${names.singularPascal},
4031
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
5388
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
5389
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
4032
5390
  import * as schema from "${aliasModule(schemaPath)}";
4033
5391
 
4034
5392
  type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelect;
@@ -4036,35 +5394,104 @@ type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelec
4036
5394
  function to${names.singularPascal}(row: ${names.singularPascal}Row): ${names.singularPascal} {
4037
5395
  return {
4038
5396
  id: row.id,
4039
- name: row.name,
5397
+ ${options.tenant ? `\t\ttenantId: row.tenantId,\n` : ""} name: row.name,
5398
+ version: row.version,
4040
5399
  createdAt: row.createdAt,
5400
+ updatedAt: row.updatedAt,
4041
5401
  };
4042
5402
  }
4043
5403
 
5404
+ type List${names.pluralPascal}Query = Parameters<${names.singularPascal}Repository["list"]>[0];
5405
+
5406
+ function ${names.singularCamel}SortColumn(query: List${names.pluralPascal}Query) {
5407
+ return query.sortBy === "name" ? schema.${names.pluralCamel}.name : schema.${names.pluralCamel}.createdAt;
5408
+ }
5409
+
5410
+ function ${names.singularCamel}CursorFilter(
5411
+ query: List${names.pluralPascal}Query,
5412
+ ): SQL<unknown> | undefined {
5413
+ if (!query.cursor) return undefined;
5414
+
5415
+ const column = ${names.singularCamel}SortColumn(query);
5416
+ const compare = query.sortDirection === "asc" ? gt : lt;
5417
+
5418
+ return or(
5419
+ compare(column, query.cursor.sortValue),
5420
+ and(
5421
+ eq(column, query.cursor.sortValue),
5422
+ compare(schema.${names.pluralCamel}.id, query.cursor.id),
5423
+ ),
5424
+ ) as SQL<unknown>;
5425
+ }
5426
+
5427
+ function ${names.singularCamel}ListWhere(
5428
+ query: List${names.pluralPascal}Query,
5429
+ ): SQL<unknown> | undefined {
5430
+ const filters: SQL<unknown>[] = [
5431
+ ${options.tenant ? `\t\teq(schema.${names.pluralCamel}.tenantId, query.tenantId),\n` : ""}${options.softDelete ? `\t\tisNull(schema.${names.pluralCamel}.deletedAt),\n` : ""} ];
5432
+ const cursor = ${names.singularCamel}CursorFilter(query);
5433
+
5434
+ if (query.name) {
5435
+ const pattern = \`%\${query.name.toLocaleLowerCase()}%\`;
5436
+ filters.push(sql\`lower(\${schema.${names.pluralCamel}.name}) like \${pattern}\`);
5437
+ }
5438
+ if (cursor) filters.push(cursor);
5439
+
5440
+ return filters.length > 0 ? and(...filters) : undefined;
5441
+ }
5442
+
5443
+ function ${names.singularCamel}OrderBy(query: List${names.pluralPascal}Query) {
5444
+ const order = query.sortDirection === "asc" ? asc : desc;
5445
+
5446
+ return [order(${names.singularCamel}SortColumn(query)), order(schema.${names.pluralCamel}.id)] as const;
5447
+ }
5448
+
5449
+ function cursorFor${names.singularPascal}(
5450
+ row: ${names.singularPascal}Row,
5451
+ query: List${names.pluralPascal}Query,
5452
+ ): string {
5453
+ return encode${names.singularPascal}Cursor({
5454
+ sortBy: query.sortBy,
5455
+ sortDirection: query.sortDirection,
5456
+ sortValue: row[query.sortBy],
5457
+ id: row.id,
5458
+ });
5459
+ }
5460
+
4044
5461
  export function createDrizzle${names.singularPascal}Repository(
4045
5462
  db: DrizzleTursoDatabase<typeof schema>,
4046
5463
  ): ${names.singularPascal}Repository {
4047
5464
  return {
4048
- async list(page) {
5465
+ async list(query) {
5466
+ const where = ${names.singularCamel}ListWhere(query);
4049
5467
  const rows = await db
4050
5468
  .select()
4051
5469
  .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);
5470
+ .where(where)
5471
+ .orderBy(...${names.singularCamel}OrderBy(query))
5472
+ .limit(query.page.limit + 1);
5473
+ const pageRows = rows.slice(0, query.page.limit);
5474
+ const nextCursor =
5475
+ rows.length > query.page.limit && pageRows.length > 0
5476
+ ? cursorFor${names.singularPascal}(pageRows[pageRows.length - 1]!, query)
5477
+ : null;
5478
+
5479
+ return cursorPageResult(
5480
+ pageRows.map(to${names.singularPascal}),
5481
+ query.page,
5482
+ nextCursor,
5483
+ );
4060
5484
  },
4061
- async create(input: Create${names.singularPascal}Input) {
5485
+ async create(input) {
5486
+ const now = new Date().toISOString();
4062
5487
  const [row] = await db
4063
5488
  .insert(schema.${names.pluralCamel})
4064
5489
  .values({
4065
5490
  id: crypto.randomUUID(),
4066
- name: input.name,
4067
- createdAt: new Date().toISOString(),
5491
+ ${options.tenant ? `\t\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
5492
+ version: 1,
5493
+ createdAt: now,
5494
+ updatedAt: now,
4068
5495
  })
4069
5496
  .returning();
4070
5497
 
@@ -4074,6 +5501,7 @@ export function createDrizzle${names.singularPascal}Repository(
4074
5501
 
4075
5502
  return to${names.singularPascal}(row);
4076
5503
  },
5504
+ ${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
5505
  };
4078
5506
  }
4079
5507
  `;
@@ -4082,13 +5510,17 @@ export function createDrizzle${names.singularPascal}Repository(
4082
5510
  function contractFile(
4083
5511
  names: ResourceNames,
4084
5512
  config: ResolvedBeignetConfig,
5513
+ mode: ResourceGenerationMode,
5514
+ options: ResourceGenerationOptions,
4085
5515
  ): string {
4086
- return `import { createContractGroup } from "@beignet/core/contracts";
5516
+ return `import { defineContractGroup } from "@beignet/core/contracts";
4087
5517
  import { z } from "zod";
4088
- import {
4089
- create${names.singularPascal}UseCase,
4090
- list${names.pluralPascal}UseCase,
4091
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
5518
+ ${mode === "resource" ? `import { errors } from "${aliasModule(resourceSharedErrorsPath(config))}";\n` : ""}import {
5519
+ Create${names.singularPascal}InputSchema,
5520
+ List${names.pluralPascal}InputSchema,
5521
+ List${names.pluralPascal}OutputSchema,
5522
+ ${names.singularPascal}Schema,
5523
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n Update${names.singularPascal}BodySchema,\n` : ""}} from "${aliasModule(resourceSchemaFilePath(names, config))}";
4092
5524
 
4093
5525
  const ErrorResponseSchema = z.object({
4094
5526
  code: z.string(),
@@ -4096,7 +5528,7 @@ const ErrorResponseSchema = z.object({
4096
5528
  requestId: z.string().optional(),
4097
5529
  });
4098
5530
 
4099
- const ${names.pluralCamel} = createContractGroup()
5531
+ const ${names.pluralCamel} = defineContractGroup()
4100
5532
  .namespace("${names.pluralCamel}")
4101
5533
  .responses({
4102
5534
  500: ErrorResponseSchema,
@@ -4104,22 +5536,52 @@ const ${names.pluralCamel} = createContractGroup()
4104
5536
 
4105
5537
  export const list${names.pluralPascal} = ${names.pluralCamel}
4106
5538
  .get("/api/${names.pluralKebab}")
4107
- .query(list${names.pluralPascal}UseCase.inputSchema)
5539
+ .query(List${names.pluralPascal}InputSchema)
4108
5540
  .responses({
4109
- 200: list${names.pluralPascal}UseCase.outputSchema,
5541
+ 200: List${names.pluralPascal}OutputSchema,
4110
5542
  });
4111
5543
 
4112
5544
  export const create${names.singularPascal} = ${names.pluralCamel}
4113
5545
  .post("/api/${names.pluralKebab}")
4114
- .body(create${names.singularPascal}UseCase.inputSchema)
5546
+ .body(Create${names.singularPascal}InputSchema)
5547
+ ${options.auth ? ` .meta({\n auth: "required",\n authorization: { ability: "${names.pluralCamel}.create" },\n })\n .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden })\n` : ""} .responses({
5548
+ 201: ${names.singularPascal}Schema,
5549
+ });
5550
+ ${
5551
+ mode === "resource"
5552
+ ? `
5553
+ export const get${names.singularPascal} = ${names.pluralCamel}
5554
+ .get("/api/${names.pluralKebab}/:id")
5555
+ .pathParams(${names.singularPascal}IdInputSchema)
5556
+ .errors({ ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound })
5557
+ .responses({
5558
+ 200: ${names.singularPascal}Schema,
5559
+ });
5560
+
5561
+ export const update${names.singularPascal} = ${names.pluralCamel}
5562
+ .patch("/api/${names.pluralKebab}/:id")
5563
+ .pathParams(${names.singularPascal}IdInputSchema)
5564
+ .body(Update${names.singularPascal}BodySchema)
5565
+ ${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 })
4115
5566
  .responses({
4116
- 201: create${names.singularPascal}UseCase.outputSchema,
5567
+ 200: ${names.singularPascal}Schema,
4117
5568
  });
5569
+
5570
+ export const delete${names.singularPascal} = ${names.pluralCamel}
5571
+ .delete("/api/${names.pluralKebab}/:id")
5572
+ .pathParams(${names.singularPascal}IdInputSchema)
5573
+ ${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 })
5574
+ .responses({
5575
+ 204: null,
5576
+ });
5577
+ `
5578
+ : ""
5579
+ }
4118
5580
  `;
4119
5581
  }
4120
5582
 
4121
5583
  function standaloneContractFile(names: ResourceNames): string {
4122
- return `import { createContractGroup } from "@beignet/core/contracts";
5584
+ return `import { defineContractGroup } from "@beignet/core/contracts";
4123
5585
  import { z } from "zod";
4124
5586
 
4125
5587
  const ErrorResponseSchema = z.object({
@@ -4128,7 +5590,7 @@ const ErrorResponseSchema = z.object({
4128
5590
  requestId: z.string().optional(),
4129
5591
  });
4130
5592
 
4131
- const ${names.pluralCamel} = createContractGroup()
5593
+ const ${names.pluralCamel} = defineContractGroup()
4132
5594
  .namespace("${names.pluralCamel}")
4133
5595
  .responses({
4134
5596
  500: ErrorResponseSchema,
@@ -4169,7 +5631,8 @@ function standaloneUseCaseFile(
4169
5631
  config: ResolvedBeignetConfig,
4170
5632
  filePath: string,
4171
5633
  ): string {
4172
- return `import { z } from "zod";
5634
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5635
+ import { z } from "zod";
4173
5636
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
4174
5637
 
4175
5638
  export const ${names.action.pascal}InputSchema = z.object({});
@@ -4202,29 +5665,27 @@ function useCaseTestFile(
4202
5665
  return `import { describe, expect, it } from "bun:test";
4203
5666
  import { createUseCaseTester } from "@beignet/core/application";
4204
5667
  import { createInMemoryDevtools } from "@beignet/devtools";
4205
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
5668
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
5669
+ import { createTestAnonymousActor } from "@beignet/core/ports/testing";
4206
5670
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4207
5671
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
4208
5672
  import { ${names.exportName} } from "${relativeModule(filePath, useCaseFilePath(names, config))}";
4209
5673
 
4210
5674
  describe("${names.exportName}", () => {
4211
5675
  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
- }));
5676
+ const testFixture = createTestPorts<AppContext["ports"]>({
5677
+ base: appPorts,
5678
+ overrides: {
5679
+ gate: appPorts.gate,
5680
+ devtools: createInMemoryDevtools(),
5681
+ },
5682
+ });
5683
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
5684
+ ports: testFixture.ports,
5685
+ actor: createTestAnonymousActor(),
5686
+ tenant: null,
5687
+ });
5688
+ const tester = createUseCaseTester<AppContext>(createTestContext);
4228
5689
 
4229
5690
  const result = await tester.run(${names.exportName}, {});
4230
5691
 
@@ -4290,6 +5751,144 @@ export const ${names.policyName} = definePolicy({
4290
5751
  `;
4291
5752
  }
4292
5753
 
5754
+ function resourcePolicyFile(
5755
+ names: ResourceNames,
5756
+ _config: ResolvedBeignetConfig,
5757
+ options: ResourceGenerationOptions,
5758
+ ): string {
5759
+ return `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
5760
+ import { definePolicy, deny } from "@beignet/core/ports";
5761
+
5762
+ export type AuthorizationContext = {
5763
+ actor: ActivityActor;
5764
+ tenant?: ActivityTenant;
5765
+ };
5766
+
5767
+ type ${names.singularPascal}PolicyResource = {
5768
+ id: string;
5769
+ tenantId?: string;
5770
+ };
5771
+
5772
+ function isAuthenticated(ctx: AuthorizationContext) {
5773
+ return ctx.actor.type === "user" && Boolean(ctx.actor.id);
5774
+ }
5775
+
5776
+ function canWrite(ctx: AuthorizationContext) {
5777
+ if (!isAuthenticated(ctx)) return deny("You must be signed in.");
5778
+ ${options.tenant ? `\tif (!ctx.tenant?.id) return deny("A tenant is required.");\n` : ""} return true;
5779
+ }
5780
+
5781
+ export const ${names.singularCamel}Policy = definePolicy({
5782
+ "${names.pluralCamel}.view": (_ctx: AuthorizationContext) => true,
5783
+ "${names.pluralCamel}.create": (ctx: AuthorizationContext) =>
5784
+ canWrite(ctx),
5785
+ "${names.pluralCamel}.update": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
5786
+ const decision = canWrite(ctx);
5787
+ if (decision !== true) return decision;
5788
+ ${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` : ""}
5789
+ return true;
5790
+ },
5791
+ "${names.pluralCamel}.delete": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
5792
+ const decision = canWrite(ctx);
5793
+ if (decision !== true) return decision;
5794
+ ${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` : ""}
5795
+ return true;
5796
+ },
5797
+ "${names.pluralCamel}.manage": (_ctx: AuthorizationContext) =>
5798
+ deny("You are not allowed to manage this ${names.singularKebab}."),
5799
+ });
5800
+ `;
5801
+ }
5802
+
5803
+ function resourceEventsFile(names: ResourceNames): string {
5804
+ return `import { defineEvent } from "@beignet/core/events";
5805
+ import { z } from "zod";
5806
+
5807
+ export const ${names.singularPascal}Created = defineEvent("${names.pluralCamel}.created", {
5808
+ payload: z.object({
5809
+ id: z.string().uuid(),
5810
+ }),
5811
+ });
5812
+
5813
+ export const ${names.singularPascal}Updated = defineEvent("${names.pluralCamel}.updated", {
5814
+ payload: z.object({
5815
+ id: z.string().uuid(),
5816
+ }),
5817
+ });
5818
+
5819
+ export const ${names.singularPascal}Deleted = defineEvent("${names.pluralCamel}.deleted", {
5820
+ payload: z.object({
5821
+ id: z.string().uuid(),
5822
+ }),
5823
+ });
5824
+
5825
+ export const ${names.singularCamel}Events = [
5826
+ ${names.singularPascal}Created,
5827
+ ${names.singularPascal}Updated,
5828
+ ${names.singularPascal}Deleted,
5829
+ ] as const;
5830
+ `;
5831
+ }
5832
+
5833
+ function resourcePolicyTestFile(
5834
+ names: ResourceNames,
5835
+ options: ResourceGenerationOptions,
5836
+ ): string {
5837
+ return `import { describe, expect, it } from "bun:test";
5838
+ import { createPolicyTester } from "@beignet/core/ports/testing";
5839
+ import { ${names.singularCamel}Policy } from "../policy";
5840
+ import type { ${names.singularPascal} } from "../schemas";
5841
+
5842
+ function create${names.singularPascal}(): ${names.singularPascal} {
5843
+ const now = new Date().toISOString();
5844
+ return {
5845
+ id: "00000000-0000-4000-8000-000000000001",
5846
+ ${options.tenant ? `\t\ttenantId: "tenant_test",\n` : ""} name: "Test ${names.singularPascal}",
5847
+ version: 1,
5848
+ createdAt: now,
5849
+ updatedAt: now,
5850
+ };
5851
+ }
5852
+
5853
+ describe("${names.singularCamel}Policy", () => {
5854
+ it("documents generated ${names.pluralKebab} authorization rules", async () => {
5855
+ const tester = createPolicyTester({
5856
+ policies: [${names.singularCamel}Policy],
5857
+ });
5858
+ const ${names.singularCamel} = create${names.singularPascal}();
5859
+ const ctx = {
5860
+ actor: { type: "user" as const, id: "user_test" },
5861
+ ${options.tenant ? `\t\t\ttenant: { id: "tenant_test" },\n` : ""} };
5862
+
5863
+ await expect(
5864
+ tester.assertMatrix([
5865
+ {
5866
+ name: "authenticated actor can create ${names.singularKebab}",
5867
+ ctx,
5868
+ ability: "${names.pluralCamel}.create",
5869
+ expected: "allow",
5870
+ },
5871
+ {
5872
+ name: "authenticated actor can update ${names.singularKebab}",
5873
+ ctx,
5874
+ ability: "${names.pluralCamel}.update",
5875
+ subject: ${names.singularCamel},
5876
+ expected: "allow",
5877
+ },
5878
+ {
5879
+ name: "authenticated actor can delete ${names.singularKebab}",
5880
+ ctx,
5881
+ ability: "${names.pluralCamel}.delete",
5882
+ subject: ${names.singularCamel},
5883
+ expected: "allow",
5884
+ },
5885
+ ]),
5886
+ ).resolves.toBeUndefined();
5887
+ });
5888
+ });
5889
+ `;
5890
+ }
5891
+
4293
5892
  function eventFile(names: EventNames): string {
4294
5893
  return `import { defineEvent } from "@beignet/core/events";
4295
5894
  import { z } from "zod";
@@ -4307,11 +5906,9 @@ export const ${names.eventExportName} = defineEvent("${names.eventName}", {
4307
5906
  }
4308
5907
 
4309
5908
  function jobFile(names: JobNames, config: ResolvedBeignetConfig): string {
4310
- return `import { createJobHandlers, retry } from "@beignet/core/jobs";
5909
+ return `import { retry } from "@beignet/core/jobs";
4311
5910
  import { z } from "zod";
4312
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4313
-
4314
- const jobs = createJobHandlers<AppContext>();
5911
+ import { defineJob } from "${aliasModule(config.paths.jobsBuilder)}";
4315
5912
 
4316
5913
  export const ${names.payloadSchemaName} = z.object({
4317
5914
  \tid: z.string().uuid(),
@@ -4319,7 +5916,7 @@ export const ${names.payloadSchemaName} = z.object({
4319
5916
 
4320
5917
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
4321
5918
 
4322
- export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
5919
+ export const ${names.jobExportName} = defineJob("${names.jobName}", {
4323
5920
  \tpayload: ${names.payloadSchemaName},
4324
5921
  \tretry: retry.exponential({
4325
5922
  \t\tattempts: 3,
@@ -4336,16 +5933,43 @@ export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
4336
5933
  `;
4337
5934
  }
4338
5935
 
5936
+ function taskFile(names: TaskNames, config: ResolvedBeignetConfig): string {
5937
+ return `import { z } from "zod";
5938
+ import { defineTask } from "${aliasModule(config.paths.tasksBuilder)}";
5939
+
5940
+ export const ${names.inputSchemaName} = z.object({
5941
+ \tdryRun: z.boolean().default(true),
5942
+ });
5943
+
5944
+ export type ${names.inputTypeName} = z.infer<typeof ${names.inputSchemaName}>;
5945
+
5946
+ export const ${names.taskExportName} = defineTask("${names.taskName}", {
5947
+ \tinput: ${names.inputSchemaName},
5948
+ \tdescription: "Operational ${names.feature.kebab} ${names.artifact.kebab} task.",
5949
+ \tasync handle({ input, ctx }) {
5950
+ \t\tctx.ports.logger.info("Task handled", {
5951
+ \t\t\ttaskName: "${names.taskName}",
5952
+ \t\t\tdryRun: input.dryRun,
5953
+ \t\t});
5954
+
5955
+ \t\treturn {
5956
+ \t\t\tdryRun: input.dryRun,
5957
+ \t\t};
5958
+ \t},
5959
+ });
5960
+ `;
5961
+ }
5962
+
4339
5963
  function factoryFile(
4340
5964
  names: FactoryNames,
4341
5965
  config: ResolvedBeignetConfig,
4342
5966
  ): string {
4343
5967
  const resource = featureResourceNames(names);
4344
5968
 
4345
- return `import { defineFactory } from "@beignet/core/testing";
5969
+ return `import { createFactory } from "@beignet/core/testing";
4346
5970
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4347
5971
 
4348
- export const ${names.factoryExportName} = defineFactory("${names.factoryName}", {
5972
+ export const ${names.factoryExportName} = createFactory("${names.factoryName}", {
4349
5973
  \tdefaults: ({ sequence }) => ({
4350
5974
  \t\tname: "${resource.singularPascal} " + sequence,
4351
5975
  \t}),
@@ -4375,14 +5999,9 @@ function notificationFile(
4375
5999
  names: NotificationNames,
4376
6000
  config: ResolvedBeignetConfig,
4377
6001
  ): string {
4378
- return `import {
4379
- \tcreateNotificationHandlers,
4380
- \tdefineMailNotificationChannel,
4381
- } from "@beignet/core/notifications";
6002
+ return `import { defineMailNotificationChannel } from "@beignet/core/notifications";
4382
6003
  import { z } from "zod";
4383
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4384
-
4385
- const notifications = createNotificationHandlers<AppContext>();
6004
+ import { defineNotification } from "${aliasModule(config.paths.notificationsBuilder)}";
4386
6005
 
4387
6006
  export const ${names.payloadSchemaName} = z.object({
4388
6007
  \tid: z.string().uuid(),
@@ -4392,7 +6011,7 @@ export const ${names.payloadSchemaName} = z.object({
4392
6011
 
4393
6012
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
4394
6013
 
4395
- export const ${names.notificationExportName} = notifications.defineNotification(
6014
+ export const ${names.notificationExportName} = defineNotification(
4396
6015
  \t"${names.notificationName}",
4397
6016
  \t{
4398
6017
  \t\tpayload: ${names.payloadSchemaName},
@@ -4414,13 +6033,10 @@ function listenerFile(
4414
6033
  ): string {
4415
6034
  const filePath = listenerFilePath(names, config);
4416
6035
 
4417
- return `import { createEventHandlers } from "@beignet/core/events";
4418
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6036
+ return `import { defineListener } from "${aliasModule(config.paths.listenersBuilder)}";
4419
6037
  import { ${names.event.eventExportName} } from "${relativeModule(filePath, eventFilePath(names.event, config))}";
4420
6038
 
4421
- const events = createEventHandlers<AppContext>();
4422
-
4423
- export const ${names.listenerExportName} = events.defineListener(${names.event.eventExportName}, {
6039
+ export const ${names.listenerExportName} = defineListener(${names.event.eventExportName}, {
4424
6040
  \tname: "${names.listenerName}",
4425
6041
  \tasync handle({ payload, ctx }) {
4426
6042
  \t\tctx.ports.logger.info("Listener handled", {
@@ -4436,11 +6052,8 @@ function scheduleFile(
4436
6052
  names: ScheduleNames,
4437
6053
  config: ResolvedBeignetConfig,
4438
6054
  ): 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>();
6055
+ return `import { z } from "zod";
6056
+ import { defineSchedule } from "${aliasModule(config.paths.schedulesBuilder)}";
4444
6057
 
4445
6058
  export const ${names.payloadSchemaName} = z.object({
4446
6059
  \tdate: z.string(),
@@ -4448,7 +6061,7 @@ export const ${names.payloadSchemaName} = z.object({
4448
6061
 
4449
6062
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
4450
6063
 
4451
- export const ${names.scheduleExportName} = schedules.defineSchedule(
6064
+ export const ${names.scheduleExportName} = defineSchedule(
4452
6065
  \t"${names.scheduleName}",
4453
6066
  \t{
4454
6067
  \t\tcron: "${names.cron}",
@@ -4526,111 +6139,119 @@ export const ${names.uploadExportName} = defineUpload<
4526
6139
  `;
4527
6140
  }
4528
6141
 
6142
+ function featureUiComponentFile(
6143
+ names: FeatureUiNames,
6144
+ config: ResolvedBeignetConfig,
6145
+ ): string {
6146
+ return `"use client";
6147
+
6148
+ import { contractErrorMessage } from "@beignet/core/client";
6149
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6150
+ import { useState } from "react";
6151
+ import { rq } from "${aliasModule(clientIndexPath(config))}";
6152
+ import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(
6153
+ resourceContractFilePath(names, config),
6154
+ )}";
6155
+
6156
+ export function ${names.componentExportName}() {
6157
+ \tconst [name, setName] = useState("");
6158
+ \tconst queryClient = useQueryClient();
6159
+ \tconst ${names.pluralCamel}Query = useQuery(
6160
+ \t\trq(list${names.pluralPascal}).queryOptions({ query: {} }),
6161
+ \t);
6162
+ \tconst create${names.singularPascal}Mutation = useMutation(
6163
+ \t\trq(create${names.singularPascal}).mutationOptions({
6164
+ \t\t\tonSuccess: async () => {
6165
+ \t\t\t\tsetName("");
6166
+ \t\t\t\tawait rq(list${names.pluralPascal}).invalidate(queryClient);
6167
+ \t\t\t},
6168
+ \t\t}),
6169
+ \t);
6170
+
6171
+ \treturn (
6172
+ \t\t<section className="${names.pluralKebab}-panel">
6173
+ \t\t\t<form
6174
+ \t\t\t\tclassName="${names.pluralKebab}-composer"
6175
+ \t\t\t\tonSubmit={(event) => {
6176
+ \t\t\t\t\tevent.preventDefault();
6177
+ \t\t\t\t\tconst trimmedName = name.trim();
6178
+ \t\t\t\t\tif (!trimmedName) return;
6179
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.reset();
6180
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.mutate({
6181
+ \t\t\t\t\t\tbody: { name: trimmedName },
6182
+ \t\t\t\t\t});
6183
+ \t\t\t\t}}
6184
+ \t\t\t>
6185
+ \t\t\t\t<label htmlFor="${names.singularKebab}-name">New ${names.singularKebab}</label>
6186
+ \t\t\t\t<div className="${names.pluralKebab}-composer-row">
6187
+ \t\t\t\t\t<input
6188
+ \t\t\t\t\t\tid="${names.singularKebab}-name"
6189
+ \t\t\t\t\t\tvalue={name}
6190
+ \t\t\t\t\t\tonChange={(event) => setName(event.currentTarget.value)}
6191
+ \t\t\t\t\t\tplaceholder="Name"
6192
+ \t\t\t\t\t/>
6193
+ \t\t\t\t\t<button
6194
+ \t\t\t\t\t\ttype="submit"
6195
+ \t\t\t\t\t\tdisabled={!name.trim() || create${names.singularPascal}Mutation.isPending}
6196
+ \t\t\t\t\t>
6197
+ \t\t\t\t\t\t{create${names.singularPascal}Mutation.isPending ? "Creating" : "Create"}
6198
+ \t\t\t\t\t</button>
6199
+ \t\t\t\t</div>
6200
+ \t\t\t\t{create${names.singularPascal}Mutation.isError ? (
6201
+ \t\t\t\t\t<p role="alert">
6202
+ \t\t\t\t\t\t{contractErrorMessage(
6203
+ \t\t\t\t\t\t\tcreate${names.singularPascal}Mutation.error,
6204
+ \t\t\t\t\t\t\t"Could not create ${names.singularKebab}.",
6205
+ \t\t\t\t\t\t)}
6206
+ \t\t\t\t\t</p>
6207
+ \t\t\t\t) : null}
6208
+ \t\t\t</form>
6209
+
6210
+ \t\t\t<div className="${names.pluralKebab}-list">
6211
+ \t\t\t\t<div className="${names.pluralKebab}-list-heading">
6212
+ \t\t\t\t\t<h2>${names.pluralPascal}</h2>
6213
+ \t\t\t\t\t<span>{${names.pluralCamel}Query.data?.items.length ?? 0} shown</span>
6214
+ \t\t\t\t</div>
6215
+ \t\t\t\t{${names.pluralCamel}Query.isLoading ? <p>Loading...</p> : null}
6216
+ \t\t\t\t{${names.pluralCamel}Query.isError ? (
6217
+ \t\t\t\t\t<p role="alert">Could not load ${names.pluralKebab}.</p>
6218
+ \t\t\t\t) : null}
6219
+ \t\t\t\t{${names.pluralCamel}Query.data?.items.length === 0 ? (
6220
+ \t\t\t\t\t<p>No ${names.pluralKebab} yet.</p>
6221
+ \t\t\t\t) : null}
6222
+ \t\t\t\t<ul>
6223
+ \t\t\t\t\t{${names.pluralCamel}Query.data?.items.map((${names.singularCamel}) => (
6224
+ \t\t\t\t\t\t<li key={${names.singularCamel}.id}>
6225
+ \t\t\t\t\t\t\t<strong>{${names.singularCamel}.name}</strong>
6226
+ \t\t\t\t\t\t\t<small>{new Date(${names.singularCamel}.createdAt).toLocaleDateString()}</small>
6227
+ \t\t\t\t\t\t</li>
6228
+ \t\t\t\t\t))}
6229
+ \t\t\t\t</ul>
6230
+ \t\t\t</div>
6231
+ \t\t</section>
6232
+ \t);
6233
+ }
6234
+ `;
6235
+ }
6236
+
4529
6237
  function scheduleRouteFile(
4530
6238
  names: ScheduleNames,
4531
6239
  config: ResolvedBeignetConfig,
4532
6240
  ): string {
4533
- return `import { createInlineScheduleRunner } from "@beignet/core/schedules";
4534
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6241
+ return `import { createScheduleRoute } from "@beignet/next";
4535
6242
  import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
4536
6243
  import { env } from "@/lib/env";
4537
6244
  import { server } from "${aliasModule(config.paths.server)}";
4538
6245
 
4539
- async function run${names.artifact.pascal}(request: Request) {
4540
- \tconst cronSecret = env.CRON_SECRET;
4541
-
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});
6246
+ export const runtime = "nodejs";
4620
6247
 
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};
6248
+ export const { GET, POST } = createScheduleRoute({
6249
+ \tserver,
6250
+ \tschedules: [${names.scheduleExportName}],
6251
+ \tschedule: ${names.scheduleExportName}.name,
6252
+ \tsecret: env.CRON_SECRET,
6253
+ \tsource: "cron-route",
6254
+ });
4634
6255
  `;
4635
6256
  }
4636
6257
 
@@ -4648,84 +6269,161 @@ function useCaseIndexExport(names: UseCaseNames): string {
4648
6269
  function routeGroupFile(
4649
6270
  names: ResourceNames,
4650
6271
  config: ResolvedBeignetConfig,
6272
+ mode: ResourceGenerationMode,
6273
+ _options: ResourceGenerationOptions,
4651
6274
  ): 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))}";
6275
+ const routeFilePath = path.join(
6276
+ config.paths.features,
6277
+ names.pluralKebab,
6278
+ "routes.ts",
6279
+ );
6280
+ const contractsPath = resourceContractFilePath(names, config);
6281
+ const useCasesPath = resourceUseCaseIndexPath(names, config);
6282
+
6283
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
6284
+ import { defineRouteGroup } from "@beignet/next";
6285
+ import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
6286
+ import {
6287
+ create${names.singularPascal},
6288
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
6289
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${relativeModule(routeFilePath, contractsPath)}";
4655
6290
  import {
4656
6291
  create${names.singularPascal}UseCase,
4657
- list${names.pluralPascal}UseCase,
4658
- } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceUseCaseIndexPath(names, config))}";
6292
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
6293
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${relativeModule(routeFilePath, useCasesPath)}";
4659
6294
 
4660
6295
  export const ${names.singularCamel}Routes = defineRouteGroup<AppContext>({
4661
6296
  name: "${names.pluralKebab}",
4662
6297
  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
- ],
6298
+ { contract: list${names.pluralPascal}, useCase: list${names.pluralPascal}UseCase },
6299
+ { contract: create${names.singularPascal}, useCase: create${names.singularPascal}UseCase },
6300
+ ${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
6301
  });
4679
6302
  `;
4680
6303
  }
4681
6304
 
4682
- function testFile(names: ResourceNames, config: ResolvedBeignetConfig): string {
6305
+ function testFile(
6306
+ names: ResourceNames,
6307
+ config: ResolvedBeignetConfig,
6308
+ mode: ResourceGenerationMode,
6309
+ options: ResourceGenerationOptions,
6310
+ ): string {
4683
6311
  const repositoryPath = path.join(
4684
6312
  path.dirname(config.paths.infrastructurePorts),
4685
6313
  names.pluralKebab,
4686
6314
  `in-memory-${names.singularKebab}-repository.ts`,
4687
6315
  );
6316
+ const serverContextPath = path.join(
6317
+ path.dirname(config.paths.server),
6318
+ "context.ts",
6319
+ );
6320
+ const requestTarget = options.tenant ? "requester" : "app";
4688
6321
 
4689
6322
  return `import { describe, expect, it } from "bun:test";
4690
6323
  import { createUseCaseTester } from "@beignet/core/application";
4691
- import { defineRoutes } from "@beignet/web";
4692
- import { createTestApp } from "@beignet/web/testing";
6324
+ ${mode === "resource" ? `import { isAppError } from "@beignet/core/errors";\n` : ""}import { defineRoutes } from "@beignet/web";
6325
+ import { createTestApp${options.tenant ? ", createTestRequester" : ""} } from "@beignet/web/testing";
4693
6326
  import { createInMemoryDevtools } from "@beignet/devtools";
4694
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
6327
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
6328
+ import { ${options.auth ? "createTestUserActor" : "createTestAnonymousActor"}${options.tenant ? ", createTestTenant" : ""} } from "@beignet/core/ports/testing";
4695
6329
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4696
6330
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
4697
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
6331
+ import { appContext } from "${aliasModule(serverContextPath)}";
6332
+ import {
6333
+ create${names.singularPascal},
6334
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
6335
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${aliasModule(resourceContractFilePath(names, config))}";
4698
6336
  import { createInMemory${names.singularPascal}Repository } from "${aliasModule(repositoryPath)}";
4699
6337
  import { ${names.singularCamel}Routes } from "${aliasModule(path.join(resourceFeatureDir(names, config), "routes.ts"))}";
4700
6338
  import {
4701
6339
  create${names.singularPascal}UseCase,
4702
- list${names.pluralPascal}UseCase,
4703
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
6340
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
6341
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
4704
6342
 
4705
6343
  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"]),
6344
+ it("${mode === "resource" ? `exercises ${names.pluralCamel} CRUD` : `creates and lists ${names.pluralCamel}`}", async () => {
6345
+ const seed${names.pluralPascal} = [
6346
+ {
6347
+ id: "00000000-0000-4000-8000-000000000101",
6348
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Alpha ${names.singularPascal}",
6349
+ version: 1,
6350
+ createdAt: "2024-01-01T00:00:00.000Z",
6351
+ updatedAt: "2024-01-01T00:00:00.000Z",
6352
+ },
6353
+ {
6354
+ id: "00000000-0000-4000-8000-000000000102",
6355
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Beta ${names.singularPascal}",
6356
+ version: 1,
6357
+ createdAt: "2024-01-02T00:00:00.000Z",
6358
+ updatedAt: "2024-01-02T00:00:00.000Z",
6359
+ },
6360
+ {
6361
+ id: "00000000-0000-4000-8000-000000000103",
6362
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Gamma ${names.singularPascal}",
6363
+ version: 1,
6364
+ createdAt: "2024-01-03T00:00:00.000Z",
6365
+ updatedAt: "2024-01-03T00:00:00.000Z",
6366
+ },
6367
+ ${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` : ""} ];
6368
+ const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository(seed${names.pluralPascal});
6369
+ const testFixture = createTestPorts<AppContext["ports"]>({
6370
+ base: appPorts,
6371
+ overrides: {
4713
6372
  ${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,
6373
+ ${
6374
+ options.auth
6375
+ ? `\t\t\t\tauth: {
6376
+ getSession: async () => ({
6377
+ user: { id: "user_test", name: "Test User" },
6378
+ }),
6379
+ },
6380
+ gate: appPorts.gate,\n`
6381
+ : `\t\t\t\tauth: {
6382
+ getSession: async () => null,
6383
+ },\n`
6384
+ } devtools: createInMemoryDevtools(),
6385
+ },
6386
+ transaction: {
6387
+ ports: (ports) => ({
6388
+ ...ports,
6389
+ ${names.pluralCamel},
6390
+ }),
6391
+ },
6392
+ });
6393
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
6394
+ ports: testFixture.ports,
6395
+ actor: ${options.auth ? `createTestUserActor("user_test")` : "createTestAnonymousActor()"},
6396
+ tenant: ${options.tenant ? `createTestTenant("tenant_test")` : "null"},
4725
6397
  });
4726
6398
  const tester = createUseCaseTester<AppContext>(createTestContext);
4727
6399
 
4728
6400
  const ctx = await tester.ctx();
6401
+ const defaultPage = await tester.run(list${names.pluralPascal}UseCase, {}, { ctx });
6402
+ const filteredPage = await tester.run(
6403
+ list${names.pluralPascal}UseCase,
6404
+ { name: "alp" },
6405
+ { ctx },
6406
+ );
6407
+ const firstPage = await tester.run(
6408
+ list${names.pluralPascal}UseCase,
6409
+ { limit: 2 },
6410
+ { ctx },
6411
+ );
6412
+ const secondPage = await tester.run(
6413
+ list${names.pluralPascal}UseCase,
6414
+ { limit: 2, cursor: firstPage.page.nextCursor },
6415
+ { ctx },
6416
+ );
6417
+ const nameAscendingPage = await tester.run(
6418
+ list${names.pluralPascal}UseCase,
6419
+ { sortBy: "name", sortDirection: "asc" },
6420
+ { ctx },
6421
+ );
6422
+ const nameDescendingPage = await tester.run(
6423
+ list${names.pluralPascal}UseCase,
6424
+ { sortBy: "name", sortDirection: "desc" },
6425
+ { ctx },
6426
+ );
4729
6427
  const created = await tester.run(
4730
6428
  create${names.singularPascal}UseCase,
4731
6429
  { name: "First ${names.singularPascal}" },
@@ -4733,35 +6431,113 @@ describe("${names.pluralCamel} resource", () => {
4733
6431
  );
4734
6432
  const result = await tester.run(
4735
6433
  list${names.pluralPascal}UseCase,
4736
- { limit: 20, offset: 0 },
6434
+ { name: "First" },
4737
6435
  { ctx },
4738
6436
  );
4739
6437
 
6438
+ expect(defaultPage.page).toMatchObject({
6439
+ kind: "cursor",
6440
+ limit: 20,
6441
+ cursor: null,
6442
+ nextCursor: null,
6443
+ hasMore: false,
6444
+ });
6445
+ expect(defaultPage.items.map((item) => item.name)).toEqual([
6446
+ "Gamma ${names.singularPascal}",
6447
+ "Beta ${names.singularPascal}",
6448
+ "Alpha ${names.singularPascal}",
6449
+ ]);
6450
+ expect(filteredPage.items.map((item) => item.name)).toEqual([
6451
+ "Alpha ${names.singularPascal}",
6452
+ ]);
6453
+ expect(firstPage.items.map((item) => item.name)).toEqual([
6454
+ "Gamma ${names.singularPascal}",
6455
+ "Beta ${names.singularPascal}",
6456
+ ]);
6457
+ expect(firstPage.page.nextCursor).toEqual(expect.any(String));
6458
+ expect(secondPage.items.map((item) => item.name)).toEqual([
6459
+ "Alpha ${names.singularPascal}",
6460
+ ]);
6461
+ expect(secondPage.page.nextCursor).toBeNull();
6462
+ expect(nameAscendingPage.items.map((item) => item.name)).toEqual([
6463
+ "Alpha ${names.singularPascal}",
6464
+ "Beta ${names.singularPascal}",
6465
+ "Gamma ${names.singularPascal}",
6466
+ ]);
6467
+ expect(nameDescendingPage.items.map((item) => item.name)).toEqual([
6468
+ "Gamma ${names.singularPascal}",
6469
+ "Beta ${names.singularPascal}",
6470
+ "Alpha ${names.singularPascal}",
6471
+ ]);
4740
6472
  expect(created.name).toBe("First ${names.singularPascal}");
4741
- expect(result.page.total).toBe(1);
4742
6473
  expect(result.items).toEqual([created]);
6474
+ ${
6475
+ mode === "resource"
6476
+ ? `
6477
+ const fetched = await tester.run(
6478
+ get${names.singularPascal}UseCase,
6479
+ { id: created.id },
6480
+ { ctx },
6481
+ );
6482
+ const updated = await tester.run(
6483
+ update${names.singularPascal}UseCase,
6484
+ { id: created.id, name: "Updated ${names.singularPascal}", version: fetched.version },
6485
+ { ctx },
6486
+ );
6487
+ try {
6488
+ await tester.run(
6489
+ update${names.singularPascal}UseCase,
6490
+ { id: created.id, name: "Stale ${names.singularPascal}", version: fetched.version },
6491
+ { ctx },
6492
+ );
6493
+ throw new Error("Expected ${names.singularPascal}Conflict");
6494
+ } catch (error) {
6495
+ expect(isAppError(error)).toBe(true);
6496
+ if (isAppError(error)) {
6497
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_CONFLICT");
6498
+ }
6499
+ }
6500
+ await tester.run(delete${names.singularPascal}UseCase, { id: created.id }, { ctx });
6501
+ const afterDelete = await tester.run(
6502
+ list${names.pluralPascal}UseCase,
6503
+ { name: "Updated" },
6504
+ { ctx },
6505
+ );
6506
+
6507
+ expect(fetched).toEqual(created);
6508
+ expect(updated.name).toBe("Updated ${names.singularPascal}");
6509
+ expect(updated.version).toBe(fetched.version + 1);
6510
+ expect(afterDelete.items).toEqual([]);
6511
+
6512
+ try {
6513
+ await tester.run(get${names.singularPascal}UseCase, { id: created.id }, { ctx });
6514
+ throw new Error("Expected ${names.singularPascal}NotFound");
6515
+ } catch (error) {
6516
+ expect(isAppError(error)).toBe(true);
6517
+ if (isAppError(error)) {
6518
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_NOT_FOUND");
6519
+ }
6520
+ }
6521
+ `
6522
+ : ""
6523
+ }
4743
6524
 
4744
- const app = await createTestApp<AppContext, AppContext["ports"]>({
4745
- ports: testPorts,
6525
+ const app = await createTestApp({
6526
+ ports: testFixture.ports,
4746
6527
  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
- }),
6528
+ context: appContext,
4752
6529
  });
4753
-
4754
- const createdViaRoute = await app.request(create${names.singularPascal}, {
6530
+ ${options.tenant ? `\t\tconst requester = createTestRequester(app, {\n\t\t\theaders: { "x-tenant-id": "tenant_test" },\n\t\t});\n` : ""}
6531
+ const createdViaRoute = await ${requestTarget}.request(create${names.singularPascal}, {
4755
6532
  body: { name: "Second ${names.singularPascal}" },
4756
6533
  });
4757
- const listedViaRoute = await app.request(list${names.pluralPascal}, {
4758
- query: { limit: 20, offset: 0 },
6534
+ ${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}, {
6535
+ query: { name: "${mode === "resource" ? "Updated via route" : "Second"}" },
4759
6536
  });
4760
- await app.stop();
6537
+ ${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
6538
 
4762
6539
  expect(createdViaRoute.name).toBe("Second ${names.singularPascal}");
4763
- expect(listedViaRoute.page.total).toBe(2);
4764
- expect(listedViaRoute.items).toContainEqual(createdViaRoute);
6540
+ ${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
6541
  });
4766
6542
  });
4767
6543
  `;