@beignet/cli 0.0.1 → 0.0.3

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.
package/src/make.ts CHANGED
@@ -17,6 +17,31 @@ type MakeResourceOptions = {
17
17
  config?: BeignetConfig;
18
18
  };
19
19
 
20
+ export const makeFeatureAddonChoices = [
21
+ "policy",
22
+ "event",
23
+ "events",
24
+ "job",
25
+ "jobs",
26
+ "notification",
27
+ "notifications",
28
+ "upload",
29
+ "uploads",
30
+ ] as const;
31
+
32
+ export type MakeFeatureAddon = (typeof makeFeatureAddonChoices)[number];
33
+
34
+ type NormalizedMakeFeatureAddon =
35
+ | "policy"
36
+ | "event"
37
+ | "job"
38
+ | "notification"
39
+ | "upload";
40
+
41
+ type MakeFeatureOptions = MakeResourceOptions & {
42
+ with?: readonly MakeFeatureAddon[];
43
+ };
44
+
20
45
  type MakeContractOptions = {
21
46
  name: string;
22
47
  cwd?: string;
@@ -81,6 +106,30 @@ type MakeJobOptions = {
81
106
  config?: BeignetConfig;
82
107
  };
83
108
 
109
+ type MakeFactoryOptions = {
110
+ name: string;
111
+ cwd?: string;
112
+ force?: boolean;
113
+ dryRun?: boolean;
114
+ config?: BeignetConfig;
115
+ };
116
+
117
+ type MakeSeedOptions = {
118
+ name: string;
119
+ cwd?: string;
120
+ force?: boolean;
121
+ dryRun?: boolean;
122
+ config?: BeignetConfig;
123
+ };
124
+
125
+ type MakeNotificationOptions = {
126
+ name: string;
127
+ cwd?: string;
128
+ force?: boolean;
129
+ dryRun?: boolean;
130
+ config?: BeignetConfig;
131
+ };
132
+
84
133
  type MakeListenerOptions = {
85
134
  name: string;
86
135
  event: string;
@@ -101,6 +150,14 @@ type MakeScheduleOptions = {
101
150
  route?: boolean;
102
151
  };
103
152
 
153
+ type MakeUploadOptions = {
154
+ name: string;
155
+ cwd?: string;
156
+ force?: boolean;
157
+ dryRun?: boolean;
158
+ config?: BeignetConfig;
159
+ };
160
+
104
161
  type MakeResult = {
105
162
  name: string;
106
163
  targetDir: string;
@@ -111,17 +168,70 @@ type MakeResult = {
111
168
  skippedFiles: string[];
112
169
  };
113
170
 
171
+ /**
172
+ * Result returned by `beignet make resource`.
173
+ */
114
174
  export type MakeResourceResult = MakeResult;
175
+ /**
176
+ * Result returned by `beignet make feature`.
177
+ */
178
+ export type MakeFeatureResult = MakeResult;
179
+ /**
180
+ * Result returned by `beignet make contract`.
181
+ */
115
182
  export type MakeContractResult = MakeResult;
183
+ /**
184
+ * Result returned by `beignet make use-case`.
185
+ */
116
186
  export type MakeUseCaseResult = MakeResult;
187
+ /**
188
+ * Result returned by `beignet make test`.
189
+ */
117
190
  export type MakeTestResult = MakeResult;
191
+ /**
192
+ * Result returned by `beignet make port`.
193
+ */
118
194
  export type MakePortResult = MakeResult;
195
+ /**
196
+ * Result returned by `beignet make adapter`.
197
+ */
119
198
  export type MakeAdapterResult = MakeResult;
199
+ /**
200
+ * Result returned by `beignet make policy`.
201
+ */
120
202
  export type MakePolicyResult = MakeResult;
203
+ /**
204
+ * Result returned by `beignet make event`.
205
+ */
121
206
  export type MakeEventResult = MakeResult;
207
+ /**
208
+ * Result returned by `beignet make job`.
209
+ */
122
210
  export type MakeJobResult = MakeResult;
211
+ /**
212
+ * Result returned by `beignet make factory`.
213
+ */
214
+ export type MakeFactoryResult = MakeResult;
215
+ /**
216
+ * Result returned by `beignet make seed`.
217
+ */
218
+ export type MakeSeedResult = MakeResult;
219
+ /**
220
+ * Result returned by `beignet make notification`.
221
+ */
222
+ export type MakeNotificationResult = MakeResult;
223
+ /**
224
+ * Result returned by `beignet make listener`.
225
+ */
123
226
  export type MakeListenerResult = MakeResult;
227
+ /**
228
+ * Result returned by `beignet make schedule`.
229
+ */
124
230
  export type MakeScheduleResult = MakeResult;
231
+ /**
232
+ * Result returned by `beignet make upload`.
233
+ */
234
+ export type MakeUploadResult = MakeResult;
125
235
 
126
236
  type ResourceNames = {
127
237
  input: string;
@@ -190,6 +300,23 @@ type JobNames = FeatureArtifactNames & {
190
300
  payloadTypeName: string;
191
301
  };
192
302
 
303
+ type FactoryNames = FeatureArtifactNames & {
304
+ factoryName: string;
305
+ factoryExportName: string;
306
+ };
307
+
308
+ type SeedNames = FeatureArtifactNames & {
309
+ seedName: string;
310
+ seedExportName: string;
311
+ };
312
+
313
+ type NotificationNames = FeatureArtifactNames & {
314
+ notificationName: string;
315
+ notificationExportName: string;
316
+ payloadSchemaName: string;
317
+ payloadTypeName: string;
318
+ };
319
+
193
320
  type ListenerNames = FeatureArtifactNames & {
194
321
  listenerName: string;
195
322
  listenerExportName: string;
@@ -205,6 +332,13 @@ type ScheduleNames = FeatureArtifactNames & {
205
332
  timezone?: string;
206
333
  };
207
334
 
335
+ type UploadNames = FeatureArtifactNames & {
336
+ uploadName: string;
337
+ uploadExportName: string;
338
+ metadataSchemaName: string;
339
+ metadataTypeName: string;
340
+ };
341
+
208
342
  export async function makeResource(
209
343
  options: MakeResourceOptions,
210
344
  ): Promise<MakeResourceResult> {
@@ -266,6 +400,119 @@ export async function makeResource(
266
400
  };
267
401
  }
268
402
 
403
+ export async function makeFeature(
404
+ options: MakeFeatureOptions,
405
+ ): Promise<MakeFeatureResult> {
406
+ const addons = normalizeMakeFeatureAddons(options.with ?? []);
407
+ if (addons.length === 0) {
408
+ return makeFeatureResource(options);
409
+ }
410
+
411
+ if (options.dryRun) {
412
+ return makeFeatureSlice(options, addons);
413
+ }
414
+
415
+ await makeFeatureSlice({ ...options, dryRun: true }, addons);
416
+ return makeFeatureSlice(options, addons);
417
+ }
418
+
419
+ async function makeFeatureResource(
420
+ options: MakeResourceOptions,
421
+ ): Promise<MakeResourceResult> {
422
+ try {
423
+ return await makeResource(options);
424
+ } catch (error) {
425
+ if (error instanceof Error && error.message.includes("make resource")) {
426
+ throw new Error(
427
+ error.message.replaceAll("make resource", "make feature"),
428
+ );
429
+ }
430
+ throw error;
431
+ }
432
+ }
433
+
434
+ async function makeFeatureSlice(
435
+ options: MakeFeatureOptions,
436
+ addons: readonly NormalizedMakeFeatureAddon[],
437
+ ): Promise<MakeFeatureResult> {
438
+ let result = await makeFeatureResource(options);
439
+
440
+ for (const addon of addons) {
441
+ result = mergeMakeResults(
442
+ result,
443
+ await makeFeatureAddon(addon, result.name, options),
444
+ );
445
+ }
446
+
447
+ return result;
448
+ }
449
+
450
+ async function makeFeatureAddon(
451
+ addon: NormalizedMakeFeatureAddon,
452
+ feature: string,
453
+ options: MakeFeatureOptions,
454
+ ): Promise<MakeResult> {
455
+ const shared = {
456
+ cwd: options.cwd,
457
+ force: options.force,
458
+ dryRun: options.dryRun,
459
+ config: options.config,
460
+ };
461
+
462
+ if (addon === "policy") {
463
+ return makePolicy({ ...shared, name: feature });
464
+ }
465
+ if (addon === "event") {
466
+ return makeEvent({ ...shared, name: `${feature}/created` });
467
+ }
468
+ if (addon === "job") {
469
+ return makeJob({ ...shared, name: `${feature}/process` });
470
+ }
471
+ if (addon === "notification") {
472
+ return makeNotification({ ...shared, name: `${feature}/created` });
473
+ }
474
+
475
+ return makeUpload({ ...shared, name: `${feature}/attachment` });
476
+ }
477
+
478
+ function normalizeMakeFeatureAddons(
479
+ addons: readonly MakeFeatureAddon[],
480
+ ): NormalizedMakeFeatureAddon[] {
481
+ const normalized = new Set<NormalizedMakeFeatureAddon>();
482
+
483
+ for (const addon of addons) {
484
+ if (addon === "events") normalized.add("event");
485
+ else if (addon === "jobs") normalized.add("job");
486
+ else if (addon === "notifications") normalized.add("notification");
487
+ else if (addon === "uploads") normalized.add("upload");
488
+ else normalized.add(addon);
489
+ }
490
+
491
+ return featureAddonOrder.filter((addon) => normalized.has(addon));
492
+ }
493
+
494
+ const featureAddonOrder: readonly NormalizedMakeFeatureAddon[] = [
495
+ "policy",
496
+ "event",
497
+ "job",
498
+ "notification",
499
+ "upload",
500
+ ];
501
+
502
+ function mergeMakeResults(left: MakeResult, right: MakeResult): MakeResult {
503
+ return {
504
+ ...left,
505
+ files: uniqueStrings([...left.files, ...right.files]),
506
+ createdFiles: uniqueStrings([...left.createdFiles, ...right.createdFiles]),
507
+ updatedFiles: uniqueStrings([...left.updatedFiles, ...right.updatedFiles]),
508
+ skippedFiles: uniqueStrings([...left.skippedFiles, ...right.skippedFiles]),
509
+ };
510
+ }
511
+
512
+ function uniqueStrings(values: readonly string[]): string[] {
513
+ return [...new Set(values)];
514
+ }
515
+
269
516
  export async function makeContract(
270
517
  options: MakeContractOptions,
271
518
  ): Promise<MakeContractResult> {
@@ -371,7 +618,11 @@ export async function makeTest(
371
618
  if (result === "skipped") skippedFiles.push(file.path);
372
619
  }
373
620
 
374
- if (await updatePackageJson(targetDir, { dryRun: Boolean(options.dryRun) })) {
621
+ if (
622
+ await updatePackageJson(targetDir, {
623
+ dryRun: Boolean(options.dryRun),
624
+ })
625
+ ) {
375
626
  updatedFiles.push("package.json");
376
627
  }
377
628
 
@@ -578,6 +829,81 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
578
829
  });
579
830
  }
580
831
 
832
+ export async function makeFactory(
833
+ options: MakeFactoryOptions,
834
+ ): Promise<MakeFactoryResult> {
835
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
836
+ const names = factoryNames(options.name);
837
+ const config = options.config
838
+ ? resolveConfig(options.config)
839
+ : await loadBeignetConfig(targetDir);
840
+
841
+ await assertPersistenceArtifactApp(targetDir, names, config, "factory");
842
+
843
+ return makeFeatureArtifact({
844
+ targetDir,
845
+ config,
846
+ force: Boolean(options.force),
847
+ dryRun: Boolean(options.dryRun),
848
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
849
+ files: factoryFiles(names, config),
850
+ index: featureArtifactIndexFile("factory", names, config),
851
+ dependencies: {
852
+ "@beignet/core": beignetDependencyVersion,
853
+ },
854
+ });
855
+ }
856
+
857
+ export async function makeSeed(
858
+ options: MakeSeedOptions,
859
+ ): Promise<MakeSeedResult> {
860
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
861
+ const names = seedNames(options.name);
862
+ const config = options.config
863
+ ? resolveConfig(options.config)
864
+ : await loadBeignetConfig(targetDir);
865
+
866
+ await assertPersistenceArtifactApp(targetDir, names, config, "seed");
867
+
868
+ return makeFeatureArtifact({
869
+ targetDir,
870
+ config,
871
+ force: Boolean(options.force),
872
+ dryRun: Boolean(options.dryRun),
873
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
874
+ files: seedFiles(names, config),
875
+ index: featureArtifactIndexFile("seed", names, config),
876
+ dependencies: {
877
+ "@beignet/core": beignetDependencyVersion,
878
+ },
879
+ });
880
+ }
881
+
882
+ export async function makeNotification(
883
+ options: MakeNotificationOptions,
884
+ ): Promise<MakeNotificationResult> {
885
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
886
+ const names = notificationNames(options.name);
887
+ const config = options.config
888
+ ? resolveConfig(options.config)
889
+ : await loadBeignetConfig(targetDir);
890
+
891
+ await assertFeatureArtifactApp(targetDir, config, "notification");
892
+
893
+ return makeFeatureArtifact({
894
+ targetDir,
895
+ config,
896
+ force: Boolean(options.force),
897
+ dryRun: Boolean(options.dryRun),
898
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
899
+ files: notificationFiles(names, config),
900
+ index: featureArtifactIndexFile("notification", names, config),
901
+ dependencies: {
902
+ "@beignet/core": beignetDependencyVersion,
903
+ },
904
+ });
905
+ }
906
+
581
907
  export async function makeListener(
582
908
  options: MakeListenerOptions,
583
909
  ): Promise<MakeListenerResult> {
@@ -652,6 +978,31 @@ export async function makeSchedule(
652
978
  });
653
979
  }
654
980
 
981
+ export async function makeUpload(
982
+ options: MakeUploadOptions,
983
+ ): Promise<MakeUploadResult> {
984
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
985
+ const names = uploadNames(options.name);
986
+ const config = options.config
987
+ ? resolveConfig(options.config)
988
+ : await loadBeignetConfig(targetDir);
989
+
990
+ await assertFeatureArtifactApp(targetDir, config, "upload");
991
+
992
+ return makeFeatureArtifact({
993
+ targetDir,
994
+ config,
995
+ force: Boolean(options.force),
996
+ dryRun: Boolean(options.dryRun),
997
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
998
+ files: uploadFiles(names, config),
999
+ index: featureArtifactIndexFile("upload", names, config),
1000
+ dependencies: {
1001
+ "@beignet/core": beignetDependencyVersion,
1002
+ },
1003
+ });
1004
+ }
1005
+
655
1006
  async function assertStandardApp(
656
1007
  targetDir: string,
657
1008
  config: ResolvedBeignetConfig,
@@ -777,7 +1128,13 @@ async function assertPolicyApp(
777
1128
  async function assertFeatureArtifactApp(
778
1129
  targetDir: string,
779
1130
  config: ResolvedBeignetConfig,
780
- artifact: "event" | "job" | "listener" | "schedule",
1131
+ artifact:
1132
+ | "event"
1133
+ | "job"
1134
+ | "notification"
1135
+ | "listener"
1136
+ | "schedule"
1137
+ | "upload",
781
1138
  options: { requireServer?: boolean } = {},
782
1139
  ): Promise<void> {
783
1140
  const requiredFiles = [
@@ -796,6 +1153,29 @@ async function assertFeatureArtifactApp(
796
1153
  }
797
1154
  }
798
1155
 
1156
+ async function assertPersistenceArtifactApp(
1157
+ targetDir: string,
1158
+ names: FactoryNames | SeedNames,
1159
+ config: ResolvedBeignetConfig,
1160
+ artifact: "factory" | "seed",
1161
+ ): Promise<void> {
1162
+ const requiredFiles = [
1163
+ config.paths.appContext,
1164
+ config.paths.ports,
1165
+ resourcePortFilePath(featureResourceNames(names), config),
1166
+ ];
1167
+
1168
+ for (const file of requiredFiles) {
1169
+ try {
1170
+ await stat(path.join(targetDir, file));
1171
+ } catch {
1172
+ throw new Error(
1173
+ `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.`,
1174
+ );
1175
+ }
1176
+ }
1177
+ }
1178
+
799
1179
  type WriteGeneratedFileOptions = {
800
1180
  force: boolean;
801
1181
  dryRun: boolean;
@@ -996,11 +1376,21 @@ async function updateResourceWiring(
996
1376
  ? (maybeOptions as { dryRun: boolean })
997
1377
  : persistenceOrOptions;
998
1378
  const updated = new Set<string>();
999
- if (await updatePackageJson(targetDir, options)) updated.add("package.json");
1379
+ if (
1380
+ await updatePackageJson(targetDir, {
1381
+ ...options,
1382
+ webTestingDependency: true,
1383
+ })
1384
+ ) {
1385
+ updated.add("package.json");
1386
+ }
1000
1387
  if (await updatePortsIndex(targetDir, names, config, options)) {
1001
1388
  updated.add(config.paths.ports);
1002
1389
  }
1003
- if (await updateInfrastructurePorts(targetDir, names, config, options)) {
1390
+ if (
1391
+ persistence === "memory" &&
1392
+ (await updateInfrastructurePorts(targetDir, names, config, options))
1393
+ ) {
1004
1394
  updated.add(config.paths.infrastructurePorts);
1005
1395
  }
1006
1396
  if (
@@ -1037,17 +1427,27 @@ async function updateResourceWiring(
1037
1427
 
1038
1428
  async function updatePackageJson(
1039
1429
  targetDir: string,
1040
- options: { dryRun: boolean },
1430
+ options: { dryRun: boolean; webTestingDependency?: boolean },
1041
1431
  ): Promise<boolean> {
1042
1432
  const filePath = path.join(targetDir, "package.json");
1043
1433
  const original = await readFile(filePath, "utf8");
1044
1434
  const packageJson = JSON.parse(original) as {
1045
1435
  scripts?: Record<string, string>;
1436
+ dependencies?: Record<string, string>;
1437
+ devDependencies?: Record<string, string>;
1046
1438
  };
1047
1439
  packageJson.scripts = packageJson.scripts ?? {};
1048
- if (packageJson.scripts.test) return false;
1049
1440
 
1050
- packageJson.scripts.test = "bun test";
1441
+ packageJson.scripts.test ??= "bun test";
1442
+
1443
+ if (options.webTestingDependency) {
1444
+ packageJson.devDependencies = packageJson.devDependencies ?? {};
1445
+ packageJson.devDependencies["@beignet/web"] ??=
1446
+ packageJson.dependencies?.["@beignet/core"] ??
1447
+ packageJson.dependencies?.["@beignet/next"] ??
1448
+ "*";
1449
+ }
1450
+
1051
1451
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
1052
1452
  if (!options.dryRun) await writeFile(filePath, next);
1053
1453
  return next !== original;
@@ -2193,6 +2593,10 @@ function resourceNames(input: string): ResourceNames {
2193
2593
  return names;
2194
2594
  }
2195
2595
 
2596
+ function featureResourceNames(names: FeatureArtifactNames): ResourceNames {
2597
+ return resourceNames(names.feature.kebab);
2598
+ }
2599
+
2196
2600
  function useCaseNames(input: string): UseCaseNames {
2197
2601
  const parts = input
2198
2602
  .trim()
@@ -2333,6 +2737,49 @@ function jobNames(input: string): JobNames {
2333
2737
  };
2334
2738
  }
2335
2739
 
2740
+ function factoryNames(input: string): FactoryNames {
2741
+ const names = featureArtifactNames(input, "Factory");
2742
+ const factoryExportName = `${names.artifact.camel}Factory`;
2743
+
2744
+ assertGeneratedIdentifiers(input, "Factory name", [factoryExportName]);
2745
+
2746
+ return {
2747
+ ...names,
2748
+ factoryName: names.stableName,
2749
+ factoryExportName,
2750
+ };
2751
+ }
2752
+
2753
+ function seedNames(input: string): SeedNames {
2754
+ const names = featureArtifactNames(input, "Seed");
2755
+ const seedExportName = `${names.artifact.camel}Seed`;
2756
+
2757
+ assertGeneratedIdentifiers(input, "Seed name", [seedExportName]);
2758
+
2759
+ return {
2760
+ ...names,
2761
+ seedName: names.stableName,
2762
+ seedExportName,
2763
+ };
2764
+ }
2765
+
2766
+ function notificationNames(input: string): NotificationNames {
2767
+ const names = featureArtifactNames(input, "Notification");
2768
+ const notificationExportName = `${names.featureSingularPascal}${names.artifact.pascal}Notification`;
2769
+
2770
+ assertGeneratedIdentifiers(input, "Notification name", [
2771
+ notificationExportName,
2772
+ ]);
2773
+
2774
+ return {
2775
+ ...names,
2776
+ notificationName: names.stableName,
2777
+ notificationExportName,
2778
+ payloadSchemaName: `${notificationExportName}PayloadSchema`,
2779
+ payloadTypeName: `${notificationExportName}Payload`,
2780
+ };
2781
+ }
2782
+
2336
2783
  function listenerNames(input: string, eventInput: string): ListenerNames {
2337
2784
  const names = featureArtifactNames(input, "Listener");
2338
2785
  const listenerExportName = names.artifact.camel;
@@ -2367,6 +2814,21 @@ function scheduleNames(
2367
2814
  };
2368
2815
  }
2369
2816
 
2817
+ function uploadNames(input: string): UploadNames {
2818
+ const names = featureArtifactNames(input, "Upload");
2819
+ const uploadExportName = `${names.artifact.pascal}Upload`;
2820
+
2821
+ assertGeneratedIdentifiers(input, "Upload name", [uploadExportName]);
2822
+
2823
+ return {
2824
+ ...names,
2825
+ uploadName: names.stableName,
2826
+ uploadExportName,
2827
+ metadataSchemaName: `${uploadExportName}MetadataSchema`,
2828
+ metadataTypeName: `${uploadExportName}Metadata`,
2829
+ };
2830
+ }
2831
+
2370
2832
  function normalizeEventReference(input: string): string {
2371
2833
  const trimmed = input.trim();
2372
2834
  if (trimmed.includes("/")) return trimmed;
@@ -2689,6 +3151,42 @@ function jobFiles(
2689
3151
  ];
2690
3152
  }
2691
3153
 
3154
+ function notificationFiles(
3155
+ names: NotificationNames,
3156
+ config: ResolvedBeignetConfig,
3157
+ ): GeneratedFile[] {
3158
+ return [
3159
+ {
3160
+ path: notificationFilePath(names, config),
3161
+ content: notificationFile(names, config),
3162
+ },
3163
+ ];
3164
+ }
3165
+
3166
+ function factoryFiles(
3167
+ names: FactoryNames,
3168
+ config: ResolvedBeignetConfig,
3169
+ ): GeneratedFile[] {
3170
+ return [
3171
+ {
3172
+ path: factoryFilePath(names, config),
3173
+ content: factoryFile(names, config),
3174
+ },
3175
+ ];
3176
+ }
3177
+
3178
+ function seedFiles(
3179
+ names: SeedNames,
3180
+ config: ResolvedBeignetConfig,
3181
+ ): GeneratedFile[] {
3182
+ return [
3183
+ {
3184
+ path: seedFilePath(names, config),
3185
+ content: seedFile(names, config),
3186
+ },
3187
+ ];
3188
+ }
3189
+
2692
3190
  function listenerFiles(
2693
3191
  names: ListenerNames,
2694
3192
  config: ResolvedBeignetConfig,
@@ -2723,6 +3221,18 @@ function scheduleFiles(
2723
3221
  return files;
2724
3222
  }
2725
3223
 
3224
+ function uploadFiles(
3225
+ names: UploadNames,
3226
+ config: ResolvedBeignetConfig,
3227
+ ): GeneratedFile[] {
3228
+ return [
3229
+ {
3230
+ path: uploadFilePath(names, config),
3231
+ content: uploadFile(names, config),
3232
+ },
3233
+ ];
3234
+ }
3235
+
2726
3236
  function portFilePath(names: PortNames, config: ResolvedBeignetConfig): string {
2727
3237
  return path.join(path.dirname(config.paths.ports), `${names.kebab}.ts`);
2728
3238
  }
@@ -2917,7 +3427,15 @@ function policyFilePath(
2917
3427
 
2918
3428
  function featureArtifactDir(
2919
3429
  names: FeatureArtifactNames,
2920
- kind: "events" | "jobs" | "listeners" | "schedules",
3430
+ kind:
3431
+ | "events"
3432
+ | "factories"
3433
+ | "jobs"
3434
+ | "notifications"
3435
+ | "listeners"
3436
+ | "seeds"
3437
+ | "schedules"
3438
+ | "uploads",
2921
3439
  config: ResolvedBeignetConfig,
2922
3440
  ): string {
2923
3441
  const featureDir = path.join(config.paths.features, names.feature.kebab);
@@ -2925,6 +3443,9 @@ function featureArtifactDir(
2925
3443
  if (kind === "events") {
2926
3444
  return path.join(featureDir, "domain", "events");
2927
3445
  }
3446
+ if (kind === "factories") {
3447
+ return path.join(featureDir, "tests", "factories");
3448
+ }
2928
3449
 
2929
3450
  return path.join(featureDir, kind);
2930
3451
  }
@@ -2946,6 +3467,33 @@ function jobFilePath(names: JobNames, config: ResolvedBeignetConfig): string {
2946
3467
  );
2947
3468
  }
2948
3469
 
3470
+ function factoryFilePath(
3471
+ names: FactoryNames,
3472
+ config: ResolvedBeignetConfig,
3473
+ ): string {
3474
+ return path.join(
3475
+ featureArtifactDir(names, "factories", config),
3476
+ `${names.artifact.kebab}.ts`,
3477
+ );
3478
+ }
3479
+
3480
+ function seedFilePath(names: SeedNames, config: ResolvedBeignetConfig): string {
3481
+ return path.join(
3482
+ featureArtifactDir(names, "seeds", config),
3483
+ `${names.artifact.kebab}.ts`,
3484
+ );
3485
+ }
3486
+
3487
+ function notificationFilePath(
3488
+ names: NotificationNames,
3489
+ config: ResolvedBeignetConfig,
3490
+ ): string {
3491
+ return path.join(
3492
+ featureArtifactDir(names, "notifications", config),
3493
+ `${names.artifact.kebab}.ts`,
3494
+ );
3495
+ }
3496
+
2949
3497
  function listenerFilePath(
2950
3498
  names: ListenerNames,
2951
3499
  config: ResolvedBeignetConfig,
@@ -2966,6 +3514,16 @@ function scheduleFilePath(
2966
3514
  );
2967
3515
  }
2968
3516
 
3517
+ function uploadFilePath(
3518
+ names: UploadNames,
3519
+ config: ResolvedBeignetConfig,
3520
+ ): string {
3521
+ return path.join(
3522
+ featureArtifactDir(names, "uploads", config),
3523
+ `${names.artifact.kebab}.ts`,
3524
+ );
3525
+ }
3526
+
2969
3527
  function scheduleRouteFilePath(
2970
3528
  names: ScheduleNames,
2971
3529
  config: ResolvedBeignetConfig,
@@ -3029,14 +3587,40 @@ async function updateUseCaseIndex(
3029
3587
 
3030
3588
  type FeatureArtifactIndexFile = {
3031
3589
  path: string;
3590
+ kind:
3591
+ | "event"
3592
+ | "job"
3593
+ | "factory"
3594
+ | "seed"
3595
+ | "notification"
3596
+ | "listener"
3597
+ | "schedule"
3598
+ | "upload";
3032
3599
  importName: string;
3033
3600
  artifactPath: string;
3034
3601
  registryName: string;
3602
+ registryEntryName: string;
3035
3603
  };
3036
3604
 
3037
3605
  function featureArtifactIndexFile(
3038
- kind: "event" | "job" | "listener" | "schedule",
3039
- names: EventNames | JobNames | ListenerNames | ScheduleNames,
3606
+ kind:
3607
+ | "event"
3608
+ | "job"
3609
+ | "factory"
3610
+ | "seed"
3611
+ | "notification"
3612
+ | "listener"
3613
+ | "schedule"
3614
+ | "upload",
3615
+ names:
3616
+ | EventNames
3617
+ | JobNames
3618
+ | FactoryNames
3619
+ | SeedNames
3620
+ | NotificationNames
3621
+ | ListenerNames
3622
+ | ScheduleNames
3623
+ | UploadNames,
3040
3624
  config: ResolvedBeignetConfig,
3041
3625
  ): FeatureArtifactIndexFile {
3042
3626
  const artifactPath =
@@ -3044,31 +3628,57 @@ function featureArtifactIndexFile(
3044
3628
  ? eventFilePath(names as EventNames, config)
3045
3629
  : kind === "job"
3046
3630
  ? jobFilePath(names as JobNames, config)
3047
- : kind === "listener"
3048
- ? listenerFilePath(names as ListenerNames, config)
3049
- : scheduleFilePath(names as ScheduleNames, config);
3631
+ : kind === "factory"
3632
+ ? factoryFilePath(names as FactoryNames, config)
3633
+ : kind === "seed"
3634
+ ? seedFilePath(names as SeedNames, config)
3635
+ : kind === "notification"
3636
+ ? notificationFilePath(names as NotificationNames, config)
3637
+ : kind === "listener"
3638
+ ? listenerFilePath(names as ListenerNames, config)
3639
+ : kind === "schedule"
3640
+ ? scheduleFilePath(names as ScheduleNames, config)
3641
+ : uploadFilePath(names as UploadNames, config);
3050
3642
  const registrySuffix =
3051
3643
  kind === "event"
3052
3644
  ? "Events"
3053
3645
  : kind === "job"
3054
3646
  ? "Jobs"
3055
- : kind === "listener"
3056
- ? "Listeners"
3057
- : "Schedules";
3647
+ : kind === "factory"
3648
+ ? "Factories"
3649
+ : kind === "seed"
3650
+ ? "Seeds"
3651
+ : kind === "notification"
3652
+ ? "Notifications"
3653
+ : kind === "listener"
3654
+ ? "Listeners"
3655
+ : kind === "schedule"
3656
+ ? "Schedules"
3657
+ : "Uploads";
3058
3658
  const importName =
3059
3659
  kind === "event"
3060
3660
  ? (names as EventNames).eventExportName
3061
3661
  : kind === "job"
3062
3662
  ? (names as JobNames).jobExportName
3063
- : kind === "listener"
3064
- ? (names as ListenerNames).listenerExportName
3065
- : (names as ScheduleNames).scheduleExportName;
3663
+ : kind === "factory"
3664
+ ? (names as FactoryNames).factoryExportName
3665
+ : kind === "seed"
3666
+ ? (names as SeedNames).seedExportName
3667
+ : kind === "notification"
3668
+ ? (names as NotificationNames).notificationExportName
3669
+ : kind === "listener"
3670
+ ? (names as ListenerNames).listenerExportName
3671
+ : kind === "schedule"
3672
+ ? (names as ScheduleNames).scheduleExportName
3673
+ : (names as UploadNames).uploadExportName;
3066
3674
 
3067
3675
  return {
3068
3676
  path: path.join(path.dirname(artifactPath), "index.ts"),
3677
+ kind,
3069
3678
  importName,
3070
3679
  artifactPath,
3071
3680
  registryName: `${names.featureSingularCamel}${registrySuffix}`,
3681
+ registryEntryName: names.artifact.camel,
3072
3682
  };
3073
3683
  }
3074
3684
 
@@ -3081,10 +3691,22 @@ async function updateFeatureArtifactIndex(
3081
3691
  const existing = await readOptionalFile(destination);
3082
3692
  const specifier = relativeModule(file.path, file.artifactPath);
3083
3693
  const importLine = `import { ${file.importName} } from "${specifier}";`;
3694
+ const defineUploadsImport = `import { defineUploads } from "@beignet/core/uploads";`;
3084
3695
  const exportLine = `export { ${file.importName} } from "${specifier}";`;
3085
3696
 
3086
3697
  if (existing === undefined) {
3087
- const content = `${importLine}
3698
+ const content =
3699
+ file.kind === "upload"
3700
+ ? `${defineUploadsImport}
3701
+ ${importLine}
3702
+
3703
+ ${exportLine}
3704
+
3705
+ export const ${file.registryName} = defineUploads({
3706
+ \t${file.registryEntryName}: ${file.importName},
3707
+ });
3708
+ `
3709
+ : `${importLine}
3088
3710
 
3089
3711
  ${exportLine}
3090
3712
 
@@ -3114,16 +3736,49 @@ export const ${file.registryName} = [${file.importName}] as const;
3114
3736
  : `${next.trimEnd()}\n${exportLine}\n`;
3115
3737
  }
3116
3738
 
3117
- const registry = arrayInitializerInfo(next, file.registryName);
3118
- 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]);
3124
- next = `${next.slice(0, registry.start)}${nextArray}${next.slice(
3125
- registry.end,
3126
- )}`;
3739
+ if (file.kind === "upload") {
3740
+ const registry = defineUploadsInitializerInfo(next, file.registryName);
3741
+ const arrayRegistry = arrayInitializerInfo(next, file.registryName);
3742
+ if (registry) {
3743
+ if (!next.includes(defineUploadsImport)) {
3744
+ next = insertAfterImports(next, defineUploadsImport);
3745
+ }
3746
+ if (!registry.text.includes(`${file.registryEntryName}:`)) {
3747
+ next = `${next.slice(0, registry.end)}\t${file.registryEntryName}: ${file.importName},\n${next.slice(
3748
+ registry.end,
3749
+ )}`;
3750
+ }
3751
+ } else if (arrayRegistry) {
3752
+ if (
3753
+ !identifiersFromArrayExpression(arrayRegistry.text).has(file.importName)
3754
+ ) {
3755
+ const nextArray = appendToArrayExpression(arrayRegistry.text, [
3756
+ file.importName,
3757
+ ]);
3758
+ next = `${next.slice(0, arrayRegistry.start)}${nextArray}${next.slice(
3759
+ arrayRegistry.end,
3760
+ )}`;
3761
+ }
3762
+ } else {
3763
+ if (!next.includes(defineUploadsImport)) {
3764
+ next = insertAfterImports(next, defineUploadsImport);
3765
+ }
3766
+ next = `${next.trimEnd()}\n\nexport const ${file.registryName} = defineUploads({\n\t${file.registryEntryName}: ${file.importName},\n});\n`;
3767
+ }
3768
+ } else {
3769
+ const registry = arrayInitializerInfo(next, file.registryName);
3770
+ if (!registry) {
3771
+ next = `${next.trimEnd()}\n\nexport const ${file.registryName} = [${file.importName}] as const;\n`;
3772
+ } else if (
3773
+ !identifiersFromArrayExpression(registry.text).has(file.importName)
3774
+ ) {
3775
+ const nextArray = appendToArrayExpression(registry.text, [
3776
+ file.importName,
3777
+ ]);
3778
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(
3779
+ registry.end,
3780
+ )}`;
3781
+ }
3127
3782
  }
3128
3783
 
3129
3784
  if (next === existing) return "skipped";
@@ -3151,6 +3806,28 @@ function arrayInitializerInfo(
3151
3806
  };
3152
3807
  }
3153
3808
 
3809
+ function defineUploadsInitializerInfo(
3810
+ source: string,
3811
+ constName: string,
3812
+ ): { text: string; start: number; end: number } | undefined {
3813
+ const match = new RegExp(
3814
+ `\\bconst\\s+${constName}\\s*=\\s*defineUploads\\s*\\(`,
3815
+ ).exec(source);
3816
+ if (!match) return undefined;
3817
+
3818
+ const openBrace = source.indexOf("{", match.index);
3819
+ if (openBrace === -1) return undefined;
3820
+
3821
+ const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
3822
+ if (closeBrace === -1) return undefined;
3823
+
3824
+ return {
3825
+ text: source.slice(openBrace, closeBrace + 1),
3826
+ start: openBrace,
3827
+ end: closeBrace,
3828
+ };
3829
+ }
3830
+
3154
3831
  function schemasFile(names: ResourceNames): string {
3155
3832
  return `import { z } from "zod";
3156
3833
 
@@ -3166,10 +3843,14 @@ export const List${names.pluralPascal}InputSchema = z.object({
3166
3843
  });
3167
3844
 
3168
3845
  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),
3846
+ items: z.array(${names.singularPascal}Schema),
3847
+ page: z.object({
3848
+ kind: z.literal("offset"),
3849
+ limit: z.number().int().min(1),
3850
+ offset: z.number().int().min(0),
3851
+ total: z.number().int().min(0),
3852
+ hasMore: z.boolean(),
3853
+ }),
3173
3854
  });
3174
3855
 
3175
3856
  export const Create${names.singularPascal}InputSchema = z.object({
@@ -3195,7 +3876,8 @@ function listUseCaseFile(
3195
3876
  `list-${names.pluralKebab}.ts`,
3196
3877
  );
3197
3878
 
3198
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3879
+ return `import { normalizeOffsetPage } from "@beignet/core/pagination";
3880
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3199
3881
  import {
3200
3882
  List${names.pluralPascal}InputSchema,
3201
3883
  List${names.pluralPascal}OutputSchema,
@@ -3206,13 +3888,12 @@ export const list${names.pluralPascal}UseCase = useCase
3206
3888
  .input(List${names.pluralPascal}InputSchema)
3207
3889
  .output(List${names.pluralPascal}OutputSchema)
3208
3890
  .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
- };
3891
+ const page = normalizeOffsetPage(input, {
3892
+ defaultLimit: 20,
3893
+ maxLimit: 100,
3894
+ });
3895
+
3896
+ return ctx.ports.${names.pluralCamel}.list(page);
3216
3897
  });
3217
3898
  `;
3218
3899
  }
@@ -3260,18 +3941,19 @@ function repositoryPortFile(
3260
3941
  config: ResolvedBeignetConfig,
3261
3942
  ): string {
3262
3943
  return `import type {
3944
+ OffsetPage,
3945
+ OffsetPageInfo,
3946
+ PageResult,
3947
+ } from "@beignet/core/pagination";
3948
+ import type {
3263
3949
  Create${names.singularPascal}Input,
3264
- List${names.pluralPascal}Input,
3265
3950
  ${names.singularPascal},
3266
3951
  } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3267
3952
 
3268
- export type List${names.pluralPascal}Result = {
3269
- ${names.pluralCamel}: ${names.singularPascal}[];
3270
- total: number;
3271
- };
3953
+ export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, OffsetPageInfo>;
3272
3954
 
3273
3955
  export interface ${names.singularPascal}Repository {
3274
- list(input: List${names.pluralPascal}Input): Promise<List${names.pluralPascal}Result>;
3956
+ list(page: OffsetPage): Promise<List${names.pluralPascal}Result>;
3275
3957
  create(input: Create${names.singularPascal}Input): Promise<${names.singularPascal}>;
3276
3958
  }
3277
3959
  `;
@@ -3283,10 +3965,10 @@ function inMemoryRepositoryFile(
3283
3965
  ): string {
3284
3966
  const repositoryPortPath = resourcePortFilePath(names, config);
3285
3967
 
3286
- return `import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3968
+ return `import { offsetPageResult } from "@beignet/core/pagination";
3969
+ import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3287
3970
  import type {
3288
3971
  Create${names.singularPascal}Input,
3289
- List${names.pluralPascal}Input,
3290
3972
  ${names.singularPascal},
3291
3973
  } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3292
3974
 
@@ -3296,18 +3978,16 @@ export function createInMemory${names.singularPascal}Repository(
3296
3978
  const ${names.pluralCamel} = new Map(seed.map((${names.singularCamel}) => [${names.singularCamel}.id, ${names.singularCamel}]));
3297
3979
 
3298
3980
  return {
3299
- async list(input: List${names.pluralPascal}Input) {
3981
+ async list(page) {
3300
3982
  const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values()).sort(
3301
3983
  (left, right) => right.createdAt.localeCompare(left.createdAt),
3302
3984
  );
3303
3985
 
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
- };
3986
+ return offsetPageResult(
3987
+ all${names.pluralPascal}.slice(page.offset, page.offset + page.limit),
3988
+ page,
3989
+ all${names.pluralPascal}.length,
3990
+ );
3311
3991
  },
3312
3992
  async create(input: Create${names.singularPascal}Input) {
3313
3993
  const ${names.singularCamel}: ${names.singularPascal} = {
@@ -3341,12 +4021,12 @@ function drizzleRepositoryFile(
3341
4021
  const repositoryPortPath = resourcePortFilePath(names, config);
3342
4022
  const schemaPath = drizzleSchemaIndexPath(config);
3343
4023
 
3344
- return `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
4024
+ return `import { offsetPageResult } from "@beignet/core/pagination";
4025
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
3345
4026
  import { count, desc } from "drizzle-orm";
3346
4027
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3347
4028
  import type {
3348
4029
  Create${names.singularPascal}Input,
3349
- List${names.pluralPascal}Input,
3350
4030
  ${names.singularPascal},
3351
4031
  } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3352
4032
  import * as schema from "${aliasModule(schemaPath)}";
@@ -3365,21 +4045,18 @@ export function createDrizzle${names.singularPascal}Repository(
3365
4045
  db: DrizzleTursoDatabase<typeof schema>,
3366
4046
  ): ${names.singularPascal}Repository {
3367
4047
  return {
3368
- async list(input: List${names.pluralPascal}Input) {
4048
+ async list(page) {
3369
4049
  const rows = await db
3370
4050
  .select()
3371
4051
  .from(schema.${names.pluralCamel})
3372
4052
  .orderBy(desc(schema.${names.pluralCamel}.createdAt))
3373
- .limit(input.limit)
3374
- .offset(input.offset);
4053
+ .limit(page.limit)
4054
+ .offset(page.offset);
3375
4055
  const [{ total }] = await db
3376
4056
  .select({ total: count() })
3377
4057
  .from(schema.${names.pluralCamel});
3378
4058
 
3379
- return {
3380
- ${names.pluralCamel}: rows.map(to${names.singularPascal}),
3381
- total,
3382
- };
4059
+ return offsetPageResult(rows.map(to${names.singularPascal}), page, total);
3383
4060
  },
3384
4061
  async create(input: Create${names.singularPascal}Input) {
3385
4062
  const [row] = await db
@@ -3472,10 +4149,14 @@ export const list${names.pluralPascal} = ${names.pluralCamel}
3472
4149
  )
3473
4150
  .responses({
3474
4151
  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),
4152
+ items: z.array(${names.singularCamel}Schema),
4153
+ page: z.object({
4154
+ kind: z.literal("offset"),
4155
+ limit: z.number().int().min(1),
4156
+ offset: z.number().int().min(0),
4157
+ total: z.number().int().min(0),
4158
+ hasMore: z.boolean(),
4159
+ }),
3479
4160
  }),
3480
4161
  });
3481
4162
 
@@ -3532,7 +4213,7 @@ describe("${names.exportName}", () => {
3532
4213
  ...appPorts,
3533
4214
  uow: createNoopUnitOfWork(
3534
4215
  () => appPorts as unknown as AppContext["ports"],
3535
- ) as AppContext["ports"]["uow"],
4216
+ ) as unknown as AppContext["ports"]["uow"],
3536
4217
  devtools: createInMemoryDevtools(),
3537
4218
  storage: createMemoryStorage(),
3538
4219
  } as AppContext["ports"];
@@ -3626,7 +4307,7 @@ export const ${names.eventExportName} = defineEvent("${names.eventName}", {
3626
4307
  }
3627
4308
 
3628
4309
  function jobFile(names: JobNames, config: ResolvedBeignetConfig): string {
3629
- return `import { createJobHandlers } from "@beignet/core/jobs";
4310
+ return `import { createJobHandlers, retry } from "@beignet/core/jobs";
3630
4311
  import { z } from "zod";
3631
4312
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3632
4313
 
@@ -3640,9 +4321,11 @@ export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}
3640
4321
 
3641
4322
  export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
3642
4323
  \tpayload: ${names.payloadSchemaName},
3643
- \tretry: {
4324
+ \tretry: retry.exponential({
3644
4325
  \t\tattempts: 3,
3645
- \t},
4326
+ \t\tinitialDelay: "1s",
4327
+ \t\tmaxDelay: "1m",
4328
+ \t}),
3646
4329
  \tasync handle({ payload, ctx }) {
3647
4330
  \t\tctx.ports.logger.info("Job handled", {
3648
4331
  \t\t\tjobName: "${names.jobName}",
@@ -3653,6 +4336,78 @@ export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
3653
4336
  `;
3654
4337
  }
3655
4338
 
4339
+ function factoryFile(
4340
+ names: FactoryNames,
4341
+ config: ResolvedBeignetConfig,
4342
+ ): string {
4343
+ const resource = featureResourceNames(names);
4344
+
4345
+ return `import { defineFactory } from "@beignet/core/testing";
4346
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4347
+
4348
+ export const ${names.factoryExportName} = defineFactory("${names.factoryName}", {
4349
+ \tdefaults: ({ sequence }) => ({
4350
+ \t\tname: "${resource.singularPascal} " + sequence,
4351
+ \t}),
4352
+ \tpersist: (ctx: AppContext, input) => ctx.ports.${resource.pluralCamel}.create(input),
4353
+ });
4354
+ `;
4355
+ }
4356
+
4357
+ function seedFile(names: SeedNames, config: ResolvedBeignetConfig): string {
4358
+ const resource = featureResourceNames(names);
4359
+
4360
+ return `import { defineSeed } from "@beignet/core/testing";
4361
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4362
+
4363
+ export const ${names.seedExportName} = defineSeed("${names.seedName}", {
4364
+ \tdescription: "Creates starter ${names.feature.kebab} data.",
4365
+ \trun: async (ctx: AppContext) => {
4366
+ \t\tawait ctx.ports.${resource.pluralCamel}.create({
4367
+ \t\t\tname: "Demo ${resource.singularPascal}",
4368
+ \t\t});
4369
+ \t},
4370
+ });
4371
+ `;
4372
+ }
4373
+
4374
+ function notificationFile(
4375
+ names: NotificationNames,
4376
+ config: ResolvedBeignetConfig,
4377
+ ): string {
4378
+ return `import {
4379
+ \tcreateNotificationHandlers,
4380
+ \tdefineMailNotificationChannel,
4381
+ } from "@beignet/core/notifications";
4382
+ import { z } from "zod";
4383
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4384
+
4385
+ const notifications = createNotificationHandlers<AppContext>();
4386
+
4387
+ export const ${names.payloadSchemaName} = z.object({
4388
+ \tid: z.string().uuid(),
4389
+ \tsubject: z.string(),
4390
+ \trecipientEmail: z.string().email(),
4391
+ });
4392
+
4393
+ export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
4394
+
4395
+ export const ${names.notificationExportName} = notifications.defineNotification(
4396
+ \t"${names.notificationName}",
4397
+ \t{
4398
+ \t\tpayload: ${names.payloadSchemaName},
4399
+ \t\tchannels: {
4400
+ \t\t\temail: defineMailNotificationChannel(({ payload }) => ({
4401
+ \t\t\t\tto: payload.recipientEmail,
4402
+ \t\t\t\tsubject: payload.subject,
4403
+ \t\t\t\ttext: \`${names.notificationName} notification for \${payload.id}.\`,
4404
+ \t\t\t})),
4405
+ \t\t},
4406
+ \t},
4407
+ );
4408
+ `;
4409
+ }
4410
+
3656
4411
  function listenerFile(
3657
4412
  names: ListenerNames,
3658
4413
  config: ResolvedBeignetConfig,
@@ -3716,6 +4471,61 @@ ${names.timezone ? `\t\ttimezone: "${names.timezone}",\n` : ""}\t\tpayload: ${na
3716
4471
  `;
3717
4472
  }
3718
4473
 
4474
+ function uploadFile(names: UploadNames, config: ResolvedBeignetConfig): string {
4475
+ return `import { defineUpload } from "@beignet/core/uploads";
4476
+ import { z } from "zod";
4477
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4478
+
4479
+ export const ${names.metadataSchemaName} = z.object({
4480
+ \tresourceId: z.string().min(1),
4481
+ });
4482
+
4483
+ export type ${names.metadataTypeName} = z.infer<typeof ${names.metadataSchemaName}>;
4484
+
4485
+ export const ${names.uploadExportName} = defineUpload<
4486
+ \t"${names.uploadName}",
4487
+ \ttypeof ${names.metadataSchemaName},
4488
+ \tAppContext,
4489
+ \t{ objectKeys: string[] }
4490
+ >("${names.uploadName}", {
4491
+ \tmetadata: ${names.metadataSchemaName},
4492
+ \tfile: {
4493
+ \t\tcontentTypes: ["application/pdf", "text/plain"],
4494
+ \t\tmaxSizeBytes: 5 * 1024 * 1024,
4495
+ \t\tmaxFiles: 1,
4496
+ \t\tvisibility: "private",
4497
+ \t\tcacheControl: "private, max-age=0",
4498
+ \t},
4499
+ \tauthorize({ ctx }) {
4500
+ \t\treturn ctx.actor.type === "user";
4501
+ \t},
4502
+ \tkey({ ctx, metadata, uploadId, file }) {
4503
+ \t\tconst tenantId = ctx.tenant?.id ?? "default";
4504
+ \t\tconst extension = file.name.includes(".") ? file.name.split(".").pop() : undefined;
4505
+ \t\tconst suffix = extension ? \`.\${extension}\` : "";
4506
+ \t\treturn "${names.feature.kebab}/" + tenantId + "/" + metadata.resourceId + "/" + uploadId + suffix;
4507
+ \t},
4508
+ \tstorageMetadata({ ctx, metadata }) {
4509
+ \t\treturn {
4510
+ \t\t\ttenantId: ctx.tenant?.id ?? "default",
4511
+ \t\t\tresourceId: metadata.resourceId,
4512
+ \t\t};
4513
+ \t},
4514
+ \tasync onComplete({ ctx, metadata, files }) {
4515
+ \t\tctx.ports.logger.info("Upload completed", {
4516
+ \t\t\tuploadName: "${names.uploadName}",
4517
+ \t\t\tresourceId: metadata.resourceId,
4518
+ \t\t\tobjectKeys: files.map((file) => file.key),
4519
+ \t\t});
4520
+
4521
+ \t\treturn {
4522
+ \t\t\tobjectKeys: files.map((file) => file.key),
4523
+ \t\t};
4524
+ \t},
4525
+ });
4526
+ `;
4527
+ }
4528
+
3719
4529
  function scheduleRouteFile(
3720
4530
  names: ScheduleNames,
3721
4531
  config: ResolvedBeignetConfig,
@@ -3878,11 +4688,15 @@ function testFile(names: ResourceNames, config: ResolvedBeignetConfig): string {
3878
4688
 
3879
4689
  return `import { describe, expect, it } from "bun:test";
3880
4690
  import { createUseCaseTester } from "@beignet/core/application";
4691
+ import { defineRoutes } from "@beignet/web";
4692
+ import { createTestApp } from "@beignet/web/testing";
3881
4693
  import { createInMemoryDevtools } from "@beignet/devtools";
3882
4694
  import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
3883
4695
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3884
4696
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
4697
+ import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
3885
4698
  import { createInMemory${names.singularPascal}Repository } from "${aliasModule(repositoryPath)}";
4699
+ import { ${names.singularCamel}Routes } from "${aliasModule(path.join(resourceFeatureDir(names, config), "routes.ts"))}";
3886
4700
  import {
3887
4701
  create${names.singularPascal}UseCase,
3888
4702
  list${names.pluralPascal}UseCase,
@@ -3897,18 +4711,19 @@ describe("${names.pluralCamel} resource", () => {
3897
4711
  uow: createNoopUnitOfWork(() => ({
3898
4712
  ...(appPorts as unknown as AppContext["ports"]),
3899
4713
  ${names.pluralCamel},
3900
- })) as AppContext["ports"]["uow"],
4714
+ })) as unknown as AppContext["ports"]["uow"],
3901
4715
  devtools: createInMemoryDevtools(),
3902
4716
  storage: createMemoryStorage(),
3903
4717
  } as AppContext["ports"];
3904
4718
  const actor = createAnonymousActor();
3905
- const tester = createUseCaseTester<AppContext>(() => ({
4719
+ const createTestContext = () => ({
3906
4720
  requestId: "test-request",
3907
4721
  actor,
3908
4722
  auth: null,
3909
4723
  gate: testPorts.gate.bind({ actor, auth: null }),
3910
4724
  ports: testPorts,
3911
- }));
4725
+ });
4726
+ const tester = createUseCaseTester<AppContext>(createTestContext);
3912
4727
 
3913
4728
  const ctx = await tester.ctx();
3914
4729
  const created = await tester.run(
@@ -3923,8 +4738,30 @@ describe("${names.pluralCamel} resource", () => {
3923
4738
  );
3924
4739
 
3925
4740
  expect(created.name).toBe("First ${names.singularPascal}");
3926
- expect(result.total).toBe(1);
3927
- expect(result.${names.pluralCamel}).toEqual([created]);
4741
+ expect(result.page.total).toBe(1);
4742
+ expect(result.items).toEqual([created]);
4743
+
4744
+ const app = await createTestApp<AppContext, AppContext["ports"]>({
4745
+ ports: testPorts,
4746
+ routes: defineRoutes<AppContext>([${names.singularCamel}Routes]),
4747
+ createContext: async () => createTestContext(),
4748
+ mapUnhandledError: () => ({
4749
+ status: 500,
4750
+ body: { code: "INTERNAL_SERVER_ERROR", message: "Internal server error" },
4751
+ }),
4752
+ });
4753
+
4754
+ const createdViaRoute = await app.request(create${names.singularPascal}, {
4755
+ body: { name: "Second ${names.singularPascal}" },
4756
+ });
4757
+ const listedViaRoute = await app.request(list${names.pluralPascal}, {
4758
+ query: { limit: 20, offset: 0 },
4759
+ });
4760
+ await app.stop();
4761
+
4762
+ expect(createdViaRoute.name).toBe("Second ${names.singularPascal}");
4763
+ expect(listedViaRoute.page.total).toBe(2);
4764
+ expect(listedViaRoute.items).toContainEqual(createdViaRoute);
3928
4765
  });
3929
4766
  });
3930
4767
  `;