@beignet/cli 0.0.1 → 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 +231 -0
  2. package/README.md +497 -90
  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 +39 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +26 -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 +12 -0
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +19 -24
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +37 -0
  28. package/dist/db.d.ts.map +1 -0
  29. package/dist/db.js +146 -0
  30. package/dist/db.js.map +1 -0
  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 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +710 -400
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +45 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +2191 -100
  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 +22 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +370 -38
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +109 -1
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +2225 -333
  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 +12 -8
  68. package/dist/templates.d.ts.map +1 -1
  69. package/dist/templates.js +2144 -385
  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 +47 -0
  80. package/src/create-prompts.ts +182 -0
  81. package/src/create.ts +32 -28
  82. package/src/db.ts +222 -0
  83. package/src/github-annotations.ts +37 -0
  84. package/src/index.ts +1119 -535
  85. package/src/inspect.ts +3372 -134
  86. package/src/lib.ts +45 -0
  87. package/src/lint.ts +533 -45
  88. package/src/make.ts +3010 -397
  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 +2282 -462
  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,6 +16,26 @@ 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;
23
+ };
24
+
25
+ export type { MakeFeatureAddon } from "./choices.js";
26
+ export { makeFeatureAddonChoices } from "./choices.js";
27
+
28
+ type NormalizedMakeFeatureAddon =
29
+ | "policy"
30
+ | "task"
31
+ | "event"
32
+ | "job"
33
+ | "notification"
34
+ | "ui"
35
+ | "upload";
36
+
37
+ type MakeFeatureOptions = MakeResourceOptions & {
38
+ with?: readonly MakeFeatureAddon[];
18
39
  };
19
40
 
20
41
  type MakeContractOptions = {
@@ -33,6 +54,14 @@ type MakeUseCaseOptions = {
33
54
  config?: BeignetConfig;
34
55
  };
35
56
 
57
+ type MakeTaskOptions = {
58
+ name: string;
59
+ cwd?: string;
60
+ force?: boolean;
61
+ dryRun?: boolean;
62
+ config?: BeignetConfig;
63
+ };
64
+
36
65
  type MakeTestOptions = {
37
66
  name: string;
38
67
  cwd?: string;
@@ -81,6 +110,30 @@ type MakeJobOptions = {
81
110
  config?: BeignetConfig;
82
111
  };
83
112
 
113
+ type MakeFactoryOptions = {
114
+ name: string;
115
+ cwd?: string;
116
+ force?: boolean;
117
+ dryRun?: boolean;
118
+ config?: BeignetConfig;
119
+ };
120
+
121
+ type MakeSeedOptions = {
122
+ name: string;
123
+ cwd?: string;
124
+ force?: boolean;
125
+ dryRun?: boolean;
126
+ config?: BeignetConfig;
127
+ };
128
+
129
+ type MakeNotificationOptions = {
130
+ name: string;
131
+ cwd?: string;
132
+ force?: boolean;
133
+ dryRun?: boolean;
134
+ config?: BeignetConfig;
135
+ };
136
+
84
137
  type MakeListenerOptions = {
85
138
  name: string;
86
139
  event: string;
@@ -101,7 +154,16 @@ type MakeScheduleOptions = {
101
154
  route?: boolean;
102
155
  };
103
156
 
157
+ type MakeUploadOptions = {
158
+ name: string;
159
+ cwd?: string;
160
+ force?: boolean;
161
+ dryRun?: boolean;
162
+ config?: BeignetConfig;
163
+ };
164
+
104
165
  type MakeResult = {
166
+ schemaVersion: 1;
105
167
  name: string;
106
168
  targetDir: string;
107
169
  dryRun: boolean;
@@ -111,17 +173,74 @@ type MakeResult = {
111
173
  skippedFiles: string[];
112
174
  };
113
175
 
176
+ /**
177
+ * Result returned by `beignet make resource`.
178
+ */
114
179
  export type MakeResourceResult = MakeResult;
180
+ /**
181
+ * Result returned by `beignet make feature`.
182
+ */
183
+ export type MakeFeatureResult = MakeResult;
184
+ /**
185
+ * Result returned by `beignet make contract`.
186
+ */
115
187
  export type MakeContractResult = MakeResult;
188
+ /**
189
+ * Result returned by `beignet make use-case`.
190
+ */
116
191
  export type MakeUseCaseResult = MakeResult;
192
+ /**
193
+ * Result returned by `beignet make task`.
194
+ */
195
+ export type MakeTaskResult = MakeResult;
196
+ /**
197
+ * Result returned by `beignet make test`.
198
+ */
117
199
  export type MakeTestResult = MakeResult;
200
+ /**
201
+ * Result returned by `beignet make port`.
202
+ */
118
203
  export type MakePortResult = MakeResult;
204
+ /**
205
+ * Result returned by `beignet make adapter`.
206
+ */
119
207
  export type MakeAdapterResult = MakeResult;
208
+ /**
209
+ * Result returned by `beignet make policy`.
210
+ */
120
211
  export type MakePolicyResult = MakeResult;
212
+ /**
213
+ * Result returned by `beignet make event`.
214
+ */
121
215
  export type MakeEventResult = MakeResult;
216
+ /**
217
+ * Result returned by `beignet make job`.
218
+ */
122
219
  export type MakeJobResult = MakeResult;
220
+ /**
221
+ * Result returned by `beignet make factory`.
222
+ */
223
+ export type MakeFactoryResult = MakeResult;
224
+ /**
225
+ * Result returned by `beignet make seed`.
226
+ */
227
+ export type MakeSeedResult = MakeResult;
228
+ /**
229
+ * Result returned by `beignet make notification`.
230
+ */
231
+ export type MakeNotificationResult = MakeResult;
232
+ /**
233
+ * Result returned by `beignet make listener`.
234
+ */
123
235
  export type MakeListenerResult = MakeResult;
236
+ /**
237
+ * Result returned by `beignet make schedule`.
238
+ */
124
239
  export type MakeScheduleResult = MakeResult;
240
+ /**
241
+ * Result returned by `beignet make upload`.
242
+ */
243
+ export type MakeUploadResult = MakeResult;
125
244
 
126
245
  type ResourceNames = {
127
246
  input: string;
@@ -133,6 +252,15 @@ type ResourceNames = {
133
252
  pluralPascal: string;
134
253
  };
135
254
 
255
+ type ResourceGenerationMode = "feature" | "resource";
256
+
257
+ type ResourceGenerationOptions = {
258
+ auth: boolean;
259
+ tenant: boolean;
260
+ events: boolean;
261
+ softDelete: boolean;
262
+ };
263
+
136
264
  type GeneratedFile = {
137
265
  path: string;
138
266
  content: string;
@@ -190,6 +318,30 @@ type JobNames = FeatureArtifactNames & {
190
318
  payloadTypeName: string;
191
319
  };
192
320
 
321
+ type TaskNames = FeatureArtifactNames & {
322
+ taskName: string;
323
+ taskExportName: string;
324
+ inputSchemaName: string;
325
+ inputTypeName: string;
326
+ };
327
+
328
+ type FactoryNames = FeatureArtifactNames & {
329
+ factoryName: string;
330
+ factoryExportName: string;
331
+ };
332
+
333
+ type SeedNames = FeatureArtifactNames & {
334
+ seedName: string;
335
+ seedExportName: string;
336
+ };
337
+
338
+ type NotificationNames = FeatureArtifactNames & {
339
+ notificationName: string;
340
+ notificationExportName: string;
341
+ payloadSchemaName: string;
342
+ payloadTypeName: string;
343
+ };
344
+
193
345
  type ListenerNames = FeatureArtifactNames & {
194
346
  listenerName: string;
195
347
  listenerExportName: string;
@@ -205,8 +357,58 @@ type ScheduleNames = FeatureArtifactNames & {
205
357
  timezone?: string;
206
358
  };
207
359
 
360
+ type UploadNames = FeatureArtifactNames & {
361
+ uploadName: string;
362
+ uploadExportName: string;
363
+ metadataSchemaName: string;
364
+ metadataTypeName: string;
365
+ };
366
+
367
+ type FeatureUiNames = ResourceNames & {
368
+ componentExportName: string;
369
+ componentFileName: string;
370
+ };
371
+
208
372
  export async function makeResource(
209
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,
210
412
  ): Promise<MakeResourceResult> {
211
413
  const targetDir = path.resolve(options.cwd ?? process.cwd());
212
414
  const names = resourceNames(options.name);
@@ -215,9 +417,24 @@ export async function makeResource(
215
417
  : await loadBeignetConfig(targetDir);
216
418
 
217
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
+ }
218
428
 
219
429
  const persistence = await detectResourcePersistence(targetDir, config);
220
- 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
+ );
221
438
  const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
222
439
  force: Boolean(options.force),
223
440
  });
@@ -228,6 +445,8 @@ export async function makeResource(
228
445
  persistence,
229
446
  {
230
447
  dryRun: true,
448
+ mode,
449
+ generationOptions,
231
450
  },
232
451
  );
233
452
  const createdFiles = plannedFiles
@@ -251,11 +470,14 @@ export async function makeResource(
251
470
  ...(await updateResourceWiring(targetDir, names, config, {
252
471
  persistence,
253
472
  dryRun: false,
473
+ mode,
474
+ generationOptions,
254
475
  })),
255
476
  );
256
477
  }
257
478
 
258
479
  return {
480
+ schemaVersion: 1,
259
481
  name: names.pluralKebab,
260
482
  targetDir,
261
483
  dryRun: Boolean(options.dryRun),
@@ -266,6 +488,113 @@ export async function makeResource(
266
488
  };
267
489
  }
268
490
 
491
+ function resourceGenerationOptions(
492
+ options: MakeResourceOptions,
493
+ mode: ResourceGenerationMode,
494
+ ): ResourceGenerationOptions {
495
+ if (mode !== "resource") {
496
+ return { auth: false, tenant: false, events: false, softDelete: false };
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
+ };
505
+ }
506
+
507
+ async function makeFeatureSlice(
508
+ options: MakeFeatureOptions,
509
+ addons: readonly NormalizedMakeFeatureAddon[],
510
+ ): Promise<MakeFeatureResult> {
511
+ let result = await makeFeatureResource(options);
512
+
513
+ for (const addon of addons) {
514
+ result = mergeMakeResults(
515
+ result,
516
+ await makeFeatureAddon(addon, result.name, options),
517
+ );
518
+ }
519
+
520
+ return result;
521
+ }
522
+
523
+ async function makeFeatureAddon(
524
+ addon: NormalizedMakeFeatureAddon,
525
+ feature: string,
526
+ options: MakeFeatureOptions,
527
+ ): Promise<MakeResult> {
528
+ const shared = {
529
+ cwd: options.cwd,
530
+ force: options.force,
531
+ dryRun: options.dryRun,
532
+ config: options.config,
533
+ };
534
+
535
+ if (addon === "policy") {
536
+ return makePolicy({ ...shared, name: feature });
537
+ }
538
+ if (addon === "task") {
539
+ return makeTask({ ...shared, name: `${feature}/backfill` });
540
+ }
541
+ if (addon === "event") {
542
+ return makeEvent({ ...shared, name: `${feature}/created` });
543
+ }
544
+ if (addon === "job") {
545
+ return makeJob({ ...shared, name: `${feature}/process` });
546
+ }
547
+ if (addon === "notification") {
548
+ return makeNotification({ ...shared, name: `${feature}/created` });
549
+ }
550
+ if (addon === "ui") {
551
+ return makeFeatureUi({ ...shared, name: feature });
552
+ }
553
+
554
+ return makeUpload({ ...shared, name: `${feature}/attachment` });
555
+ }
556
+
557
+ function normalizeMakeFeatureAddons(
558
+ addons: readonly MakeFeatureAddon[],
559
+ ): NormalizedMakeFeatureAddon[] {
560
+ const normalized = new Set<NormalizedMakeFeatureAddon>();
561
+
562
+ for (const addon of addons) {
563
+ if (addon === "events") normalized.add("event");
564
+ else if (addon === "tasks") normalized.add("task");
565
+ else if (addon === "jobs") normalized.add("job");
566
+ else if (addon === "notifications") normalized.add("notification");
567
+ else if (addon === "uploads") normalized.add("upload");
568
+ else normalized.add(addon);
569
+ }
570
+
571
+ return featureAddonOrder.filter((addon) => normalized.has(addon));
572
+ }
573
+
574
+ const featureAddonOrder: readonly NormalizedMakeFeatureAddon[] = [
575
+ "policy",
576
+ "task",
577
+ "event",
578
+ "job",
579
+ "notification",
580
+ "ui",
581
+ "upload",
582
+ ];
583
+
584
+ function mergeMakeResults(left: MakeResult, right: MakeResult): MakeResult {
585
+ return {
586
+ ...left,
587
+ files: uniqueStrings([...left.files, ...right.files]),
588
+ createdFiles: uniqueStrings([...left.createdFiles, ...right.createdFiles]),
589
+ updatedFiles: uniqueStrings([...left.updatedFiles, ...right.updatedFiles]),
590
+ skippedFiles: uniqueStrings([...left.skippedFiles, ...right.skippedFiles]),
591
+ };
592
+ }
593
+
594
+ function uniqueStrings(values: readonly string[]): string[] {
595
+ return [...new Set(values)];
596
+ }
597
+
269
598
  export async function makeContract(
270
599
  options: MakeContractOptions,
271
600
  ): Promise<MakeContractResult> {
@@ -290,6 +619,7 @@ export async function makeContract(
290
619
  }
291
620
 
292
621
  return {
622
+ schemaVersion: 1,
293
623
  name: names.pluralKebab,
294
624
  targetDir,
295
625
  dryRun: Boolean(options.dryRun),
@@ -335,6 +665,7 @@ export async function makeUseCase(
335
665
  if (indexResult === "skipped") skippedFiles.push(indexFile.path);
336
666
 
337
667
  return {
668
+ schemaVersion: 1,
338
669
  name: `${names.feature.kebab}/${names.action.kebab}`,
339
670
  targetDir,
340
671
  dryRun: Boolean(options.dryRun),
@@ -371,11 +702,16 @@ export async function makeTest(
371
702
  if (result === "skipped") skippedFiles.push(file.path);
372
703
  }
373
704
 
374
- if (await updatePackageJson(targetDir, { dryRun: Boolean(options.dryRun) })) {
705
+ if (
706
+ await updatePackageJson(targetDir, {
707
+ dryRun: Boolean(options.dryRun),
708
+ })
709
+ ) {
375
710
  updatedFiles.push("package.json");
376
711
  }
377
712
 
378
713
  return {
714
+ schemaVersion: 1,
379
715
  name: `${names.feature.kebab}/${names.action.kebab}`,
380
716
  targetDir,
381
717
  dryRun: Boolean(options.dryRun),
@@ -438,6 +774,7 @@ export async function makePort(
438
774
  }
439
775
 
440
776
  return {
777
+ schemaVersion: 1,
441
778
  name: names.kebab,
442
779
  targetDir,
443
780
  dryRun: Boolean(options.dryRun),
@@ -483,6 +820,7 @@ export async function makeAdapter(
483
820
  }
484
821
 
485
822
  return {
823
+ schemaVersion: 1,
486
824
  name: names.kebab,
487
825
  targetDir,
488
826
  dryRun: Boolean(options.dryRun),
@@ -520,6 +858,7 @@ export async function makePolicy(
520
858
  }
521
859
 
522
860
  return {
861
+ schemaVersion: 1,
523
862
  name: names.kebab,
524
863
  targetDir,
525
864
  dryRun: Boolean(options.dryRun),
@@ -570,7 +909,10 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
570
909
  force: Boolean(options.force),
571
910
  dryRun: Boolean(options.dryRun),
572
911
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
573
- files: jobFiles(names, config),
912
+ files: [
913
+ ...(await missingCapabilityBuilderFiles(targetDir, "jobs", config)),
914
+ ...jobFiles(names, config),
915
+ ],
574
916
  index: featureArtifactIndexFile("job", names, config),
575
917
  dependencies: {
576
918
  "@beignet/core": beignetDependencyVersion,
@@ -578,6 +920,131 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
578
920
  });
579
921
  }
580
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
+
966
+ export async function makeFactory(
967
+ options: MakeFactoryOptions,
968
+ ): Promise<MakeFactoryResult> {
969
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
970
+ const names = factoryNames(options.name);
971
+ const config = options.config
972
+ ? resolveConfig(options.config)
973
+ : await loadBeignetConfig(targetDir);
974
+
975
+ await assertPersistenceArtifactApp(targetDir, names, config, "factory");
976
+
977
+ return makeFeatureArtifact({
978
+ targetDir,
979
+ config,
980
+ force: Boolean(options.force),
981
+ dryRun: Boolean(options.dryRun),
982
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
983
+ files: factoryFiles(names, config),
984
+ index: featureArtifactIndexFile("factory", names, config),
985
+ dependencies: {
986
+ "@beignet/core": beignetDependencyVersion,
987
+ },
988
+ });
989
+ }
990
+
991
+ export async function makeSeed(
992
+ options: MakeSeedOptions,
993
+ ): Promise<MakeSeedResult> {
994
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
995
+ const names = seedNames(options.name);
996
+ const config = options.config
997
+ ? resolveConfig(options.config)
998
+ : await loadBeignetConfig(targetDir);
999
+
1000
+ await assertPersistenceArtifactApp(targetDir, names, config, "seed");
1001
+
1002
+ return makeFeatureArtifact({
1003
+ targetDir,
1004
+ config,
1005
+ force: Boolean(options.force),
1006
+ dryRun: Boolean(options.dryRun),
1007
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
1008
+ files: seedFiles(names, config),
1009
+ index: featureArtifactIndexFile("seed", names, config),
1010
+ dependencies: {
1011
+ "@beignet/core": beignetDependencyVersion,
1012
+ },
1013
+ });
1014
+ }
1015
+
1016
+ export async function makeNotification(
1017
+ options: MakeNotificationOptions,
1018
+ ): Promise<MakeNotificationResult> {
1019
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
1020
+ const names = notificationNames(options.name);
1021
+ const config = options.config
1022
+ ? resolveConfig(options.config)
1023
+ : await loadBeignetConfig(targetDir);
1024
+
1025
+ await assertFeatureArtifactApp(targetDir, config, "notification");
1026
+
1027
+ return makeFeatureArtifact({
1028
+ targetDir,
1029
+ config,
1030
+ force: Boolean(options.force),
1031
+ dryRun: Boolean(options.dryRun),
1032
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
1033
+ files: [
1034
+ ...(await missingCapabilityBuilderFiles(
1035
+ targetDir,
1036
+ "notifications",
1037
+ config,
1038
+ )),
1039
+ ...notificationFiles(names, config),
1040
+ ],
1041
+ index: featureArtifactIndexFile("notification", names, config),
1042
+ dependencies: {
1043
+ "@beignet/core": beignetDependencyVersion,
1044
+ },
1045
+ });
1046
+ }
1047
+
581
1048
  export async function makeListener(
582
1049
  options: MakeListenerOptions,
583
1050
  ): Promise<MakeListenerResult> {
@@ -596,7 +1063,10 @@ export async function makeListener(
596
1063
  force: Boolean(options.force),
597
1064
  dryRun: Boolean(options.dryRun),
598
1065
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
599
- files: listenerFiles(names, config),
1066
+ files: [
1067
+ ...(await missingCapabilityBuilderFiles(targetDir, "listeners", config)),
1068
+ ...listenerFiles(names, config),
1069
+ ],
600
1070
  index: featureArtifactIndexFile("listener", names, config),
601
1071
  dependencies: {
602
1072
  "@beignet/core": beignetDependencyVersion,
@@ -636,7 +1106,10 @@ export async function makeSchedule(
636
1106
  requireServer: Boolean(options.route),
637
1107
  });
638
1108
 
639
- 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
+ ];
640
1113
 
641
1114
  return makeFeatureArtifact({
642
1115
  targetDir,
@@ -652,47 +1125,136 @@ export async function makeSchedule(
652
1125
  });
653
1126
  }
654
1127
 
655
- async function assertStandardApp(
656
- targetDir: string,
657
- config: ResolvedBeignetConfig,
658
- ): Promise<void> {
659
- const requiredFiles = [
660
- config.paths.appContext,
661
- config.paths.infrastructurePorts,
662
- config.paths.ports,
663
- config.paths.server,
664
- config.paths.useCaseBuilder,
665
- ];
1128
+ export async function makeUpload(
1129
+ options: MakeUploadOptions,
1130
+ ): Promise<MakeUploadResult> {
1131
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
1132
+ const names = uploadNames(options.name);
1133
+ const config = options.config
1134
+ ? resolveConfig(options.config)
1135
+ : await loadBeignetConfig(targetDir);
666
1136
 
667
- for (const file of requiredFiles) {
668
- try {
669
- await stat(path.join(targetDir, file));
670
- } catch {
671
- throw new Error(
672
- `beignet make resource expects a standard Beignet app. Missing ${file}.`,
673
- );
674
- }
675
- }
676
- }
1137
+ await assertFeatureArtifactApp(targetDir, config, "upload");
677
1138
 
678
- async function assertUseCaseApp(
679
- targetDir: string,
680
- config: ResolvedBeignetConfig,
681
- ): Promise<void> {
682
- try {
683
- await stat(path.join(targetDir, config.paths.useCaseBuilder));
684
- } catch {
685
- throw new Error(
686
- `beignet make use-case expects a standard Beignet app. Missing ${config.paths.useCaseBuilder}.`,
687
- );
688
- }
1139
+ return makeFeatureArtifact({
1140
+ targetDir,
1141
+ config,
1142
+ force: Boolean(options.force),
1143
+ dryRun: Boolean(options.dryRun),
1144
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
1145
+ files: uploadFiles(names, config),
1146
+ index: featureArtifactIndexFile("upload", names, config),
1147
+ dependencies: {
1148
+ "@beignet/core": beignetDependencyVersion,
1149
+ },
1150
+ });
689
1151
  }
690
1152
 
691
- async function assertTestApp(
692
- targetDir: string,
693
- names: UseCaseNames,
694
- config: ResolvedBeignetConfig,
695
- ): Promise<void> {
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
+
1217
+ async function assertStandardApp(
1218
+ targetDir: string,
1219
+ config: ResolvedBeignetConfig,
1220
+ ): Promise<void> {
1221
+ const requiredFiles = [
1222
+ config.paths.appContext,
1223
+ config.paths.infrastructurePorts,
1224
+ config.paths.ports,
1225
+ config.paths.server,
1226
+ config.paths.useCaseBuilder,
1227
+ ];
1228
+
1229
+ for (const file of requiredFiles) {
1230
+ try {
1231
+ await stat(path.join(targetDir, file));
1232
+ } catch {
1233
+ throw new Error(
1234
+ `beignet make resource expects a standard Beignet app. Missing ${file}.`,
1235
+ );
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ async function assertUseCaseApp(
1241
+ targetDir: string,
1242
+ config: ResolvedBeignetConfig,
1243
+ ): Promise<void> {
1244
+ try {
1245
+ await stat(path.join(targetDir, config.paths.useCaseBuilder));
1246
+ } catch {
1247
+ throw new Error(
1248
+ `beignet make use-case expects a standard Beignet app. Missing ${config.paths.useCaseBuilder}.`,
1249
+ );
1250
+ }
1251
+ }
1252
+
1253
+ async function assertTestApp(
1254
+ targetDir: string,
1255
+ names: UseCaseNames,
1256
+ config: ResolvedBeignetConfig,
1257
+ ): Promise<void> {
696
1258
  const requiredFiles = [
697
1259
  config.paths.appContext,
698
1260
  config.paths.infrastructurePorts,
@@ -777,7 +1339,14 @@ async function assertPolicyApp(
777
1339
  async function assertFeatureArtifactApp(
778
1340
  targetDir: string,
779
1341
  config: ResolvedBeignetConfig,
780
- artifact: "event" | "job" | "listener" | "schedule",
1342
+ artifact:
1343
+ | "event"
1344
+ | "job"
1345
+ | "task"
1346
+ | "notification"
1347
+ | "listener"
1348
+ | "schedule"
1349
+ | "upload",
781
1350
  options: { requireServer?: boolean } = {},
782
1351
  ): Promise<void> {
783
1352
  const requiredFiles = [
@@ -796,6 +1365,47 @@ async function assertFeatureArtifactApp(
796
1365
  }
797
1366
  }
798
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
+
1386
+ async function assertPersistenceArtifactApp(
1387
+ targetDir: string,
1388
+ names: FactoryNames | SeedNames,
1389
+ config: ResolvedBeignetConfig,
1390
+ artifact: "factory" | "seed",
1391
+ ): Promise<void> {
1392
+ const requiredFiles = [
1393
+ config.paths.appContext,
1394
+ config.paths.ports,
1395
+ resourcePortFilePath(featureResourceNames(names), config),
1396
+ ];
1397
+
1398
+ for (const file of requiredFiles) {
1399
+ try {
1400
+ await stat(path.join(targetDir, file));
1401
+ } catch {
1402
+ throw new Error(
1403
+ `beignet make ${artifact} expects a feature with a repository port. Missing ${file}. Run beignet make feature ${names.feature.kebab} first, or add the repository port before generating ${artifact}s.`,
1404
+ );
1405
+ }
1406
+ }
1407
+ }
1408
+
799
1409
  type WriteGeneratedFileOptions = {
800
1410
  force: boolean;
801
1411
  dryRun: boolean;
@@ -884,6 +1494,83 @@ async function readOptionalFile(filePath: string): Promise<string | undefined> {
884
1494
  }
885
1495
  }
886
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
+
887
1574
  async function makeFeatureArtifact(options: {
888
1575
  targetDir: string;
889
1576
  config: ResolvedBeignetConfig;
@@ -935,6 +1622,7 @@ async function makeFeatureArtifact(options: {
935
1622
  }
936
1623
 
937
1624
  return {
1625
+ schemaVersion: 1,
938
1626
  name: options.name,
939
1627
  targetDir: options.targetDir,
940
1628
  dryRun: options.dryRun,
@@ -978,14 +1666,29 @@ function beignetDependencyVersion(packageJson: PackageJsonLike): string {
978
1666
  );
979
1667
  }
980
1668
 
1669
+ function tanstackReactQueryDependencyVersion(
1670
+ packageJson: PackageJsonLike,
1671
+ ): string {
1672
+ return packageJson.dependencies?.["@tanstack/react-query"] ?? "^5.100.7";
1673
+ }
1674
+
981
1675
  async function updateResourceWiring(
982
1676
  targetDir: string,
983
1677
  names: ResourceNames,
984
1678
  config: ResolvedBeignetConfig,
985
1679
  persistenceOrOptions:
986
1680
  | ResourcePersistence
987
- | { dryRun: boolean; persistence?: ResourcePersistence },
988
- 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
+ },
989
1692
  ): Promise<string[]> {
990
1693
  const persistence =
991
1694
  typeof persistenceOrOptions === "string"
@@ -995,12 +1698,49 @@ async function updateResourceWiring(
995
1698
  typeof persistenceOrOptions === "string"
996
1699
  ? (maybeOptions as { dryRun: boolean })
997
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));
998
1711
  const updated = new Set<string>();
999
- if (await updatePackageJson(targetDir, options)) updated.add("package.json");
1000
- if (await updatePortsIndex(targetDir, names, config, options)) {
1712
+ if (
1713
+ await updatePackageJson(targetDir, {
1714
+ ...options,
1715
+ webTestingDependency: true,
1716
+ })
1717
+ ) {
1718
+ updated.add("package.json");
1719
+ }
1720
+ if (
1721
+ await updatePortsIndex(targetDir, names, config, {
1722
+ ...options,
1723
+ generationOptions,
1724
+ })
1725
+ ) {
1001
1726
  updated.add(config.paths.ports);
1002
1727
  }
1003
- if (await updateInfrastructurePorts(targetDir, names, config, options)) {
1728
+ if (
1729
+ persistence === "memory" &&
1730
+ (await updateInfrastructurePorts(targetDir, names, config, options))
1731
+ ) {
1732
+ updated.add(config.paths.infrastructurePorts);
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
+ ) {
1004
1744
  updated.add(config.paths.infrastructurePorts);
1005
1745
  }
1006
1746
  if (
@@ -1021,7 +1761,14 @@ async function updateResourceWiring(
1021
1761
  ) {
1022
1762
  updated.add(config.paths.server);
1023
1763
  }
1024
- 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
+ ) {
1025
1772
  updated.add(config.paths.openapiRoute);
1026
1773
  }
1027
1774
  const routesFile = await updateServerRoutes(
@@ -1037,17 +1784,27 @@ async function updateResourceWiring(
1037
1784
 
1038
1785
  async function updatePackageJson(
1039
1786
  targetDir: string,
1040
- options: { dryRun: boolean },
1787
+ options: { dryRun: boolean; webTestingDependency?: boolean },
1041
1788
  ): Promise<boolean> {
1042
1789
  const filePath = path.join(targetDir, "package.json");
1043
1790
  const original = await readFile(filePath, "utf8");
1044
1791
  const packageJson = JSON.parse(original) as {
1045
1792
  scripts?: Record<string, string>;
1793
+ dependencies?: Record<string, string>;
1794
+ devDependencies?: Record<string, string>;
1046
1795
  };
1047
1796
  packageJson.scripts = packageJson.scripts ?? {};
1048
- if (packageJson.scripts.test) return false;
1049
1797
 
1050
- packageJson.scripts.test = "bun test";
1798
+ packageJson.scripts.test ??= "bun test";
1799
+
1800
+ if (options.webTestingDependency) {
1801
+ packageJson.devDependencies = packageJson.devDependencies ?? {};
1802
+ packageJson.devDependencies["@beignet/web"] ??=
1803
+ packageJson.dependencies?.["@beignet/core"] ??
1804
+ packageJson.dependencies?.["@beignet/next"] ??
1805
+ "*";
1806
+ }
1807
+
1051
1808
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
1052
1809
  if (!options.dryRun) await writeFile(filePath, next);
1053
1810
  return next !== original;
@@ -1057,7 +1814,7 @@ async function updatePortsIndex(
1057
1814
  targetDir: string,
1058
1815
  names: ResourceNames,
1059
1816
  config: ResolvedBeignetConfig,
1060
- options: { dryRun: boolean },
1817
+ options: { dryRun: boolean; generationOptions?: ResourceGenerationOptions },
1061
1818
  ): Promise<boolean> {
1062
1819
  const filePath = path.join(targetDir, config.paths.ports);
1063
1820
  const original = await readFile(filePath, "utf8");
@@ -1069,6 +1826,30 @@ async function updatePortsIndex(
1069
1826
  next = insertAfterImports(next, importLine);
1070
1827
  }
1071
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
+
1072
1853
  const entry = `\t${names.pluralCamel}: ${names.singularPascal}Repository;`;
1073
1854
  if (!next.includes(entry.trim())) {
1074
1855
  next = insertTypeProperty(
@@ -1225,7 +2006,7 @@ async function updateInfrastructurePorts(
1225
2006
  if (!next.includes(repositoryConst)) {
1226
2007
  next = replaceRequired(
1227
2008
  next,
1228
- /(\nexport const appPorts = definePorts(?:<[^>]+>)?\(\{)/,
2009
+ /(\nexport const appPorts = definePorts(?:<[^>]+>)?\((?:\)\()?\{)/,
1229
2010
  `\n${repositoryConst}\n$1`,
1230
2011
  `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add ${repositoryConst} manually, or restore the generated infrastructure ports file before running make resource.`,
1231
2012
  );
@@ -1262,6 +2043,37 @@ async function updateInfrastructurePorts(
1262
2043
  return true;
1263
2044
  }
1264
2045
 
2046
+ async function updateInfrastructureGatePolicies(
2047
+ targetDir: string,
2048
+ names: ResourceNames,
2049
+ config: ResolvedBeignetConfig,
2050
+ options: { dryRun: boolean },
2051
+ ): Promise<boolean> {
2052
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2053
+ const original = await readFile(filePath, "utf8");
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
+
1265
2077
  async function updateDrizzleSchemaIndex(
1266
2078
  targetDir: string,
1267
2079
  names: ResourceNames,
@@ -1319,6 +2131,24 @@ async function updateDrizzleRepositories(
1319
2131
  return true;
1320
2132
  }
1321
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
+
1322
2152
  async function updateServerTransactionPorts(
1323
2153
  targetDir: string,
1324
2154
  names: ResourceNames,
@@ -1359,7 +2189,7 @@ async function updateOpenApiRoute(
1359
2189
  targetDir: string,
1360
2190
  names: ResourceNames,
1361
2191
  config: ResolvedBeignetConfig,
1362
- options: { dryRun: boolean },
2192
+ options: { dryRun: boolean; mode: ResourceGenerationMode },
1363
2193
  ): Promise<boolean> {
1364
2194
  const filePath = path.join(targetDir, config.paths.openapiRoute);
1365
2195
  if (!(await fileExists(filePath))) return false;
@@ -1368,15 +2198,14 @@ async function updateOpenApiRoute(
1368
2198
  const firstArg = firstCreateOpenAPIHandlerArgInfo(original);
1369
2199
  if (!firstArg?.text.startsWith("[")) return false;
1370
2200
 
1371
- const listName = `list${names.pluralPascal}`;
1372
- const createName = `create${names.singularPascal}`;
2201
+ const contractNames = resourceContractNames(names, options.mode);
1373
2202
  const listedContracts = identifiersFromArrayExpression(firstArg.text);
1374
- const missingContracts = [listName, createName].filter(
2203
+ const missingContracts = contractNames.filter(
1375
2204
  (contractName) => !listedContracts.has(contractName),
1376
2205
  );
1377
2206
  if (missingContracts.length === 0) return false;
1378
2207
 
1379
- 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))}";`;
1380
2209
  const nextArray = appendToArrayExpression(firstArg.text, missingContracts);
1381
2210
  let next = `${original.slice(0, firstArg.start)}${nextArray}${original.slice(
1382
2211
  firstArg.end,
@@ -1391,38 +2220,106 @@ async function updateOpenApiRoute(
1391
2220
  return true;
1392
2221
  }
1393
2222
 
1394
- async function updateServerRoutes(
2223
+ async function updateSharedErrors(
1395
2224
  targetDir: string,
1396
2225
  names: ResourceNames,
1397
2226
  config: ResolvedBeignetConfig,
1398
- options: { dryRun: boolean },
1399
- ): Promise<string | undefined> {
1400
- const filePath = path.join(targetDir, config.paths.server);
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
+
1401
2234
  const original = await readFile(filePath, "utf8");
1402
- const routeRegistryPath = routeRegistryFileFromServerSource(
1403
- original,
1404
- config.paths.server,
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),
1405
2239
  );
2240
+ if (missingErrorNames.length === 0) return false;
2241
+ const expectedErrorNames = missingErrorNames.join(" and ");
1406
2242
 
1407
- if (routeRegistryPath) {
1408
- await assertImportedRoutesFileExists(targetDir, routeRegistryPath);
1409
- return updateDefineRoutesFile(
1410
- targetDir,
1411
- routeRegistryPath,
1412
- names,
1413
- config,
1414
- options,
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.`,
1415
2247
  );
1416
2248
  }
1417
2249
 
1418
- return updateDefineRoutesFile(
1419
- targetDir,
1420
- config.paths.server,
1421
- names,
1422
- config,
1423
- options,
1424
- );
1425
- }
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
+
2291
+ async function updateServerRoutes(
2292
+ targetDir: string,
2293
+ names: ResourceNames,
2294
+ config: ResolvedBeignetConfig,
2295
+ options: { dryRun: boolean },
2296
+ ): Promise<string | undefined> {
2297
+ const filePath = path.join(targetDir, config.paths.server);
2298
+ const original = await readFile(filePath, "utf8");
2299
+ const routeRegistryPath = routeRegistryFileFromServerSource(
2300
+ original,
2301
+ config.paths.server,
2302
+ );
2303
+
2304
+ if (routeRegistryPath) {
2305
+ await assertImportedRoutesFileExists(targetDir, routeRegistryPath);
2306
+ return updateDefineRoutesFile(
2307
+ targetDir,
2308
+ routeRegistryPath,
2309
+ names,
2310
+ config,
2311
+ options,
2312
+ );
2313
+ }
2314
+
2315
+ return updateDefineRoutesFile(
2316
+ targetDir,
2317
+ config.paths.server,
2318
+ names,
2319
+ config,
2320
+ options,
2321
+ );
2322
+ }
1426
2323
 
1427
2324
  async function updateDefineRoutesFile(
1428
2325
  targetDir: string,
@@ -1784,6 +2681,98 @@ function typeBodySource(source: string, typeName: string): string | undefined {
1784
2681
  return source.slice(openBrace + 1, closeBrace);
1785
2682
  }
1786
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
+
1787
2776
  function insertDefinePortsProperty(
1788
2777
  source: string,
1789
2778
  property: string,
@@ -1881,7 +2870,20 @@ function definePortsBodySource(source: string): string | undefined {
1881
2870
  function definePortsBodyRange(
1882
2871
  source: string,
1883
2872
  ): { openBrace: number; closeBrace: number } | undefined {
1884
- 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);
1885
2887
  if (!match) return undefined;
1886
2888
 
1887
2889
  const openBrace = source.indexOf("{", match.index);
@@ -1891,6 +2893,50 @@ function definePortsBodyRange(
1891
2893
  return { openBrace, closeBrace };
1892
2894
  }
1893
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
+
1894
2940
  function hasTopLevelObjectProperty(
1895
2941
  source: string,
1896
2942
  propertyName: string,
@@ -2193,6 +3239,25 @@ function resourceNames(input: string): ResourceNames {
2193
3239
  return names;
2194
3240
  }
2195
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
+
3257
+ function featureResourceNames(names: FeatureArtifactNames): ResourceNames {
3258
+ return resourceNames(names.feature.kebab);
3259
+ }
3260
+
2196
3261
  function useCaseNames(input: string): UseCaseNames {
2197
3262
  const parts = input
2198
3263
  .trim()
@@ -2333,6 +3398,64 @@ function jobNames(input: string): JobNames {
2333
3398
  };
2334
3399
  }
2335
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
+
3416
+ function factoryNames(input: string): FactoryNames {
3417
+ const names = featureArtifactNames(input, "Factory");
3418
+ const factoryExportName = `${names.artifact.camel}Factory`;
3419
+
3420
+ assertGeneratedIdentifiers(input, "Factory name", [factoryExportName]);
3421
+
3422
+ return {
3423
+ ...names,
3424
+ factoryName: names.stableName,
3425
+ factoryExportName,
3426
+ };
3427
+ }
3428
+
3429
+ function seedNames(input: string): SeedNames {
3430
+ const names = featureArtifactNames(input, "Seed");
3431
+ const seedExportName = `${names.artifact.camel}Seed`;
3432
+
3433
+ assertGeneratedIdentifiers(input, "Seed name", [seedExportName]);
3434
+
3435
+ return {
3436
+ ...names,
3437
+ seedName: names.stableName,
3438
+ seedExportName,
3439
+ };
3440
+ }
3441
+
3442
+ function notificationNames(input: string): NotificationNames {
3443
+ const names = featureArtifactNames(input, "Notification");
3444
+ const notificationExportName = `${names.featureSingularPascal}${names.artifact.pascal}Notification`;
3445
+
3446
+ assertGeneratedIdentifiers(input, "Notification name", [
3447
+ notificationExportName,
3448
+ ]);
3449
+
3450
+ return {
3451
+ ...names,
3452
+ notificationName: names.stableName,
3453
+ notificationExportName,
3454
+ payloadSchemaName: `${notificationExportName}PayloadSchema`,
3455
+ payloadTypeName: `${notificationExportName}Payload`,
3456
+ };
3457
+ }
3458
+
2336
3459
  function listenerNames(input: string, eventInput: string): ListenerNames {
2337
3460
  const names = featureArtifactNames(input, "Listener");
2338
3461
  const listenerExportName = names.artifact.camel;
@@ -2367,6 +3490,21 @@ function scheduleNames(
2367
3490
  };
2368
3491
  }
2369
3492
 
3493
+ function uploadNames(input: string): UploadNames {
3494
+ const names = featureArtifactNames(input, "Upload");
3495
+ const uploadExportName = `${names.artifact.pascal}Upload`;
3496
+
3497
+ assertGeneratedIdentifiers(input, "Upload name", [uploadExportName]);
3498
+
3499
+ return {
3500
+ ...names,
3501
+ uploadName: names.stableName,
3502
+ uploadExportName,
3503
+ metadataSchemaName: `${uploadExportName}MetadataSchema`,
3504
+ metadataTypeName: `${uploadExportName}Metadata`,
3505
+ };
3506
+ }
3507
+
2370
3508
  function normalizeEventReference(input: string): string {
2371
3509
  const trimmed = input.trim();
2372
3510
  if (trimmed.includes("/")) return trimmed;
@@ -2524,10 +3662,21 @@ function pascalCase(value: string): string {
2524
3662
  return `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
2525
3663
  }
2526
3664
 
3665
+ function constantCase(value: string): string {
3666
+ return value.replaceAll("-", "_").toUpperCase();
3667
+ }
3668
+
2527
3669
  function resourceFiles(
2528
3670
  names: ResourceNames,
2529
3671
  config: ResolvedBeignetConfig,
2530
3672
  persistence: ResourcePersistence = "memory",
3673
+ mode: ResourceGenerationMode = "feature",
3674
+ options: ResourceGenerationOptions = {
3675
+ auth: false,
3676
+ tenant: false,
3677
+ events: false,
3678
+ softDelete: false,
3679
+ },
2531
3680
  ): GeneratedFile[] {
2532
3681
  const useCaseDir = resourceUseCaseDir(names, config);
2533
3682
  const infraDir = infrastructureDir(config);
@@ -2535,27 +3684,63 @@ function resourceFiles(
2535
3684
  const files: GeneratedFile[] = [
2536
3685
  {
2537
3686
  path: resourceContractFilePath(names, config),
2538
- content: contractFile(names, config),
3687
+ content: contractFile(names, config, mode, options),
2539
3688
  },
2540
3689
  {
2541
- path: path.join(useCaseDir, "schemas.ts"),
2542
- content: schemasFile(names),
3690
+ path: path.join(featureDir, "schemas.ts"),
3691
+ content: schemasFile(names, mode, options),
2543
3692
  },
2544
3693
  {
2545
3694
  path: path.join(useCaseDir, `list-${names.pluralKebab}.ts`),
2546
- content: listUseCaseFile(names, config),
3695
+ content: listUseCaseFile(names, config, options),
2547
3696
  },
2548
3697
  {
2549
3698
  path: path.join(useCaseDir, `create-${names.singularKebab}.ts`),
2550
- content: createUseCaseFile(names, config),
3699
+ content: createUseCaseFile(names, config, options),
2551
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
+ : []),
2552
3737
  {
2553
3738
  path: path.join(useCaseDir, "index.ts"),
2554
- content: useCasesIndexFile(names),
3739
+ content: useCasesIndexFile(names, mode),
2555
3740
  },
2556
3741
  {
2557
3742
  path: resourcePortFilePath(names, config),
2558
- content: repositoryPortFile(names, config),
3743
+ content: repositoryPortFile(names, config, mode, options),
2559
3744
  },
2560
3745
  {
2561
3746
  path: path.join(
@@ -2563,15 +3748,15 @@ function resourceFiles(
2563
3748
  names.pluralKebab,
2564
3749
  `in-memory-${names.singularKebab}-repository.ts`,
2565
3750
  ),
2566
- content: inMemoryRepositoryFile(names, config),
3751
+ content: inMemoryRepositoryFile(names, config, mode, options),
2567
3752
  },
2568
3753
  {
2569
3754
  path: path.join(featureDir, "routes.ts"),
2570
- content: routeGroupFile(names, config),
3755
+ content: routeGroupFile(names, config, mode, options),
2571
3756
  },
2572
3757
  {
2573
3758
  path: resourceTestFilePath(names, config),
2574
- content: testFile(names, config),
3759
+ content: testFile(names, config, mode, options),
2575
3760
  },
2576
3761
  ];
2577
3762
 
@@ -2579,11 +3764,11 @@ function resourceFiles(
2579
3764
  files.push(
2580
3765
  {
2581
3766
  path: drizzleResourceSchemaFilePath(names, config),
2582
- content: drizzleSchemaFile(names),
3767
+ content: drizzleSchemaFile(names, options),
2583
3768
  },
2584
3769
  {
2585
3770
  path: drizzleResourceRepositoryFilePath(names, config),
2586
- content: drizzleRepositoryFile(names, config),
3771
+ content: drizzleRepositoryFile(names, config, mode, options),
2587
3772
  },
2588
3773
  );
2589
3774
  }
@@ -2591,6 +3776,31 @@ function resourceFiles(
2591
3776
  return files;
2592
3777
  }
2593
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
+
2594
3804
  function contractFiles(
2595
3805
  names: ResourceNames,
2596
3806
  config: ResolvedBeignetConfig,
@@ -2689,6 +3899,54 @@ function jobFiles(
2689
3899
  ];
2690
3900
  }
2691
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
+
3914
+ function notificationFiles(
3915
+ names: NotificationNames,
3916
+ config: ResolvedBeignetConfig,
3917
+ ): GeneratedFile[] {
3918
+ return [
3919
+ {
3920
+ path: notificationFilePath(names, config),
3921
+ content: notificationFile(names, config),
3922
+ },
3923
+ ];
3924
+ }
3925
+
3926
+ function factoryFiles(
3927
+ names: FactoryNames,
3928
+ config: ResolvedBeignetConfig,
3929
+ ): GeneratedFile[] {
3930
+ return [
3931
+ {
3932
+ path: factoryFilePath(names, config),
3933
+ content: factoryFile(names, config),
3934
+ },
3935
+ ];
3936
+ }
3937
+
3938
+ function seedFiles(
3939
+ names: SeedNames,
3940
+ config: ResolvedBeignetConfig,
3941
+ ): GeneratedFile[] {
3942
+ return [
3943
+ {
3944
+ path: seedFilePath(names, config),
3945
+ content: seedFile(names, config),
3946
+ },
3947
+ ];
3948
+ }
3949
+
2692
3950
  function listenerFiles(
2693
3951
  names: ListenerNames,
2694
3952
  config: ResolvedBeignetConfig,
@@ -2723,6 +3981,18 @@ function scheduleFiles(
2723
3981
  return files;
2724
3982
  }
2725
3983
 
3984
+ function uploadFiles(
3985
+ names: UploadNames,
3986
+ config: ResolvedBeignetConfig,
3987
+ ): GeneratedFile[] {
3988
+ return [
3989
+ {
3990
+ path: uploadFilePath(names, config),
3991
+ content: uploadFile(names, config),
3992
+ },
3993
+ ];
3994
+ }
3995
+
2726
3996
  function portFilePath(names: PortNames, config: ResolvedBeignetConfig): string {
2727
3997
  return path.join(path.dirname(config.paths.ports), `${names.kebab}.ts`);
2728
3998
  }
@@ -2773,6 +4043,30 @@ function drizzleResourceRepositoryFilePath(
2773
4043
  );
2774
4044
  }
2775
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
+
2776
4070
  async function detectResourcePersistence(
2777
4071
  targetDir: string,
2778
4072
  config: ResolvedBeignetConfig,
@@ -2791,6 +4085,36 @@ function resourceFeatureDir(
2791
4085
  return path.join(config.paths.features, names.pluralKebab);
2792
4086
  }
2793
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
+
2794
4118
  function usesFeatureOwnedContracts(config: ResolvedBeignetConfig): boolean {
2795
4119
  return (
2796
4120
  directoryPath(config.paths.contracts) ===
@@ -2855,6 +4179,13 @@ function resourceUseCaseIndexPath(
2855
4179
  return path.join(resourceUseCaseDir(names, config), "index.ts");
2856
4180
  }
2857
4181
 
4182
+ function resourceSchemaFilePath(
4183
+ names: ResourceNames,
4184
+ config: ResolvedBeignetConfig,
4185
+ ): string {
4186
+ return path.join(resourceFeatureDir(names, config), "schemas.ts");
4187
+ }
4188
+
2858
4189
  function resourcePortFilePath(
2859
4190
  names: ResourceNames,
2860
4191
  config: ResolvedBeignetConfig,
@@ -2917,7 +4248,16 @@ function policyFilePath(
2917
4248
 
2918
4249
  function featureArtifactDir(
2919
4250
  names: FeatureArtifactNames,
2920
- kind: "events" | "jobs" | "listeners" | "schedules",
4251
+ kind:
4252
+ | "events"
4253
+ | "factories"
4254
+ | "tasks"
4255
+ | "jobs"
4256
+ | "notifications"
4257
+ | "listeners"
4258
+ | "seeds"
4259
+ | "schedules"
4260
+ | "uploads",
2921
4261
  config: ResolvedBeignetConfig,
2922
4262
  ): string {
2923
4263
  const featureDir = path.join(config.paths.features, names.feature.kebab);
@@ -2925,6 +4265,9 @@ function featureArtifactDir(
2925
4265
  if (kind === "events") {
2926
4266
  return path.join(featureDir, "domain", "events");
2927
4267
  }
4268
+ if (kind === "factories") {
4269
+ return path.join(featureDir, "tests", "factories");
4270
+ }
2928
4271
 
2929
4272
  return path.join(featureDir, kind);
2930
4273
  }
@@ -2946,10 +4289,44 @@ function jobFilePath(names: JobNames, config: ResolvedBeignetConfig): string {
2946
4289
  );
2947
4290
  }
2948
4291
 
2949
- function listenerFilePath(
2950
- names: ListenerNames,
2951
- config: ResolvedBeignetConfig,
2952
- ): string {
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
+
4299
+ function factoryFilePath(
4300
+ names: FactoryNames,
4301
+ config: ResolvedBeignetConfig,
4302
+ ): string {
4303
+ return path.join(
4304
+ featureArtifactDir(names, "factories", config),
4305
+ `${names.artifact.kebab}.ts`,
4306
+ );
4307
+ }
4308
+
4309
+ function seedFilePath(names: SeedNames, config: ResolvedBeignetConfig): string {
4310
+ return path.join(
4311
+ featureArtifactDir(names, "seeds", config),
4312
+ `${names.artifact.kebab}.ts`,
4313
+ );
4314
+ }
4315
+
4316
+ function notificationFilePath(
4317
+ names: NotificationNames,
4318
+ config: ResolvedBeignetConfig,
4319
+ ): string {
4320
+ return path.join(
4321
+ featureArtifactDir(names, "notifications", config),
4322
+ `${names.artifact.kebab}.ts`,
4323
+ );
4324
+ }
4325
+
4326
+ function listenerFilePath(
4327
+ names: ListenerNames,
4328
+ config: ResolvedBeignetConfig,
4329
+ ): string {
2953
4330
  return path.join(
2954
4331
  featureArtifactDir(names, "listeners", config),
2955
4332
  `${names.artifact.kebab}.ts`,
@@ -2966,6 +4343,16 @@ function scheduleFilePath(
2966
4343
  );
2967
4344
  }
2968
4345
 
4346
+ function uploadFilePath(
4347
+ names: UploadNames,
4348
+ config: ResolvedBeignetConfig,
4349
+ ): string {
4350
+ return path.join(
4351
+ featureArtifactDir(names, "uploads", config),
4352
+ `${names.artifact.kebab}.ts`,
4353
+ );
4354
+ }
4355
+
2969
4356
  function scheduleRouteFilePath(
2970
4357
  names: ScheduleNames,
2971
4358
  config: ResolvedBeignetConfig,
@@ -3027,16 +4414,69 @@ async function updateUseCaseIndex(
3027
4414
  return "updated";
3028
4415
  }
3029
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
+
3030
4441
  type FeatureArtifactIndexFile = {
3031
4442
  path: string;
4443
+ kind:
4444
+ | "event"
4445
+ | "job"
4446
+ | "task"
4447
+ | "factory"
4448
+ | "seed"
4449
+ | "notification"
4450
+ | "listener"
4451
+ | "schedule"
4452
+ | "upload";
3032
4453
  importName: string;
3033
4454
  artifactPath: string;
3034
4455
  registryName: string;
4456
+ registryEntryName: string;
3035
4457
  };
3036
4458
 
3037
4459
  function featureArtifactIndexFile(
3038
- kind: "event" | "job" | "listener" | "schedule",
3039
- names: EventNames | JobNames | ListenerNames | ScheduleNames,
4460
+ kind:
4461
+ | "event"
4462
+ | "job"
4463
+ | "task"
4464
+ | "factory"
4465
+ | "seed"
4466
+ | "notification"
4467
+ | "listener"
4468
+ | "schedule"
4469
+ | "upload",
4470
+ names:
4471
+ | EventNames
4472
+ | JobNames
4473
+ | TaskNames
4474
+ | FactoryNames
4475
+ | SeedNames
4476
+ | NotificationNames
4477
+ | ListenerNames
4478
+ | ScheduleNames
4479
+ | UploadNames,
3040
4480
  config: ResolvedBeignetConfig,
3041
4481
  ): FeatureArtifactIndexFile {
3042
4482
  const artifactPath =
@@ -3044,31 +4484,63 @@ function featureArtifactIndexFile(
3044
4484
  ? eventFilePath(names as EventNames, config)
3045
4485
  : kind === "job"
3046
4486
  ? jobFilePath(names as JobNames, config)
3047
- : kind === "listener"
3048
- ? listenerFilePath(names as ListenerNames, config)
3049
- : scheduleFilePath(names as ScheduleNames, 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);
3050
4500
  const registrySuffix =
3051
4501
  kind === "event"
3052
4502
  ? "Events"
3053
4503
  : kind === "job"
3054
4504
  ? "Jobs"
3055
- : kind === "listener"
3056
- ? "Listeners"
3057
- : "Schedules";
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";
3058
4518
  const importName =
3059
4519
  kind === "event"
3060
4520
  ? (names as EventNames).eventExportName
3061
4521
  : kind === "job"
3062
4522
  ? (names as JobNames).jobExportName
3063
- : kind === "listener"
3064
- ? (names as ListenerNames).listenerExportName
3065
- : (names as ScheduleNames).scheduleExportName;
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;
3066
4536
 
3067
4537
  return {
3068
4538
  path: path.join(path.dirname(artifactPath), "index.ts"),
4539
+ kind,
3069
4540
  importName,
3070
4541
  artifactPath,
3071
4542
  registryName: `${names.featureSingularCamel}${registrySuffix}`,
4543
+ registryEntryName: names.artifact.camel,
3072
4544
  };
3073
4545
  }
3074
4546
 
@@ -3081,10 +4553,22 @@ async function updateFeatureArtifactIndex(
3081
4553
  const existing = await readOptionalFile(destination);
3082
4554
  const specifier = relativeModule(file.path, file.artifactPath);
3083
4555
  const importLine = `import { ${file.importName} } from "${specifier}";`;
4556
+ const defineUploadsImport = `import { defineUploads } from "@beignet/core/uploads";`;
3084
4557
  const exportLine = `export { ${file.importName} } from "${specifier}";`;
3085
4558
 
3086
4559
  if (existing === undefined) {
3087
- const content = `${importLine}
4560
+ const content =
4561
+ file.kind === "upload"
4562
+ ? `${defineUploadsImport}
4563
+ ${importLine}
4564
+
4565
+ ${exportLine}
4566
+
4567
+ export const ${file.registryName} = defineUploads({
4568
+ \t${file.registryEntryName}: ${file.importName},
4569
+ });
4570
+ `
4571
+ : `${importLine}
3088
4572
 
3089
4573
  ${exportLine}
3090
4574
 
@@ -3114,13 +4598,123 @@ export const ${file.registryName} = [${file.importName}] as const;
3114
4598
  : `${next.trimEnd()}\n${exportLine}\n`;
3115
4599
  }
3116
4600
 
3117
- const registry = arrayInitializerInfo(next, file.registryName);
4601
+ if (file.kind === "upload") {
4602
+ const registry = defineUploadsInitializerInfo(next, file.registryName);
4603
+ const arrayRegistry = arrayInitializerInfo(next, file.registryName);
4604
+ if (registry) {
4605
+ if (!next.includes(defineUploadsImport)) {
4606
+ next = insertAfterImports(next, defineUploadsImport);
4607
+ }
4608
+ if (!registry.text.includes(`${file.registryEntryName}:`)) {
4609
+ next = `${next.slice(0, registry.end)}\t${file.registryEntryName}: ${file.importName},\n${next.slice(
4610
+ registry.end,
4611
+ )}`;
4612
+ }
4613
+ } else if (arrayRegistry) {
4614
+ if (
4615
+ !identifiersFromArrayExpression(arrayRegistry.text).has(file.importName)
4616
+ ) {
4617
+ const nextArray = appendToArrayExpression(arrayRegistry.text, [
4618
+ file.importName,
4619
+ ]);
4620
+ next = `${next.slice(0, arrayRegistry.start)}${nextArray}${next.slice(
4621
+ arrayRegistry.end,
4622
+ )}`;
4623
+ }
4624
+ } else {
4625
+ if (!next.includes(defineUploadsImport)) {
4626
+ next = insertAfterImports(next, defineUploadsImport);
4627
+ }
4628
+ next = `${next.trimEnd()}\n\nexport const ${file.registryName} = defineUploads({\n\t${file.registryEntryName}: ${file.importName},\n});\n`;
4629
+ }
4630
+ } else {
4631
+ const registry = arrayInitializerInfo(next, file.registryName);
4632
+ if (!registry) {
4633
+ next = `${next.trimEnd()}\n\nexport const ${file.registryName} = [${file.importName}] as const;\n`;
4634
+ } else if (
4635
+ !identifiersFromArrayExpression(registry.text).has(file.importName)
4636
+ ) {
4637
+ const nextArray = appendToArrayExpression(registry.text, [
4638
+ file.importName,
4639
+ ]);
4640
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(
4641
+ registry.end,
4642
+ )}`;
4643
+ }
4644
+ }
4645
+
4646
+ if (next === existing) return "skipped";
4647
+ if (!options.dryRun) await writeFile(destination, next);
4648
+ return "updated";
4649
+ }
4650
+
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");
3118
4714
  if (!registry) {
3119
- next = `${next.trimEnd()}\n\nexport const ${file.registryName} = [${file.importName}] as const;\n`;
3120
- } else if (
3121
- !identifiersFromArrayExpression(registry.text).has(file.importName)
3122
- ) {
3123
- const nextArray = appendToArrayExpression(registry.text, [file.importName]);
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]);
3124
4718
  next = `${next.slice(0, registry.start)}${nextArray}${next.slice(
3125
4719
  registry.end,
3126
4720
  )}`;
@@ -3151,35 +4745,175 @@ function arrayInitializerInfo(
3151
4745
  };
3152
4746
  }
3153
4747
 
3154
- function schemasFile(names: ResourceNames): string {
4748
+ function defineUploadsInitializerInfo(
4749
+ source: string,
4750
+ constName: string,
4751
+ ): { text: string; start: number; end: number } | undefined {
4752
+ const match = new RegExp(
4753
+ `\\bconst\\s+${constName}\\s*=\\s*defineUploads\\s*\\(`,
4754
+ ).exec(source);
4755
+ if (!match) return undefined;
4756
+
4757
+ const openBrace = source.indexOf("{", match.index);
4758
+ if (openBrace === -1) return undefined;
4759
+
4760
+ const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
4761
+ if (closeBrace === -1) return undefined;
4762
+
4763
+ return {
4764
+ text: source.slice(openBrace, closeBrace + 1),
4765
+ start: openBrace,
4766
+ end: closeBrace,
4767
+ };
4768
+ }
4769
+
4770
+ function schemasFile(
4771
+ names: ResourceNames,
4772
+ mode: ResourceGenerationMode,
4773
+ options: ResourceGenerationOptions,
4774
+ ): string {
3155
4775
  return `import { z } from "zod";
3156
4776
 
3157
4777
  export const ${names.singularPascal}Schema = z.object({
3158
4778
  id: z.string().uuid(),
3159
- 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),
3160
4781
  createdAt: z.string().datetime(),
4782
+ updatedAt: z.string().datetime(),
3161
4783
  });
3162
4784
 
3163
- export const List${names.pluralPascal}InputSchema = z.object({
3164
- limit: z.coerce.number().int().min(1).max(100).default(20),
3165
- 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(),
3166
4793
  });
3167
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
+
3168
4867
  export const List${names.pluralPascal}OutputSchema = z.object({
3169
- ${names.pluralCamel}: z.array(${names.singularPascal}Schema),
3170
- total: z.number().int().min(0),
3171
- limit: z.number().int().min(1),
3172
- offset: z.number().int().min(0),
4868
+ items: z.array(${names.singularPascal}Schema),
4869
+ page: z.object({
4870
+ kind: z.literal("cursor"),
4871
+ limit: z.number().int().min(1),
4872
+ cursor: z.string().nullable(),
4873
+ nextCursor: z.string().nullable(),
4874
+ hasMore: z.boolean(),
4875
+ }),
3173
4876
  });
3174
4877
 
3175
4878
  export const Create${names.singularPascal}InputSchema = z.object({
3176
4879
  name: z.string().min(1).max(120),
3177
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
+ });
3178
4892
 
4893
+ export const Update${names.singularPascal}InputSchema =
4894
+ ${names.singularPascal}IdInputSchema.merge(Update${names.singularPascal}BodySchema);
4895
+ `
4896
+ : ""
4897
+ }
3179
4898
  export type ${names.singularPascal} = z.infer<typeof ${names.singularPascal}Schema>;
3180
4899
  export type Create${names.singularPascal}Input = z.infer<
3181
4900
  typeof Create${names.singularPascal}InputSchema
3182
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
+ }
3183
4917
  export type List${names.pluralPascal}Input = z.infer<
3184
4918
  typeof List${names.pluralPascal}InputSchema
3185
4919
  >;
@@ -3189,30 +4923,39 @@ export type List${names.pluralPascal}Input = z.infer<
3189
4923
  function listUseCaseFile(
3190
4924
  names: ResourceNames,
3191
4925
  config: ResolvedBeignetConfig,
4926
+ options: ResourceGenerationOptions,
3192
4927
  ): string {
3193
4928
  const filePath = path.join(
3194
4929
  resourceUseCaseDir(names, config),
3195
4930
  `list-${names.pluralKebab}.ts`,
3196
4931
  );
3197
4932
 
3198
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
4933
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
4934
+ import { normalizeCursorPage } from "@beignet/core/pagination";
4935
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3199
4936
  import {
4937
+ decode${names.singularPascal}Cursor,
3200
4938
  List${names.pluralPascal}InputSchema,
3201
4939
  List${names.pluralPascal}OutputSchema,
3202
- } from "./schemas";
4940
+ } from "../schemas";
3203
4941
 
3204
4942
  export const list${names.pluralPascal}UseCase = useCase
3205
4943
  .query("${names.pluralCamel}.list")
3206
4944
  .input(List${names.pluralPascal}InputSchema)
3207
4945
  .output(List${names.pluralPascal}OutputSchema)
3208
4946
  .run(async ({ ctx, input }) => {
3209
- const result = await ctx.ports.${names.pluralCamel}.list(input);
3210
- return {
3211
- ${names.pluralCamel}: result.${names.pluralCamel},
3212
- total: result.total,
3213
- limit: input.limit,
3214
- offset: input.offset,
3215
- };
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, {
4948
+ defaultLimit: 20,
4949
+ maxLimit: 100,
4950
+ });
4951
+
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` : ""} });
3216
4959
  });
3217
4960
  `;
3218
4961
  }
@@ -3220,135 +4963,430 @@ export const list${names.pluralPascal}UseCase = useCase
3220
4963
  function createUseCaseFile(
3221
4964
  names: ResourceNames,
3222
4965
  config: ResolvedBeignetConfig,
4966
+ options: ResourceGenerationOptions,
3223
4967
  ): string {
3224
4968
  const filePath = path.join(
3225
4969
  resourceUseCaseDir(names, config),
3226
4970
  `create-${names.singularKebab}.ts`,
3227
4971
  );
3228
4972
 
3229
- 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)}";
3230
4975
  import {
3231
4976
  Create${names.singularPascal}InputSchema,
3232
4977
  ${names.singularPascal}Schema,
3233
- } from "./schemas";
4978
+ } from "../schemas";
4979
+ ${options.events ? `import { ${names.singularPascal}Created } from "../domain/events";\n` : ""}
3234
4980
 
3235
4981
  export const create${names.singularPascal}UseCase = useCase
3236
4982
  .command("${names.pluralCamel}.create")
3237
4983
  .input(Create${names.singularPascal}InputSchema)
3238
4984
  .output(${names.singularPascal}Schema)
3239
- .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
+ });
5027
+ `;
5028
+ }
5029
+
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` : ""} });
3240
5107
  `;
3241
5108
  }
3242
5109
 
3243
- function useCasesIndexFile(names: ResourceNames): string {
5110
+ function useCasesIndexFile(
5111
+ names: ResourceNames,
5112
+ mode: ResourceGenerationMode,
5113
+ ): string {
3244
5114
  return `export { create${names.singularPascal}UseCase } from "./create-${names.singularKebab}";
3245
- export { list${names.pluralPascal}UseCase } from "./list-${names.pluralKebab}";
3246
- 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 {
3247
5117
  Create${names.singularPascal}InputSchema,
3248
- List${names.pluralPascal}InputSchema,
5118
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n` : ""} List${names.pluralPascal}InputSchema,
3249
5119
  List${names.pluralPascal}OutputSchema,
3250
- ${names.singularPascal}Schema,
5120
+ ${mode === "resource" ? ` Update${names.singularPascal}BodySchema,\n Update${names.singularPascal}InputSchema,\n` : ""} ${names.singularPascal}Schema,
3251
5121
  type Create${names.singularPascal}Input,
3252
- 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,
3253
5123
  type ${names.singularPascal},
3254
- } from "./schemas";
5124
+ } from "../schemas";
3255
5125
  `;
3256
5126
  }
3257
5127
 
3258
5128
  function repositoryPortFile(
3259
5129
  names: ResourceNames,
3260
5130
  config: ResolvedBeignetConfig,
5131
+ mode: ResourceGenerationMode,
5132
+ options: ResourceGenerationOptions,
3261
5133
  ): string {
3262
5134
  return `import type {
5135
+ CursorPage,
5136
+ CursorPageInfo,
5137
+ PageResult,
5138
+ SortDirection,
5139
+ } from "@beignet/core/pagination";
5140
+ import type {
3263
5141
  Create${names.singularPascal}Input,
3264
- List${names.pluralPascal}Input,
3265
- ${names.singularPascal},
3266
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3267
-
3268
- export type List${names.pluralPascal}Result = {
3269
- ${names.pluralCamel}: ${names.singularPascal}[];
3270
- total: number;
3271
- };
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` : ""}` : ""}
3272
5156
 
3273
5157
  export interface ${names.singularPascal}Repository {
3274
- list(input: List${names.pluralPascal}Input): Promise<List${names.pluralPascal}Result>;
3275
- create(input: Create${names.singularPascal}Input): Promise<${names.singularPascal}>;
3276
- }
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` : ""}}
3277
5161
  `;
3278
5162
  }
3279
5163
 
3280
5164
  function inMemoryRepositoryFile(
3281
5165
  names: ResourceNames,
3282
5166
  config: ResolvedBeignetConfig,
5167
+ mode: ResourceGenerationMode,
5168
+ options: ResourceGenerationOptions,
3283
5169
  ): string {
3284
5170
  const repositoryPortPath = resourcePortFilePath(names, config);
5171
+ const recordType = options.softDelete
5172
+ ? `type ${names.singularPascal}Record = ${names.singularPascal} & {
5173
+ \tdeletedAt: string | null;
5174
+ };
3285
5175
 
3286
- return `import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
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";
5201
+ import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
5202
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
3287
5203
  import type {
3288
5204
  Create${names.singularPascal}Input,
3289
- List${names.pluralPascal}Input,
3290
- ${names.singularPascal},
3291
- } 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
+ }
3292
5257
 
3293
5258
  export function createInMemory${names.singularPascal}Repository(
3294
5259
  seed: ${names.singularPascal}[] = [],
3295
5260
  ): ${names.singularPascal}Repository {
3296
- 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
+ );
3297
5267
 
3298
5268
  return {
3299
- async list(input: List${names.pluralPascal}Input) {
3300
- const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values()).sort(
3301
- (left, right) => right.createdAt.localeCompare(left.createdAt),
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,
3302
5285
  );
3303
-
3304
- return {
3305
- ${names.pluralCamel}: all${names.pluralPascal}.slice(
3306
- input.offset,
3307
- input.offset + input.limit,
3308
- ),
3309
- total: all${names.pluralPascal}.length,
3310
- };
3311
5286
  },
3312
- async create(input: Create${names.singularPascal}Input) {
5287
+ async create(input) {
5288
+ const now = new Date().toISOString();
3313
5289
  const ${names.singularCamel}: ${names.singularPascal} = {
3314
5290
  id: crypto.randomUUID(),
3315
- name: input.name,
3316
- 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,
3317
5295
  };
3318
- ${names.pluralCamel}.set(${names.singularCamel}.id, ${names.singularCamel});
5296
+ ${names.pluralCamel}.set(${names.singularCamel}.id, {
5297
+ ...${names.singularCamel},
5298
+ ${newDeletedAtField} });
3319
5299
  return ${names.singularCamel};
3320
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` : ""}
3321
5302
  };
3322
5303
  }
3323
5304
  `;
3324
5305
  }
3325
5306
 
3326
- function drizzleSchemaFile(names: ResourceNames): string {
3327
- 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";
3328
5312
 
3329
5313
  export const ${names.pluralCamel} = sqliteTable("${names.pluralKebab.replaceAll("-", "_")}", {
3330
5314
  id: text("id").primaryKey(),
3331
- name: text("name").notNull(),
5315
+ ${options.tenant ? `\ttenantId: text("tenant_id").notNull(),\n` : ""} name: text("name").notNull(),
5316
+ version: integer("version").notNull(),
3332
5317
  createdAt: text("created_at").notNull(),
3333
- });
5318
+ updatedAt: text("updated_at").notNull(),
5319
+ ${options.softDelete ? `\tdeletedAt: text("deleted_at"),\n` : ""}});
3334
5320
  `;
3335
5321
  }
3336
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
+
3337
5340
  function drizzleRepositoryFile(
3338
5341
  names: ResourceNames,
3339
5342
  config: ResolvedBeignetConfig,
5343
+ mode: ResourceGenerationMode,
5344
+ options: ResourceGenerationOptions,
3340
5345
  ): string {
3341
5346
  const repositoryPortPath = resourcePortFilePath(names, config);
3342
5347
  const schemaPath = drizzleSchemaIndexPath(config);
3343
-
3344
- return `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
3345
- import { count, desc } from "drizzle-orm";
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";
5382
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
5383
+ import { ${drizzleImports.join(", ")} } from "drizzle-orm";
3346
5384
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
5385
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
3347
5386
  import type {
3348
5387
  Create${names.singularPascal}Input,
3349
- List${names.pluralPascal}Input,
3350
- ${names.singularPascal},
3351
- } 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))}";
3352
5390
  import * as schema from "${aliasModule(schemaPath)}";
3353
5391
 
3354
5392
  type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelect;
@@ -3356,38 +5394,104 @@ type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelec
3356
5394
  function to${names.singularPascal}(row: ${names.singularPascal}Row): ${names.singularPascal} {
3357
5395
  return {
3358
5396
  id: row.id,
3359
- name: row.name,
5397
+ ${options.tenant ? `\t\ttenantId: row.tenantId,\n` : ""} name: row.name,
5398
+ version: row.version,
3360
5399
  createdAt: row.createdAt,
5400
+ updatedAt: row.updatedAt,
3361
5401
  };
3362
5402
  }
3363
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
+
3364
5461
  export function createDrizzle${names.singularPascal}Repository(
3365
5462
  db: DrizzleTursoDatabase<typeof schema>,
3366
5463
  ): ${names.singularPascal}Repository {
3367
5464
  return {
3368
- async list(input: List${names.pluralPascal}Input) {
5465
+ async list(query) {
5466
+ const where = ${names.singularCamel}ListWhere(query);
3369
5467
  const rows = await db
3370
5468
  .select()
3371
5469
  .from(schema.${names.pluralCamel})
3372
- .orderBy(desc(schema.${names.pluralCamel}.createdAt))
3373
- .limit(input.limit)
3374
- .offset(input.offset);
3375
- const [{ total }] = await db
3376
- .select({ total: count() })
3377
- .from(schema.${names.pluralCamel});
3378
-
3379
- return {
3380
- ${names.pluralCamel}: rows.map(to${names.singularPascal}),
3381
- total,
3382
- };
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
+ );
3383
5484
  },
3384
- async create(input: Create${names.singularPascal}Input) {
5485
+ async create(input) {
5486
+ const now = new Date().toISOString();
3385
5487
  const [row] = await db
3386
5488
  .insert(schema.${names.pluralCamel})
3387
5489
  .values({
3388
5490
  id: crypto.randomUUID(),
3389
- name: input.name,
3390
- 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,
3391
5495
  })
3392
5496
  .returning();
3393
5497
 
@@ -3397,6 +5501,7 @@ export function createDrizzle${names.singularPascal}Repository(
3397
5501
 
3398
5502
  return to${names.singularPascal}(row);
3399
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` : ""}
3400
5505
  };
3401
5506
  }
3402
5507
  `;
@@ -3405,13 +5510,17 @@ export function createDrizzle${names.singularPascal}Repository(
3405
5510
  function contractFile(
3406
5511
  names: ResourceNames,
3407
5512
  config: ResolvedBeignetConfig,
5513
+ mode: ResourceGenerationMode,
5514
+ options: ResourceGenerationOptions,
3408
5515
  ): string {
3409
- return `import { createContractGroup } from "@beignet/core/contracts";
5516
+ return `import { defineContractGroup } from "@beignet/core/contracts";
3410
5517
  import { z } from "zod";
3411
- import {
3412
- create${names.singularPascal}UseCase,
3413
- list${names.pluralPascal}UseCase,
3414
- } 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))}";
3415
5524
 
3416
5525
  const ErrorResponseSchema = z.object({
3417
5526
  code: z.string(),
@@ -3419,7 +5528,7 @@ const ErrorResponseSchema = z.object({
3419
5528
  requestId: z.string().optional(),
3420
5529
  });
3421
5530
 
3422
- const ${names.pluralCamel} = createContractGroup()
5531
+ const ${names.pluralCamel} = defineContractGroup()
3423
5532
  .namespace("${names.pluralCamel}")
3424
5533
  .responses({
3425
5534
  500: ErrorResponseSchema,
@@ -3427,22 +5536,52 @@ const ${names.pluralCamel} = createContractGroup()
3427
5536
 
3428
5537
  export const list${names.pluralPascal} = ${names.pluralCamel}
3429
5538
  .get("/api/${names.pluralKebab}")
3430
- .query(list${names.pluralPascal}UseCase.inputSchema)
5539
+ .query(List${names.pluralPascal}InputSchema)
3431
5540
  .responses({
3432
- 200: list${names.pluralPascal}UseCase.outputSchema,
5541
+ 200: List${names.pluralPascal}OutputSchema,
3433
5542
  });
3434
5543
 
3435
5544
  export const create${names.singularPascal} = ${names.pluralCamel}
3436
5545
  .post("/api/${names.pluralKebab}")
3437
- .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 })
3438
5566
  .responses({
3439
- 201: create${names.singularPascal}UseCase.outputSchema,
5567
+ 200: ${names.singularPascal}Schema,
3440
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
+ }
3441
5580
  `;
3442
5581
  }
3443
5582
 
3444
5583
  function standaloneContractFile(names: ResourceNames): string {
3445
- return `import { createContractGroup } from "@beignet/core/contracts";
5584
+ return `import { defineContractGroup } from "@beignet/core/contracts";
3446
5585
  import { z } from "zod";
3447
5586
 
3448
5587
  const ErrorResponseSchema = z.object({
@@ -3451,7 +5590,7 @@ const ErrorResponseSchema = z.object({
3451
5590
  requestId: z.string().optional(),
3452
5591
  });
3453
5592
 
3454
- const ${names.pluralCamel} = createContractGroup()
5593
+ const ${names.pluralCamel} = defineContractGroup()
3455
5594
  .namespace("${names.pluralCamel}")
3456
5595
  .responses({
3457
5596
  500: ErrorResponseSchema,
@@ -3472,10 +5611,14 @@ export const list${names.pluralPascal} = ${names.pluralCamel}
3472
5611
  )
3473
5612
  .responses({
3474
5613
  200: z.object({
3475
- ${names.pluralCamel}: z.array(${names.singularCamel}Schema),
3476
- total: z.number().int().min(0),
3477
- limit: z.number().int().min(1),
3478
- offset: z.number().int().min(0),
5614
+ items: z.array(${names.singularCamel}Schema),
5615
+ page: z.object({
5616
+ kind: z.literal("offset"),
5617
+ limit: z.number().int().min(1),
5618
+ offset: z.number().int().min(0),
5619
+ total: z.number().int().min(0),
5620
+ hasMore: z.boolean(),
5621
+ }),
3479
5622
  }),
3480
5623
  });
3481
5624
 
@@ -3488,7 +5631,8 @@ function standaloneUseCaseFile(
3488
5631
  config: ResolvedBeignetConfig,
3489
5632
  filePath: string,
3490
5633
  ): string {
3491
- return `import { z } from "zod";
5634
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
5635
+ import { z } from "zod";
3492
5636
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3493
5637
 
3494
5638
  export const ${names.action.pascal}InputSchema = z.object({});
@@ -3521,29 +5665,27 @@ function useCaseTestFile(
3521
5665
  return `import { describe, expect, it } from "bun:test";
3522
5666
  import { createUseCaseTester } from "@beignet/core/application";
3523
5667
  import { createInMemoryDevtools } from "@beignet/devtools";
3524
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
5668
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
5669
+ import { createTestAnonymousActor } from "@beignet/core/ports/testing";
3525
5670
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3526
5671
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
3527
5672
  import { ${names.exportName} } from "${relativeModule(filePath, useCaseFilePath(names, config))}";
3528
5673
 
3529
5674
  describe("${names.exportName}", () => {
3530
5675
  it("runs ${names.action.camel}", async () => {
3531
- const testPorts = {
3532
- ...appPorts,
3533
- uow: createNoopUnitOfWork(
3534
- () => appPorts as unknown as AppContext["ports"],
3535
- ) as AppContext["ports"]["uow"],
3536
- devtools: createInMemoryDevtools(),
3537
- storage: createMemoryStorage(),
3538
- } as AppContext["ports"];
3539
- const actor = createAnonymousActor();
3540
- const tester = createUseCaseTester<AppContext>(() => ({
3541
- requestId: "test-request",
3542
- actor,
3543
- auth: null,
3544
- gate: testPorts.gate.bind({ actor, auth: null }),
3545
- ports: testPorts,
3546
- }));
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);
3547
5689
 
3548
5690
  const result = await tester.run(${names.exportName}, {});
3549
5691
 
@@ -3609,6 +5751,144 @@ export const ${names.policyName} = definePolicy({
3609
5751
  `;
3610
5752
  }
3611
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
+
3612
5892
  function eventFile(names: EventNames): string {
3613
5893
  return `import { defineEvent } from "@beignet/core/events";
3614
5894
  import { z } from "zod";
@@ -3626,11 +5906,9 @@ export const ${names.eventExportName} = defineEvent("${names.eventName}", {
3626
5906
  }
3627
5907
 
3628
5908
  function jobFile(names: JobNames, config: ResolvedBeignetConfig): string {
3629
- return `import { createJobHandlers } from "@beignet/core/jobs";
5909
+ return `import { retry } from "@beignet/core/jobs";
3630
5910
  import { z } from "zod";
3631
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3632
-
3633
- const jobs = createJobHandlers<AppContext>();
5911
+ import { defineJob } from "${aliasModule(config.paths.jobsBuilder)}";
3634
5912
 
3635
5913
  export const ${names.payloadSchemaName} = z.object({
3636
5914
  \tid: z.string().uuid(),
@@ -3638,11 +5916,13 @@ export const ${names.payloadSchemaName} = z.object({
3638
5916
 
3639
5917
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
3640
5918
 
3641
- export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
5919
+ export const ${names.jobExportName} = defineJob("${names.jobName}", {
3642
5920
  \tpayload: ${names.payloadSchemaName},
3643
- \tretry: {
5921
+ \tretry: retry.exponential({
3644
5922
  \t\tattempts: 3,
3645
- \t},
5923
+ \t\tinitialDelay: "1s",
5924
+ \t\tmaxDelay: "1m",
5925
+ \t}),
3646
5926
  \tasync handle({ payload, ctx }) {
3647
5927
  \t\tctx.ports.logger.info("Job handled", {
3648
5928
  \t\t\tjobName: "${names.jobName}",
@@ -3653,19 +5933,110 @@ export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
3653
5933
  `;
3654
5934
  }
3655
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
+
5963
+ function factoryFile(
5964
+ names: FactoryNames,
5965
+ config: ResolvedBeignetConfig,
5966
+ ): string {
5967
+ const resource = featureResourceNames(names);
5968
+
5969
+ return `import { createFactory } from "@beignet/core/testing";
5970
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
5971
+
5972
+ export const ${names.factoryExportName} = createFactory("${names.factoryName}", {
5973
+ \tdefaults: ({ sequence }) => ({
5974
+ \t\tname: "${resource.singularPascal} " + sequence,
5975
+ \t}),
5976
+ \tpersist: (ctx: AppContext, input) => ctx.ports.${resource.pluralCamel}.create(input),
5977
+ });
5978
+ `;
5979
+ }
5980
+
5981
+ function seedFile(names: SeedNames, config: ResolvedBeignetConfig): string {
5982
+ const resource = featureResourceNames(names);
5983
+
5984
+ return `import { defineSeed } from "@beignet/core/testing";
5985
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
5986
+
5987
+ export const ${names.seedExportName} = defineSeed("${names.seedName}", {
5988
+ \tdescription: "Creates starter ${names.feature.kebab} data.",
5989
+ \trun: async (ctx: AppContext) => {
5990
+ \t\tawait ctx.ports.${resource.pluralCamel}.create({
5991
+ \t\t\tname: "Demo ${resource.singularPascal}",
5992
+ \t\t});
5993
+ \t},
5994
+ });
5995
+ `;
5996
+ }
5997
+
5998
+ function notificationFile(
5999
+ names: NotificationNames,
6000
+ config: ResolvedBeignetConfig,
6001
+ ): string {
6002
+ return `import { defineMailNotificationChannel } from "@beignet/core/notifications";
6003
+ import { z } from "zod";
6004
+ import { defineNotification } from "${aliasModule(config.paths.notificationsBuilder)}";
6005
+
6006
+ export const ${names.payloadSchemaName} = z.object({
6007
+ \tid: z.string().uuid(),
6008
+ \tsubject: z.string(),
6009
+ \trecipientEmail: z.string().email(),
6010
+ });
6011
+
6012
+ export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
6013
+
6014
+ export const ${names.notificationExportName} = defineNotification(
6015
+ \t"${names.notificationName}",
6016
+ \t{
6017
+ \t\tpayload: ${names.payloadSchemaName},
6018
+ \t\tchannels: {
6019
+ \t\t\temail: defineMailNotificationChannel(({ payload }) => ({
6020
+ \t\t\t\tto: payload.recipientEmail,
6021
+ \t\t\t\tsubject: payload.subject,
6022
+ \t\t\t\ttext: \`${names.notificationName} notification for \${payload.id}.\`,
6023
+ \t\t\t})),
6024
+ \t\t},
6025
+ \t},
6026
+ );
6027
+ `;
6028
+ }
6029
+
3656
6030
  function listenerFile(
3657
6031
  names: ListenerNames,
3658
6032
  config: ResolvedBeignetConfig,
3659
6033
  ): string {
3660
6034
  const filePath = listenerFilePath(names, config);
3661
6035
 
3662
- return `import { createEventHandlers } from "@beignet/core/events";
3663
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6036
+ return `import { defineListener } from "${aliasModule(config.paths.listenersBuilder)}";
3664
6037
  import { ${names.event.eventExportName} } from "${relativeModule(filePath, eventFilePath(names.event, config))}";
3665
6038
 
3666
- const events = createEventHandlers<AppContext>();
3667
-
3668
- export const ${names.listenerExportName} = events.defineListener(${names.event.eventExportName}, {
6039
+ export const ${names.listenerExportName} = defineListener(${names.event.eventExportName}, {
3669
6040
  \tname: "${names.listenerName}",
3670
6041
  \tasync handle({ payload, ctx }) {
3671
6042
  \t\tctx.ports.logger.info("Listener handled", {
@@ -3681,11 +6052,8 @@ function scheduleFile(
3681
6052
  names: ScheduleNames,
3682
6053
  config: ResolvedBeignetConfig,
3683
6054
  ): string {
3684
- return `import { createScheduleHandlers } from "@beignet/core/schedules";
3685
- import { z } from "zod";
3686
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3687
-
3688
- const schedules = createScheduleHandlers<AppContext>();
6055
+ return `import { z } from "zod";
6056
+ import { defineSchedule } from "${aliasModule(config.paths.schedulesBuilder)}";
3689
6057
 
3690
6058
  export const ${names.payloadSchemaName} = z.object({
3691
6059
  \tdate: z.string(),
@@ -3693,7 +6061,7 @@ export const ${names.payloadSchemaName} = z.object({
3693
6061
 
3694
6062
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
3695
6063
 
3696
- export const ${names.scheduleExportName} = schedules.defineSchedule(
6064
+ export const ${names.scheduleExportName} = defineSchedule(
3697
6065
  \t"${names.scheduleName}",
3698
6066
  \t{
3699
6067
  \t\tcron: "${names.cron}",
@@ -3716,111 +6084,174 @@ ${names.timezone ? `\t\ttimezone: "${names.timezone}",\n` : ""}\t\tpayload: ${na
3716
6084
  `;
3717
6085
  }
3718
6086
 
6087
+ function uploadFile(names: UploadNames, config: ResolvedBeignetConfig): string {
6088
+ return `import { defineUpload } from "@beignet/core/uploads";
6089
+ import { z } from "zod";
6090
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6091
+
6092
+ export const ${names.metadataSchemaName} = z.object({
6093
+ \tresourceId: z.string().min(1),
6094
+ });
6095
+
6096
+ export type ${names.metadataTypeName} = z.infer<typeof ${names.metadataSchemaName}>;
6097
+
6098
+ export const ${names.uploadExportName} = defineUpload<
6099
+ \t"${names.uploadName}",
6100
+ \ttypeof ${names.metadataSchemaName},
6101
+ \tAppContext,
6102
+ \t{ objectKeys: string[] }
6103
+ >("${names.uploadName}", {
6104
+ \tmetadata: ${names.metadataSchemaName},
6105
+ \tfile: {
6106
+ \t\tcontentTypes: ["application/pdf", "text/plain"],
6107
+ \t\tmaxSizeBytes: 5 * 1024 * 1024,
6108
+ \t\tmaxFiles: 1,
6109
+ \t\tvisibility: "private",
6110
+ \t\tcacheControl: "private, max-age=0",
6111
+ \t},
6112
+ \tauthorize({ ctx }) {
6113
+ \t\treturn ctx.actor.type === "user";
6114
+ \t},
6115
+ \tkey({ ctx, metadata, uploadId, file }) {
6116
+ \t\tconst tenantId = ctx.tenant?.id ?? "default";
6117
+ \t\tconst extension = file.name.includes(".") ? file.name.split(".").pop() : undefined;
6118
+ \t\tconst suffix = extension ? \`.\${extension}\` : "";
6119
+ \t\treturn "${names.feature.kebab}/" + tenantId + "/" + metadata.resourceId + "/" + uploadId + suffix;
6120
+ \t},
6121
+ \tstorageMetadata({ ctx, metadata }) {
6122
+ \t\treturn {
6123
+ \t\t\ttenantId: ctx.tenant?.id ?? "default",
6124
+ \t\t\tresourceId: metadata.resourceId,
6125
+ \t\t};
6126
+ \t},
6127
+ \tasync onComplete({ ctx, metadata, files }) {
6128
+ \t\tctx.ports.logger.info("Upload completed", {
6129
+ \t\t\tuploadName: "${names.uploadName}",
6130
+ \t\t\tresourceId: metadata.resourceId,
6131
+ \t\t\tobjectKeys: files.map((file) => file.key),
6132
+ \t\t});
6133
+
6134
+ \t\treturn {
6135
+ \t\t\tobjectKeys: files.map((file) => file.key),
6136
+ \t\t};
6137
+ \t},
6138
+ });
6139
+ `;
6140
+ }
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
+
3719
6237
  function scheduleRouteFile(
3720
6238
  names: ScheduleNames,
3721
6239
  config: ResolvedBeignetConfig,
3722
6240
  ): string {
3723
- return `import { createInlineScheduleRunner } from "@beignet/core/schedules";
3724
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6241
+ return `import { createScheduleRoute } from "@beignet/next";
3725
6242
  import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
3726
6243
  import { env } from "@/lib/env";
3727
6244
  import { server } from "${aliasModule(config.paths.server)}";
3728
6245
 
3729
- async function run${names.artifact.pascal}(request: Request) {
3730
- \tconst cronSecret = env.CRON_SECRET;
6246
+ export const runtime = "nodejs";
3731
6247
 
3732
- \tif (!cronSecret) {
3733
- \t\treturn Response.json(
3734
- \t\t\t{
3735
- \t\t\t\tok: false,
3736
- \t\t\t\terror: "CRON_SECRET is not configured.",
3737
- \t\t\t\tscheduleName: ${names.scheduleExportName}.name,
3738
- \t\t\t},
3739
- \t\t\t{ status: 500 },
3740
- \t\t);
3741
- \t}
3742
-
3743
- \tif (
3744
- \t\trequest.headers.get("authorization") !== \`Bearer \${cronSecret}\`
3745
- \t) {
3746
- \t\treturn Response.json({ error: "Unauthorized" }, { status: 401 });
3747
- \t}
3748
-
3749
- \tconst ctx = await server.createContextFromNext();
3750
- \tconst runner = createInlineScheduleRunner<AppContext>({
3751
- \t\tctx,
3752
- \t\tonStart({ run, schedule }) {
3753
- \t\t\tctx.ports.devtools.record({
3754
- \t\t\t\ttype: "schedule",
3755
- \t\t\t\twatcher: "schedules",
3756
- \t\t\t\trequestId: ctx.requestId,
3757
- \t\t\t\tscheduleName: schedule.name,
3758
- \t\t\t\tstatus: "started",
3759
- \t\t\t\tcron: schedule.cron,
3760
- \t\t\t\ttimezone: schedule.timezone,
3761
- \t\t\t\tdetails: {
3762
- \t\t\t\t\tsource: run.source,
3763
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3764
- \t\t\t\t},
3765
- \t\t\t});
3766
- \t\t},
3767
- \t\tonSuccess({ run, schedule }) {
3768
- \t\t\tctx.ports.devtools.record({
3769
- \t\t\t\ttype: "schedule",
3770
- \t\t\t\twatcher: "schedules",
3771
- \t\t\t\trequestId: ctx.requestId,
3772
- \t\t\t\tscheduleName: schedule.name,
3773
- \t\t\t\tstatus: "completed",
3774
- \t\t\t\tcron: schedule.cron,
3775
- \t\t\t\ttimezone: schedule.timezone,
3776
- \t\t\t\tdetails: {
3777
- \t\t\t\t\tsource: run.source,
3778
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3779
- \t\t\t\t},
3780
- \t\t\t});
3781
- \t\t},
3782
- \t\tonError({ error, run, schedule }) {
3783
- \t\t\tctx.ports.devtools.record({
3784
- \t\t\t\ttype: "schedule",
3785
- \t\t\t\twatcher: "schedules",
3786
- \t\t\t\trequestId: ctx.requestId,
3787
- \t\t\t\tscheduleName: schedule.name,
3788
- \t\t\t\tstatus: "failed",
3789
- \t\t\t\tcron: schedule.cron,
3790
- \t\t\t\ttimezone: schedule.timezone,
3791
- \t\t\t\tdetails: {
3792
- \t\t\t\t\terror,
3793
- \t\t\t\t\tsource: run.source,
3794
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3795
- \t\t\t\t},
3796
- \t\t\t});
3797
- \t\t\tctx.ports.logger.error("Schedule failed", {
3798
- \t\t\t\terror,
3799
- \t\t\t\tscheduleName: schedule.name,
3800
- \t\t\t});
3801
- \t\t},
3802
- \t\tonHookError({ error, hook, schedule }) {
3803
- \t\t\tctx.ports.logger.warn("Schedule lifecycle hook failed", {
3804
- \t\t\t\terror,
3805
- \t\t\t\thook,
3806
- \t\t\t\tscheduleName: schedule.name,
3807
- \t\t\t});
3808
- \t\t},
3809
- \t});
3810
-
3811
- \ttry {
3812
- \t\tawait runner.run(${names.scheduleExportName}, {
3813
- \t\t\tsource: "cron-route",
3814
- \t\t});
3815
- \t} catch {
3816
- \t\treturn Response.json({ error: "Schedule failed" }, { status: 500 });
3817
- \t}
3818
-
3819
- \treturn Response.json({ ok: true });
3820
- }
3821
-
3822
- export const GET = run${names.artifact.pascal};
3823
- 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
+ });
3824
6255
  `;
3825
6256
  }
3826
6257
 
@@ -3838,79 +6269,161 @@ function useCaseIndexExport(names: UseCaseNames): string {
3838
6269
  function routeGroupFile(
3839
6270
  names: ResourceNames,
3840
6271
  config: ResolvedBeignetConfig,
6272
+ mode: ResourceGenerationMode,
6273
+ _options: ResourceGenerationOptions,
3841
6274
  ): string {
3842
- return `import { defineRouteGroup } from "@beignet/next";
3843
- import type { AppContext } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), config.paths.appContext)}";
3844
- 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)}";
3845
6290
  import {
3846
6291
  create${names.singularPascal}UseCase,
3847
- list${names.pluralPascal}UseCase,
3848
- } 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)}";
3849
6294
 
3850
6295
  export const ${names.singularCamel}Routes = defineRouteGroup<AppContext>({
3851
6296
  name: "${names.pluralKebab}",
3852
6297
  routes: [
3853
- {
3854
- contract: list${names.pluralPascal},
3855
- handle: async ({ ctx, query }) => ({
3856
- status: 200,
3857
- body: await list${names.pluralPascal}UseCase.run({ ctx, input: query }),
3858
- }),
3859
- },
3860
- {
3861
- contract: create${names.singularPascal},
3862
- handle: async ({ ctx, body }) => ({
3863
- status: 201,
3864
- body: await create${names.singularPascal}UseCase.run({ ctx, input: body }),
3865
- }),
3866
- },
3867
- ],
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` : ""} ],
3868
6301
  });
3869
6302
  `;
3870
6303
  }
3871
6304
 
3872
- function testFile(names: ResourceNames, config: ResolvedBeignetConfig): string {
6305
+ function testFile(
6306
+ names: ResourceNames,
6307
+ config: ResolvedBeignetConfig,
6308
+ mode: ResourceGenerationMode,
6309
+ options: ResourceGenerationOptions,
6310
+ ): string {
3873
6311
  const repositoryPath = path.join(
3874
6312
  path.dirname(config.paths.infrastructurePorts),
3875
6313
  names.pluralKebab,
3876
6314
  `in-memory-${names.singularKebab}-repository.ts`,
3877
6315
  );
6316
+ const serverContextPath = path.join(
6317
+ path.dirname(config.paths.server),
6318
+ "context.ts",
6319
+ );
6320
+ const requestTarget = options.tenant ? "requester" : "app";
3878
6321
 
3879
6322
  return `import { describe, expect, it } from "bun:test";
3880
6323
  import { createUseCaseTester } from "@beignet/core/application";
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";
3881
6326
  import { createInMemoryDevtools } from "@beignet/devtools";
3882
- 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";
3883
6329
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3884
6330
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
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))}";
3885
6336
  import { createInMemory${names.singularPascal}Repository } from "${aliasModule(repositoryPath)}";
6337
+ import { ${names.singularCamel}Routes } from "${aliasModule(path.join(resourceFeatureDir(names, config), "routes.ts"))}";
3886
6338
  import {
3887
6339
  create${names.singularPascal}UseCase,
3888
- list${names.pluralPascal}UseCase,
3889
- } 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))}";
3890
6342
 
3891
6343
  describe("${names.pluralCamel} resource", () => {
3892
- it("creates and lists ${names.pluralCamel}", async () => {
3893
- const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();
3894
- const testPorts = {
3895
- ...appPorts,
3896
- ${names.pluralCamel},
3897
- uow: createNoopUnitOfWork(() => ({
3898
- ...(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: {
3899
6372
  ${names.pluralCamel},
3900
- })) as AppContext["ports"]["uow"],
3901
- devtools: createInMemoryDevtools(),
3902
- storage: createMemoryStorage(),
3903
- } as AppContext["ports"];
3904
- const actor = createAnonymousActor();
3905
- const tester = createUseCaseTester<AppContext>(() => ({
3906
- requestId: "test-request",
3907
- actor,
3908
- auth: null,
3909
- gate: testPorts.gate.bind({ actor, auth: null }),
3910
- ports: testPorts,
3911
- }));
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"},
6397
+ });
6398
+ const tester = createUseCaseTester<AppContext>(createTestContext);
3912
6399
 
3913
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
+ );
3914
6427
  const created = await tester.run(
3915
6428
  create${names.singularPascal}UseCase,
3916
6429
  { name: "First ${names.singularPascal}" },
@@ -3918,13 +6431,113 @@ describe("${names.pluralCamel} resource", () => {
3918
6431
  );
3919
6432
  const result = await tester.run(
3920
6433
  list${names.pluralPascal}UseCase,
3921
- { limit: 20, offset: 0 },
6434
+ { name: "First" },
3922
6435
  { ctx },
3923
6436
  );
3924
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
+ ]);
3925
6472
  expect(created.name).toBe("First ${names.singularPascal}");
3926
- expect(result.total).toBe(1);
3927
- expect(result.${names.pluralCamel}).toEqual([created]);
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
+ }
6524
+
6525
+ const app = await createTestApp({
6526
+ ports: testFixture.ports,
6527
+ routes: defineRoutes<AppContext>([${names.singularCamel}Routes]),
6528
+ context: appContext,
6529
+ });
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}, {
6532
+ body: { name: "Second ${names.singularPascal}" },
6533
+ });
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"}" },
6536
+ });
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();
6538
+
6539
+ expect(createdViaRoute.name).toBe("Second ${names.singularPascal}");
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`}
3928
6541
  });
3929
6542
  });
3930
6543
  `;