@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/dist/make.js CHANGED
@@ -1,20 +1,53 @@
1
1
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
4
+ export { makeFeatureAddonChoices } from "./choices.js";
4
5
  export async function makeResource(options) {
6
+ return makeResourceSlice(options, "resource");
7
+ }
8
+ export async function makeFeature(options) {
9
+ const addons = normalizeMakeFeatureAddons(options.with ?? []);
10
+ if (addons.length === 0) {
11
+ return makeFeatureResource(options);
12
+ }
13
+ if (options.dryRun) {
14
+ return makeFeatureSlice(options, addons);
15
+ }
16
+ await makeFeatureSlice({ ...options, dryRun: true }, addons);
17
+ return makeFeatureSlice(options, addons);
18
+ }
19
+ async function makeFeatureResource(options) {
20
+ try {
21
+ return await makeResourceSlice(options, "feature");
22
+ }
23
+ catch (error) {
24
+ if (error instanceof Error && error.message.includes("make resource")) {
25
+ throw new Error(error.message.replaceAll("make resource", "make feature"));
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+ async function makeResourceSlice(options, mode) {
5
31
  const targetDir = path.resolve(options.cwd ?? process.cwd());
6
32
  const names = resourceNames(options.name);
7
33
  const config = options.config
8
34
  ? resolveConfig(options.config)
9
35
  : await loadBeignetConfig(targetDir);
10
36
  await assertStandardApp(targetDir, config);
37
+ if (mode === "resource" &&
38
+ !(await fileExists(path.join(targetDir, resourceSharedErrorsPath(config))))) {
39
+ throw new Error(`beignet make resource expects an app error catalog. Missing ${resourceSharedErrorsPath(config)}.`);
40
+ }
11
41
  const persistence = await detectResourcePersistence(targetDir, config);
12
- const generatedFiles = resourceFiles(names, config, persistence);
42
+ const generationOptions = resourceGenerationOptions(options, mode);
43
+ const generatedFiles = resourceFiles(names, config, persistence, mode, generationOptions);
13
44
  const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
14
45
  force: Boolean(options.force),
15
46
  });
16
47
  const plannedWiringUpdates = await updateResourceWiring(targetDir, names, config, persistence, {
17
48
  dryRun: true,
49
+ mode,
50
+ generationOptions,
18
51
  });
19
52
  const createdFiles = plannedFiles
20
53
  .filter((file) => file.result === "created")
@@ -35,9 +68,12 @@ export async function makeResource(options) {
35
68
  updatedFiles.push(...(await updateResourceWiring(targetDir, names, config, {
36
69
  persistence,
37
70
  dryRun: false,
71
+ mode,
72
+ generationOptions,
38
73
  })));
39
74
  }
40
75
  return {
76
+ schemaVersion: 1,
41
77
  name: names.pluralKebab,
42
78
  targetDir,
43
79
  dryRun: Boolean(options.dryRun),
@@ -47,6 +83,90 @@ export async function makeResource(options) {
47
83
  skippedFiles,
48
84
  };
49
85
  }
86
+ function resourceGenerationOptions(options, mode) {
87
+ if (mode !== "resource") {
88
+ return { auth: false, tenant: false, events: false, softDelete: false };
89
+ }
90
+ return {
91
+ auth: Boolean(options.auth),
92
+ tenant: Boolean(options.tenant),
93
+ events: Boolean(options.events),
94
+ softDelete: Boolean(options.softDelete),
95
+ };
96
+ }
97
+ async function makeFeatureSlice(options, addons) {
98
+ let result = await makeFeatureResource(options);
99
+ for (const addon of addons) {
100
+ result = mergeMakeResults(result, await makeFeatureAddon(addon, result.name, options));
101
+ }
102
+ return result;
103
+ }
104
+ async function makeFeatureAddon(addon, feature, options) {
105
+ const shared = {
106
+ cwd: options.cwd,
107
+ force: options.force,
108
+ dryRun: options.dryRun,
109
+ config: options.config,
110
+ };
111
+ if (addon === "policy") {
112
+ return makePolicy({ ...shared, name: feature });
113
+ }
114
+ if (addon === "task") {
115
+ return makeTask({ ...shared, name: `${feature}/backfill` });
116
+ }
117
+ if (addon === "event") {
118
+ return makeEvent({ ...shared, name: `${feature}/created` });
119
+ }
120
+ if (addon === "job") {
121
+ return makeJob({ ...shared, name: `${feature}/process` });
122
+ }
123
+ if (addon === "notification") {
124
+ return makeNotification({ ...shared, name: `${feature}/created` });
125
+ }
126
+ if (addon === "ui") {
127
+ return makeFeatureUi({ ...shared, name: feature });
128
+ }
129
+ return makeUpload({ ...shared, name: `${feature}/attachment` });
130
+ }
131
+ function normalizeMakeFeatureAddons(addons) {
132
+ const normalized = new Set();
133
+ for (const addon of addons) {
134
+ if (addon === "events")
135
+ normalized.add("event");
136
+ else if (addon === "tasks")
137
+ normalized.add("task");
138
+ else if (addon === "jobs")
139
+ normalized.add("job");
140
+ else if (addon === "notifications")
141
+ normalized.add("notification");
142
+ else if (addon === "uploads")
143
+ normalized.add("upload");
144
+ else
145
+ normalized.add(addon);
146
+ }
147
+ return featureAddonOrder.filter((addon) => normalized.has(addon));
148
+ }
149
+ const featureAddonOrder = [
150
+ "policy",
151
+ "task",
152
+ "event",
153
+ "job",
154
+ "notification",
155
+ "ui",
156
+ "upload",
157
+ ];
158
+ function mergeMakeResults(left, right) {
159
+ return {
160
+ ...left,
161
+ files: uniqueStrings([...left.files, ...right.files]),
162
+ createdFiles: uniqueStrings([...left.createdFiles, ...right.createdFiles]),
163
+ updatedFiles: uniqueStrings([...left.updatedFiles, ...right.updatedFiles]),
164
+ skippedFiles: uniqueStrings([...left.skippedFiles, ...right.skippedFiles]),
165
+ };
166
+ }
167
+ function uniqueStrings(values) {
168
+ return [...new Set(values)];
169
+ }
50
170
  export async function makeContract(options) {
51
171
  const targetDir = path.resolve(options.cwd ?? process.cwd());
52
172
  const names = resourceNames(options.name);
@@ -70,6 +190,7 @@ export async function makeContract(options) {
70
190
  skippedFiles.push(file.path);
71
191
  }
72
192
  return {
193
+ schemaVersion: 1,
73
194
  name: names.pluralKebab,
74
195
  targetDir,
75
196
  dryRun: Boolean(options.dryRun),
@@ -113,6 +234,7 @@ export async function makeUseCase(options) {
113
234
  if (indexResult === "skipped")
114
235
  skippedFiles.push(indexFile.path);
115
236
  return {
237
+ schemaVersion: 1,
116
238
  name: `${names.feature.kebab}/${names.action.kebab}`,
117
239
  targetDir,
118
240
  dryRun: Boolean(options.dryRun),
@@ -145,10 +267,13 @@ export async function makeTest(options) {
145
267
  if (result === "skipped")
146
268
  skippedFiles.push(file.path);
147
269
  }
148
- if (await updatePackageJson(targetDir, { dryRun: Boolean(options.dryRun) })) {
270
+ if (await updatePackageJson(targetDir, {
271
+ dryRun: Boolean(options.dryRun),
272
+ })) {
149
273
  updatedFiles.push("package.json");
150
274
  }
151
275
  return {
276
+ schemaVersion: 1,
152
277
  name: `${names.feature.kebab}/${names.action.kebab}`,
153
278
  targetDir,
154
279
  dryRun: Boolean(options.dryRun),
@@ -200,6 +325,7 @@ export async function makePort(options) {
200
325
  updatedFiles.push(config.paths.infrastructurePorts);
201
326
  }
202
327
  return {
328
+ schemaVersion: 1,
203
329
  name: names.kebab,
204
330
  targetDir,
205
331
  dryRun: Boolean(options.dryRun),
@@ -238,6 +364,7 @@ export async function makeAdapter(options) {
238
364
  updatedFiles.push(config.paths.infrastructurePorts);
239
365
  }
240
366
  return {
367
+ schemaVersion: 1,
241
368
  name: names.kebab,
242
369
  targetDir,
243
370
  dryRun: Boolean(options.dryRun),
@@ -271,6 +398,7 @@ export async function makePolicy(options) {
271
398
  skippedFiles.push(file.path);
272
399
  }
273
400
  return {
401
+ schemaVersion: 1,
274
402
  name: names.kebab,
275
403
  targetDir,
276
404
  dryRun: Boolean(options.dryRun),
@@ -313,13 +441,115 @@ export async function makeJob(options) {
313
441
  force: Boolean(options.force),
314
442
  dryRun: Boolean(options.dryRun),
315
443
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
316
- files: jobFiles(names, config),
444
+ files: [
445
+ ...(await missingCapabilityBuilderFiles(targetDir, "jobs", config)),
446
+ ...jobFiles(names, config),
447
+ ],
317
448
  index: featureArtifactIndexFile("job", names, config),
318
449
  dependencies: {
319
450
  "@beignet/core": beignetDependencyVersion,
320
451
  },
321
452
  });
322
453
  }
454
+ export async function makeTask(options) {
455
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
456
+ const names = taskNames(options.name);
457
+ const config = options.config
458
+ ? resolveConfig(options.config)
459
+ : await loadBeignetConfig(targetDir);
460
+ await assertFeatureArtifactApp(targetDir, config, "task");
461
+ const result = await makeFeatureArtifact({
462
+ targetDir,
463
+ config,
464
+ force: Boolean(options.force),
465
+ dryRun: Boolean(options.dryRun),
466
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
467
+ files: [
468
+ ...(await missingCapabilityBuilderFiles(targetDir, "tasks", config)),
469
+ ...taskFiles(names, config),
470
+ ],
471
+ index: featureArtifactIndexFile("task", names, config),
472
+ dependencies: {
473
+ "@beignet/core": beignetDependencyVersion,
474
+ },
475
+ });
476
+ const registryResult = await updateTaskRegistry(targetDir, names, config, {
477
+ dryRun: Boolean(options.dryRun),
478
+ });
479
+ if (registryResult === "created")
480
+ result.createdFiles.push(config.paths.tasks);
481
+ if (registryResult === "updated")
482
+ result.updatedFiles.push(config.paths.tasks);
483
+ if (registryResult === "skipped")
484
+ result.skippedFiles.push(config.paths.tasks);
485
+ if (!result.files.includes(config.paths.tasks)) {
486
+ result.files.push(config.paths.tasks);
487
+ }
488
+ return result;
489
+ }
490
+ export async function makeFactory(options) {
491
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
492
+ const names = factoryNames(options.name);
493
+ const config = options.config
494
+ ? resolveConfig(options.config)
495
+ : await loadBeignetConfig(targetDir);
496
+ await assertPersistenceArtifactApp(targetDir, names, config, "factory");
497
+ return makeFeatureArtifact({
498
+ targetDir,
499
+ config,
500
+ force: Boolean(options.force),
501
+ dryRun: Boolean(options.dryRun),
502
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
503
+ files: factoryFiles(names, config),
504
+ index: featureArtifactIndexFile("factory", names, config),
505
+ dependencies: {
506
+ "@beignet/core": beignetDependencyVersion,
507
+ },
508
+ });
509
+ }
510
+ export async function makeSeed(options) {
511
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
512
+ const names = seedNames(options.name);
513
+ const config = options.config
514
+ ? resolveConfig(options.config)
515
+ : await loadBeignetConfig(targetDir);
516
+ await assertPersistenceArtifactApp(targetDir, names, config, "seed");
517
+ return makeFeatureArtifact({
518
+ targetDir,
519
+ config,
520
+ force: Boolean(options.force),
521
+ dryRun: Boolean(options.dryRun),
522
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
523
+ files: seedFiles(names, config),
524
+ index: featureArtifactIndexFile("seed", names, config),
525
+ dependencies: {
526
+ "@beignet/core": beignetDependencyVersion,
527
+ },
528
+ });
529
+ }
530
+ export async function makeNotification(options) {
531
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
532
+ const names = notificationNames(options.name);
533
+ const config = options.config
534
+ ? resolveConfig(options.config)
535
+ : await loadBeignetConfig(targetDir);
536
+ await assertFeatureArtifactApp(targetDir, config, "notification");
537
+ return makeFeatureArtifact({
538
+ targetDir,
539
+ config,
540
+ force: Boolean(options.force),
541
+ dryRun: Boolean(options.dryRun),
542
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
543
+ files: [
544
+ ...(await missingCapabilityBuilderFiles(targetDir, "notifications", config)),
545
+ ...notificationFiles(names, config),
546
+ ],
547
+ index: featureArtifactIndexFile("notification", names, config),
548
+ dependencies: {
549
+ "@beignet/core": beignetDependencyVersion,
550
+ },
551
+ });
552
+ }
323
553
  export async function makeListener(options) {
324
554
  const targetDir = path.resolve(options.cwd ?? process.cwd());
325
555
  const names = listenerNames(options.name, options.event);
@@ -334,7 +564,10 @@ export async function makeListener(options) {
334
564
  force: Boolean(options.force),
335
565
  dryRun: Boolean(options.dryRun),
336
566
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
337
- files: listenerFiles(names, config),
567
+ files: [
568
+ ...(await missingCapabilityBuilderFiles(targetDir, "listeners", config)),
569
+ ...listenerFiles(names, config),
570
+ ],
338
571
  index: featureArtifactIndexFile("listener", names, config),
339
572
  dependencies: {
340
573
  "@beignet/core": beignetDependencyVersion,
@@ -362,7 +595,10 @@ export async function makeSchedule(options) {
362
595
  await assertFeatureArtifactApp(targetDir, config, "schedule", {
363
596
  requireServer: Boolean(options.route),
364
597
  });
365
- const files = scheduleFiles(names, config, { route: Boolean(options.route) });
598
+ const files = [
599
+ ...(await missingCapabilityBuilderFiles(targetDir, "schedules", config)),
600
+ ...scheduleFiles(names, config, { route: Boolean(options.route) }),
601
+ ];
366
602
  return makeFeatureArtifact({
367
603
  targetDir,
368
604
  config,
@@ -376,6 +612,78 @@ export async function makeSchedule(options) {
376
612
  },
377
613
  });
378
614
  }
615
+ export async function makeUpload(options) {
616
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
617
+ const names = uploadNames(options.name);
618
+ const config = options.config
619
+ ? resolveConfig(options.config)
620
+ : await loadBeignetConfig(targetDir);
621
+ await assertFeatureArtifactApp(targetDir, config, "upload");
622
+ return makeFeatureArtifact({
623
+ targetDir,
624
+ config,
625
+ force: Boolean(options.force),
626
+ dryRun: Boolean(options.dryRun),
627
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
628
+ files: uploadFiles(names, config),
629
+ index: featureArtifactIndexFile("upload", names, config),
630
+ dependencies: {
631
+ "@beignet/core": beignetDependencyVersion,
632
+ },
633
+ });
634
+ }
635
+ async function makeFeatureUi(options) {
636
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
637
+ const names = featureUiNames(options.name);
638
+ const config = options.config
639
+ ? resolveConfig(options.config)
640
+ : await loadBeignetConfig(targetDir);
641
+ await assertFeatureUiApp(targetDir, config);
642
+ const componentFiles = featureUiComponentFiles(names, config);
643
+ const indexFile = featureUiIndexFile(names, config);
644
+ const plannedFiles = await planGeneratedFiles(targetDir, componentFiles, {
645
+ force: Boolean(options.force),
646
+ });
647
+ const createdFiles = plannedFiles
648
+ .filter((file) => file.result === "created")
649
+ .map((file) => file.path);
650
+ const updatedFiles = plannedFiles
651
+ .filter((file) => file.result === "updated")
652
+ .map((file) => file.path);
653
+ const skippedFiles = plannedFiles
654
+ .filter((file) => file.result === "skipped")
655
+ .map((file) => file.path);
656
+ if (!options.dryRun) {
657
+ for (const file of plannedFiles) {
658
+ await writePlannedGeneratedFile(targetDir, file);
659
+ }
660
+ }
661
+ const indexResult = await updateFeatureUiIndex(targetDir, indexFile, {
662
+ dryRun: Boolean(options.dryRun),
663
+ });
664
+ if (indexResult === "created")
665
+ createdFiles.push(indexFile.path);
666
+ if (indexResult === "updated")
667
+ updatedFiles.push(indexFile.path);
668
+ if (indexResult === "skipped")
669
+ skippedFiles.push(indexFile.path);
670
+ if (await updatePackageDependencies(targetDir, {
671
+ "@beignet/react-query": beignetDependencyVersion,
672
+ "@tanstack/react-query": tanstackReactQueryDependencyVersion,
673
+ }, { dryRun: Boolean(options.dryRun) })) {
674
+ updatedFiles.push("package.json");
675
+ }
676
+ return {
677
+ schemaVersion: 1,
678
+ name: names.pluralKebab,
679
+ targetDir,
680
+ dryRun: Boolean(options.dryRun),
681
+ files: [...componentFiles.map((file) => file.path), indexFile.path],
682
+ createdFiles,
683
+ updatedFiles,
684
+ skippedFiles,
685
+ };
686
+ }
379
687
  async function assertStandardApp(targetDir, config) {
380
688
  const requiredFiles = [
381
689
  config.paths.appContext,
@@ -468,6 +776,33 @@ async function assertFeatureArtifactApp(targetDir, config, artifact, options = {
468
776
  }
469
777
  }
470
778
  }
779
+ async function assertFeatureUiApp(targetDir, config) {
780
+ const clientPath = clientIndexPath(config);
781
+ const requiredFiles = [config.paths.appContext, clientPath];
782
+ for (const file of requiredFiles) {
783
+ try {
784
+ await stat(path.join(targetDir, file));
785
+ }
786
+ catch {
787
+ throw new Error(`beignet make feature --with ui expects a standard Beignet app with React Query client helpers. Missing ${file}.`);
788
+ }
789
+ }
790
+ }
791
+ async function assertPersistenceArtifactApp(targetDir, names, config, artifact) {
792
+ const requiredFiles = [
793
+ config.paths.appContext,
794
+ config.paths.ports,
795
+ resourcePortFilePath(featureResourceNames(names), config),
796
+ ];
797
+ for (const file of requiredFiles) {
798
+ try {
799
+ await stat(path.join(targetDir, file));
800
+ }
801
+ catch {
802
+ throw new Error(`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.`);
803
+ }
804
+ }
805
+ }
471
806
  async function writeGeneratedFile(targetDir, file, options) {
472
807
  const plannedFile = await planGeneratedFile(targetDir, file, {
473
808
  force: options.force,
@@ -519,6 +854,62 @@ async function readOptionalFile(filePath) {
519
854
  return undefined;
520
855
  }
521
856
  }
857
+ const capabilityBuilders = {
858
+ listeners: {
859
+ factoryName: "createListeners",
860
+ defineName: "defineListener",
861
+ module: "@beignet/core/events",
862
+ },
863
+ jobs: {
864
+ factoryName: "createJobs",
865
+ defineName: "defineJob",
866
+ module: "@beignet/core/jobs",
867
+ },
868
+ schedules: {
869
+ factoryName: "createSchedules",
870
+ defineName: "defineSchedule",
871
+ module: "@beignet/core/schedules",
872
+ },
873
+ notifications: {
874
+ factoryName: "createNotifications",
875
+ defineName: "defineNotification",
876
+ module: "@beignet/core/notifications",
877
+ },
878
+ tasks: {
879
+ factoryName: "createTasks",
880
+ defineName: "defineTask",
881
+ module: "@beignet/core/tasks",
882
+ },
883
+ };
884
+ function capabilityBuilderPath(capability, config) {
885
+ if (capability === "listeners")
886
+ return config.paths.listenersBuilder;
887
+ if (capability === "jobs")
888
+ return config.paths.jobsBuilder;
889
+ if (capability === "schedules")
890
+ return config.paths.schedulesBuilder;
891
+ if (capability === "notifications")
892
+ return config.paths.notificationsBuilder;
893
+ return config.paths.tasksBuilder;
894
+ }
895
+ function capabilityBuilderFile(capability, config) {
896
+ const builder = capabilityBuilders[capability];
897
+ return {
898
+ path: capabilityBuilderPath(capability, config),
899
+ content: `import { ${builder.factoryName} } from "${builder.module}";
900
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
901
+
902
+ export const { ${builder.defineName} } = ${builder.factoryName}<AppContext>();
903
+ `,
904
+ };
905
+ }
906
+ async function missingCapabilityBuilderFiles(targetDir, capability, config) {
907
+ const file = capabilityBuilderFile(capability, config);
908
+ if (await fileExists(path.join(targetDir, file.path))) {
909
+ return [];
910
+ }
911
+ return [file];
912
+ }
522
913
  async function makeFeatureArtifact(options) {
523
914
  const plannedFiles = await planGeneratedFiles(options.targetDir, options.files, {
524
915
  force: options.force,
@@ -550,6 +941,7 @@ async function makeFeatureArtifact(options) {
550
941
  updatedFiles.push("package.json");
551
942
  }
552
943
  return {
944
+ schemaVersion: 1,
553
945
  name: options.name,
554
946
  targetDir: options.targetDir,
555
947
  dryRun: options.dryRun,
@@ -579,6 +971,9 @@ function beignetDependencyVersion(packageJson) {
579
971
  packageJson.dependencies?.["@beignet/next"] ??
580
972
  "*");
581
973
  }
974
+ function tanstackReactQueryDependencyVersion(packageJson) {
975
+ return packageJson.dependencies?.["@tanstack/react-query"] ?? "^5.100.7";
976
+ }
582
977
  async function updateResourceWiring(targetDir, names, config, persistenceOrOptions, maybeOptions) {
583
978
  const persistence = typeof persistenceOrOptions === "string"
584
979
  ? persistenceOrOptions
@@ -586,13 +981,37 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
586
981
  const options = typeof persistenceOrOptions === "string"
587
982
  ? maybeOptions
588
983
  : persistenceOrOptions;
984
+ const mode = typeof persistenceOrOptions === "string"
985
+ ? (maybeOptions?.mode ?? "feature")
986
+ : (persistenceOrOptions.mode ?? "feature");
987
+ const generationOptions = typeof persistenceOrOptions === "string"
988
+ ? (maybeOptions?.generationOptions ??
989
+ resourceGenerationOptions({ name: names.pluralKebab }, mode))
990
+ : (persistenceOrOptions.generationOptions ??
991
+ resourceGenerationOptions({ name: names.pluralKebab }, mode));
589
992
  const updated = new Set();
590
- if (await updatePackageJson(targetDir, options))
993
+ if (await updatePackageJson(targetDir, {
994
+ ...options,
995
+ webTestingDependency: true,
996
+ })) {
591
997
  updated.add("package.json");
592
- if (await updatePortsIndex(targetDir, names, config, options)) {
998
+ }
999
+ if (await updatePortsIndex(targetDir, names, config, {
1000
+ ...options,
1001
+ generationOptions,
1002
+ })) {
593
1003
  updated.add(config.paths.ports);
594
1004
  }
595
- if (await updateInfrastructurePorts(targetDir, names, config, options)) {
1005
+ if (persistence === "memory" &&
1006
+ (await updateInfrastructurePorts(targetDir, names, config, options))) {
1007
+ updated.add(config.paths.infrastructurePorts);
1008
+ }
1009
+ if (generationOptions.auth &&
1010
+ (await updateInfrastructureGatePolicies(targetDir, names, config, options))) {
1011
+ updated.add(config.paths.infrastructurePorts);
1012
+ }
1013
+ if (persistence === "drizzle" &&
1014
+ (await updateInfrastructureDeferredPorts(targetDir, names, config, options))) {
596
1015
  updated.add(config.paths.infrastructurePorts);
597
1016
  }
598
1017
  if (persistence === "drizzle" &&
@@ -607,7 +1026,10 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
607
1026
  (await updateServerTransactionPorts(targetDir, names, config, options))) {
608
1027
  updated.add(config.paths.server);
609
1028
  }
610
- if (await updateOpenApiRoute(targetDir, names, config, options)) {
1029
+ if (await updateSharedErrors(targetDir, names, config, { ...options, mode })) {
1030
+ updated.add(resourceSharedErrorsPath(config));
1031
+ }
1032
+ if (await updateOpenApiRoute(targetDir, names, config, { ...options, mode })) {
611
1033
  updated.add(config.paths.openapiRoute);
612
1034
  }
613
1035
  const routesFile = await updateServerRoutes(targetDir, names, config, options);
@@ -620,9 +1042,14 @@ async function updatePackageJson(targetDir, options) {
620
1042
  const original = await readFile(filePath, "utf8");
621
1043
  const packageJson = JSON.parse(original);
622
1044
  packageJson.scripts = packageJson.scripts ?? {};
623
- if (packageJson.scripts.test)
624
- return false;
625
- packageJson.scripts.test = "bun test";
1045
+ packageJson.scripts.test ??= "bun test";
1046
+ if (options.webTestingDependency) {
1047
+ packageJson.devDependencies = packageJson.devDependencies ?? {};
1048
+ packageJson.devDependencies["@beignet/web"] ??=
1049
+ packageJson.dependencies?.["@beignet/core"] ??
1050
+ packageJson.dependencies?.["@beignet/next"] ??
1051
+ "*";
1052
+ }
626
1053
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
627
1054
  if (!options.dryRun)
628
1055
  await writeFile(filePath, next);
@@ -637,6 +1064,16 @@ async function updatePortsIndex(targetDir, names, config, options) {
637
1064
  if (!next.includes(importLine)) {
638
1065
  next = insertAfterImports(next, importLine);
639
1066
  }
1067
+ if (options.generationOptions?.auth) {
1068
+ const policyPath = path.join(config.paths.features, names.pluralKebab, "policy.ts");
1069
+ const policyImportLine = `import type { AuthorizationContext as ${names.singularPascal}AuthorizationContext, ${names.singularCamel}Policy } from "${relativeModule(config.paths.ports, policyPath)}";`;
1070
+ if (!next.includes(policyImportLine)) {
1071
+ next = insertAfterImports(next, policyImportLine);
1072
+ }
1073
+ next = appendUnionTypeMember(next, "AppAuthorizationContext", `${names.singularPascal}AuthorizationContext`);
1074
+ next = appendTupleTypeMember(next, "AppGate", `typeof ${names.singularCamel}Policy`);
1075
+ next = appendGatePolicyMember(next, `typeof ${names.singularCamel}Policy`);
1076
+ }
640
1077
  const entry = `\t${names.pluralCamel}: ${names.singularPascal}Repository;`;
641
1078
  if (!next.includes(entry.trim())) {
642
1079
  next = insertTypeProperty(next, "AppPorts", entry.trim(), `Could not find AppPorts in ${config.paths.ports}. Add ${entry.trim()} manually, or restore the generated ports file before running make resource.`);
@@ -731,7 +1168,7 @@ async function updateInfrastructurePorts(targetDir, names, config, options) {
731
1168
  }
732
1169
  const repositoryConst = `const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();`;
733
1170
  if (!next.includes(repositoryConst)) {
734
- next = replaceRequired(next, /(\nexport const appPorts = definePorts(?:<[^>]+>)?\(\{)/, `\n${repositoryConst}\n$1`, `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add ${repositoryConst} manually, or restore the generated infrastructure ports file before running make resource.`);
1171
+ next = replaceRequired(next, /(\nexport const appPorts = definePorts(?:<[^>]+>)?\((?:\)\()?\{)/, `\n${repositoryConst}\n$1`, `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add ${repositoryConst} manually, or restore the generated infrastructure ports file before running make resource.`);
735
1172
  }
736
1173
  const entry = `\t${names.pluralCamel},`;
737
1174
  const definePortsBody = definePortsBodySource(next);
@@ -751,6 +1188,22 @@ async function updateInfrastructurePorts(targetDir, names, config, options) {
751
1188
  await writeFile(filePath, next);
752
1189
  return true;
753
1190
  }
1191
+ async function updateInfrastructureGatePolicies(targetDir, names, config, options) {
1192
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1193
+ const original = await readFile(filePath, "utf8");
1194
+ let next = original;
1195
+ const policyPath = path.join(config.paths.features, names.pluralKebab, "policy.ts");
1196
+ const importLine = `import { ${names.singularCamel}Policy } from "${relativeModule(config.paths.infrastructurePorts, policyPath)}";`;
1197
+ if (!next.includes(importLine)) {
1198
+ next = insertAfterImports(next, importLine);
1199
+ }
1200
+ next = appendCreateGatePolicy(next, `${names.singularCamel}Policy`, `Could not find createGate({ policies: [...] }) in ${config.paths.infrastructurePorts}. Add ${names.singularCamel}Policy to the gate policies manually, or restore the generated infrastructure ports file before running make resource --auth.`);
1201
+ if (next === original)
1202
+ return false;
1203
+ if (!options.dryRun)
1204
+ await writeFile(filePath, next);
1205
+ return true;
1206
+ }
754
1207
  async function updateDrizzleSchemaIndex(targetDir, names, config, options) {
755
1208
  const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
756
1209
  if (!(await fileExists(filePath)))
@@ -789,6 +1242,19 @@ async function updateDrizzleRepositories(targetDir, names, config, options) {
789
1242
  await writeFile(filePath, next);
790
1243
  return true;
791
1244
  }
1245
+ async function updateInfrastructureDeferredPorts(targetDir, names, config, options) {
1246
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1247
+ const original = await readFile(filePath, "utf8");
1248
+ // Drizzle resources are provided by the app database provider at startup,
1249
+ // so deferred-form ports files defer the new repository key. Identity-form
1250
+ // ports files are left untouched.
1251
+ const next = appendDeferredPortKey(original, names.pluralCamel);
1252
+ if (next === original)
1253
+ return false;
1254
+ if (!options.dryRun)
1255
+ await writeFile(filePath, next);
1256
+ return true;
1257
+ }
792
1258
  async function updateServerTransactionPorts(targetDir, names, config, options) {
793
1259
  const filePath = path.join(targetDir, config.paths.server);
794
1260
  const original = await readFile(filePath, "utf8");
@@ -817,13 +1283,12 @@ async function updateOpenApiRoute(targetDir, names, config, options) {
817
1283
  const firstArg = firstCreateOpenAPIHandlerArgInfo(original);
818
1284
  if (!firstArg?.text.startsWith("["))
819
1285
  return false;
820
- const listName = `list${names.pluralPascal}`;
821
- const createName = `create${names.singularPascal}`;
1286
+ const contractNames = resourceContractNames(names, options.mode);
822
1287
  const listedContracts = identifiersFromArrayExpression(firstArg.text);
823
- const missingContracts = [listName, createName].filter((contractName) => !listedContracts.has(contractName));
1288
+ const missingContracts = contractNames.filter((contractName) => !listedContracts.has(contractName));
824
1289
  if (missingContracts.length === 0)
825
1290
  return false;
826
- const importLine = `import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
1291
+ const importLine = `import { ${contractNames.join(", ")} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
827
1292
  const nextArray = appendToArrayExpression(firstArg.text, missingContracts);
828
1293
  let next = `${original.slice(0, firstArg.start)}${nextArray}${original.slice(firstArg.end)}`;
829
1294
  if (!next.includes(importLine)) {
@@ -835,6 +1300,53 @@ async function updateOpenApiRoute(targetDir, names, config, options) {
835
1300
  await writeFile(filePath, next);
836
1301
  return true;
837
1302
  }
1303
+ async function updateSharedErrors(targetDir, names, config, options) {
1304
+ if (options.mode !== "resource")
1305
+ return false;
1306
+ const filePath = path.join(targetDir, resourceSharedErrorsPath(config));
1307
+ if (!(await fileExists(filePath)))
1308
+ return false;
1309
+ const original = await readFile(filePath, "utf8");
1310
+ const notFoundErrorName = `${names.singularPascal}NotFound`;
1311
+ const conflictErrorName = `${names.singularPascal}Conflict`;
1312
+ const missingErrorNames = [notFoundErrorName, conflictErrorName].filter((errorName) => !new RegExp(`\\b${errorName}\\b`).test(original));
1313
+ if (missingErrorNames.length === 0)
1314
+ return false;
1315
+ const expectedErrorNames = missingErrorNames.join(" and ");
1316
+ const defineErrorsStart = original.indexOf("defineErrors(");
1317
+ if (defineErrorsStart === -1) {
1318
+ throw new Error(`Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
1319
+ }
1320
+ const openBrace = original.indexOf("{", defineErrorsStart);
1321
+ if (openBrace === -1) {
1322
+ throw new Error(`Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
1323
+ }
1324
+ const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
1325
+ if (closeBrace === -1) {
1326
+ throw new Error(`Could not update ${resourceSharedErrorsPath(config)} because the error catalog object is malformed. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
1327
+ }
1328
+ const entries = [];
1329
+ if (missingErrorNames.includes(notFoundErrorName)) {
1330
+ entries.push(`${notFoundErrorName}: {
1331
+ \t\tcode: "${constantCase(names.singularKebab)}_NOT_FOUND",
1332
+ \t\tstatus: 404,
1333
+ \t\tmessage: "${names.singularPascal} not found",
1334
+ \t},`);
1335
+ }
1336
+ if (missingErrorNames.includes(conflictErrorName)) {
1337
+ entries.push(`${conflictErrorName}: {
1338
+ \t\tcode: "${constantCase(names.singularKebab)}_CONFLICT",
1339
+ \t\tstatus: 409,
1340
+ \t\tmessage: "${names.singularPascal} was changed by another request",
1341
+ \t},`);
1342
+ }
1343
+ const next = insertBeforeClosingBrace(original, openBrace, closeBrace, entries.join("\n"));
1344
+ if (next === original)
1345
+ return false;
1346
+ if (!options.dryRun)
1347
+ await writeFile(filePath, next);
1348
+ return true;
1349
+ }
838
1350
  async function updateServerRoutes(targetDir, names, config, options) {
839
1351
  const filePath = path.join(targetDir, config.paths.server);
840
1352
  const original = await readFile(filePath, "utf8");
@@ -1096,6 +1608,67 @@ function typeBodySource(source, typeName) {
1096
1608
  return undefined;
1097
1609
  return source.slice(openBrace + 1, closeBrace);
1098
1610
  }
1611
+ function appendUnionTypeMember(source, typeName, member) {
1612
+ const match = new RegExp(`(export\\s+type\\s+${typeName}\\s*=)([\\s\\S]*?);`).exec(source);
1613
+ if (!match)
1614
+ return source;
1615
+ const declaration = match[0];
1616
+ if (new RegExp(`\\b${member}\\b`).test(declaration))
1617
+ return source;
1618
+ const nextDeclaration = declaration.replace(/;\s*$/, ` | ${member};`);
1619
+ return `${source.slice(0, match.index)}${nextDeclaration}${source.slice(match.index + declaration.length)}`;
1620
+ }
1621
+ function appendTupleTypeMember(source, typeName, member) {
1622
+ const match = new RegExp(`export\\s+type\\s+${typeName}\\s*=\\s*`).exec(source);
1623
+ if (!match)
1624
+ return source;
1625
+ const tupleStart = source.indexOf("[", match.index);
1626
+ if (tupleStart === -1)
1627
+ return source;
1628
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
1629
+ if (tupleEnd === -1)
1630
+ return source;
1631
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
1632
+ if (tuple.includes(member))
1633
+ return source;
1634
+ const nextTuple = appendToArrayExpression(tuple, [member]);
1635
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(tupleEnd + 1)}`;
1636
+ }
1637
+ function appendGatePolicyMember(source, member) {
1638
+ const gateMatch = /\bGatePort\s*</.exec(source);
1639
+ if (!gateMatch)
1640
+ return source;
1641
+ const tupleStart = source.indexOf("[", gateMatch.index);
1642
+ if (tupleStart === -1)
1643
+ return source;
1644
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
1645
+ if (tupleEnd === -1)
1646
+ return source;
1647
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
1648
+ if (tuple.includes(member))
1649
+ return source;
1650
+ const nextTuple = appendToArrayExpression(tuple, [member]);
1651
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(tupleEnd + 1)}`;
1652
+ }
1653
+ function appendCreateGatePolicy(source, policy, failureMessage) {
1654
+ const policyMatch = /\bpolicies\s*:\s*\[/.exec(source);
1655
+ if (!policyMatch) {
1656
+ throw new Error(failureMessage);
1657
+ }
1658
+ const arrayStart = source.indexOf("[", policyMatch.index);
1659
+ if (arrayStart === -1) {
1660
+ throw new Error(failureMessage);
1661
+ }
1662
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
1663
+ if (arrayEnd === -1) {
1664
+ throw new Error(failureMessage);
1665
+ }
1666
+ const policies = source.slice(arrayStart, arrayEnd + 1);
1667
+ if (policies.includes(policy))
1668
+ return source;
1669
+ const nextPolicies = appendToArrayExpression(policies, [policy]);
1670
+ return `${source.slice(0, arrayStart)}${nextPolicies}${source.slice(arrayEnd + 1)}`;
1671
+ }
1099
1672
  function insertDefinePortsProperty(source, property, message) {
1100
1673
  const body = definePortsBodyRange(source);
1101
1674
  if (!body)
@@ -1145,7 +1718,15 @@ function definePortsBodySource(source) {
1145
1718
  return source.slice(body.openBrace + 1, body.closeBrace);
1146
1719
  }
1147
1720
  function definePortsBodyRange(source) {
1148
- const match = /definePorts(?:<[^>]+>)?\(\s*\{/.exec(source);
1721
+ const outer = definePortsOuterBodyRange(source);
1722
+ if (!outer)
1723
+ return undefined;
1724
+ // The curried `definePorts<AppPorts>()({ bound, deferred })` form binds app
1725
+ // ports inside `bound`; generated bindings belong there.
1726
+ return (topLevelBoundObjectRange(source, outer.openBrace, outer.closeBrace) ?? outer);
1727
+ }
1728
+ function definePortsOuterBodyRange(source) {
1729
+ const match = /definePorts(?:<[^>]+>)?\(\s*(?:\)\s*\(\s*)?\{/.exec(source);
1149
1730
  if (!match)
1150
1731
  return undefined;
1151
1732
  const openBrace = source.indexOf("{", match.index);
@@ -1154,6 +1735,39 @@ function definePortsBodyRange(source) {
1154
1735
  return undefined;
1155
1736
  return { openBrace, closeBrace };
1156
1737
  }
1738
+ function appendDeferredPortKey(source, portKey) {
1739
+ const outer = definePortsOuterBodyRange(source);
1740
+ if (!outer)
1741
+ return source;
1742
+ const body = source.slice(outer.openBrace + 1, outer.closeBrace);
1743
+ if (!hasTopLevelObjectProperty(body, "deferred"))
1744
+ return source;
1745
+ const deferredMatch = /\bdeferred\s*:\s*\[/.exec(body);
1746
+ if (!deferredMatch)
1747
+ return source;
1748
+ const arrayStart = source.indexOf("[", outer.openBrace + 1 + deferredMatch.index);
1749
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
1750
+ if (arrayEnd === -1)
1751
+ return source;
1752
+ const tuple = source.slice(arrayStart, arrayEnd + 1);
1753
+ if (new RegExp(`["']${portKey}["']`).test(tuple))
1754
+ return source;
1755
+ const nextTuple = appendToArrayExpression(tuple, [`"${portKey}"`]);
1756
+ return `${source.slice(0, arrayStart)}${nextTuple}${source.slice(arrayEnd + 1)}`;
1757
+ }
1758
+ function topLevelBoundObjectRange(source, openBrace, closeBrace) {
1759
+ const body = source.slice(openBrace + 1, closeBrace);
1760
+ if (!hasTopLevelObjectProperty(body, "bound"))
1761
+ return undefined;
1762
+ const boundMatch = /\bbound\s*:\s*\{/.exec(body);
1763
+ if (!boundMatch)
1764
+ return undefined;
1765
+ const boundOpen = source.indexOf("{", openBrace + 1 + boundMatch.index);
1766
+ const boundClose = matchingBraceIndex(source, boundOpen);
1767
+ if (boundClose === -1)
1768
+ return undefined;
1769
+ return { openBrace: boundOpen, closeBrace: boundClose };
1770
+ }
1157
1771
  function hasTopLevelObjectProperty(source, propertyName) {
1158
1772
  let depth = 0;
1159
1773
  let inString;
@@ -1383,6 +1997,21 @@ function resourceNames(input) {
1383
1997
  ]);
1384
1998
  return names;
1385
1999
  }
2000
+ function featureUiNames(input) {
2001
+ const names = resourceNames(input);
2002
+ const resolvedNames = {
2003
+ ...names,
2004
+ componentExportName: `${names.pluralPascal}Panel`,
2005
+ componentFileName: `${names.pluralKebab}-panel`,
2006
+ };
2007
+ assertGeneratedIdentifiers(input, "Feature UI name", [
2008
+ resolvedNames.componentExportName,
2009
+ ]);
2010
+ return resolvedNames;
2011
+ }
2012
+ function featureResourceNames(names) {
2013
+ return resourceNames(names.feature.kebab);
2014
+ }
1386
2015
  function useCaseNames(input) {
1387
2016
  const parts = input
1388
2017
  .trim()
@@ -1497,14 +2126,60 @@ function jobNames(input) {
1497
2126
  payloadTypeName: `${jobExportName}Payload`,
1498
2127
  };
1499
2128
  }
1500
- function listenerNames(input, eventInput) {
1501
- const names = featureArtifactNames(input, "Listener");
1502
- const listenerExportName = names.artifact.camel;
1503
- assertGeneratedIdentifiers(input, "Listener name", [listenerExportName]);
2129
+ function taskNames(input) {
2130
+ const names = featureArtifactNames(input, "Task");
2131
+ const taskExportName = `${names.artifact.camel}Task`;
2132
+ assertGeneratedIdentifiers(input, "Task name", [taskExportName]);
1504
2133
  return {
1505
2134
  ...names,
1506
- listenerName: names.stableName,
1507
- listenerExportName,
2135
+ taskName: names.stableName,
2136
+ taskExportName,
2137
+ inputSchemaName: `${names.artifact.pascal}TaskInputSchema`,
2138
+ inputTypeName: `${names.artifact.pascal}TaskInput`,
2139
+ };
2140
+ }
2141
+ function factoryNames(input) {
2142
+ const names = featureArtifactNames(input, "Factory");
2143
+ const factoryExportName = `${names.artifact.camel}Factory`;
2144
+ assertGeneratedIdentifiers(input, "Factory name", [factoryExportName]);
2145
+ return {
2146
+ ...names,
2147
+ factoryName: names.stableName,
2148
+ factoryExportName,
2149
+ };
2150
+ }
2151
+ function seedNames(input) {
2152
+ const names = featureArtifactNames(input, "Seed");
2153
+ const seedExportName = `${names.artifact.camel}Seed`;
2154
+ assertGeneratedIdentifiers(input, "Seed name", [seedExportName]);
2155
+ return {
2156
+ ...names,
2157
+ seedName: names.stableName,
2158
+ seedExportName,
2159
+ };
2160
+ }
2161
+ function notificationNames(input) {
2162
+ const names = featureArtifactNames(input, "Notification");
2163
+ const notificationExportName = `${names.featureSingularPascal}${names.artifact.pascal}Notification`;
2164
+ assertGeneratedIdentifiers(input, "Notification name", [
2165
+ notificationExportName,
2166
+ ]);
2167
+ return {
2168
+ ...names,
2169
+ notificationName: names.stableName,
2170
+ notificationExportName,
2171
+ payloadSchemaName: `${notificationExportName}PayloadSchema`,
2172
+ payloadTypeName: `${notificationExportName}Payload`,
2173
+ };
2174
+ }
2175
+ function listenerNames(input, eventInput) {
2176
+ const names = featureArtifactNames(input, "Listener");
2177
+ const listenerExportName = names.artifact.camel;
2178
+ assertGeneratedIdentifiers(input, "Listener name", [listenerExportName]);
2179
+ return {
2180
+ ...names,
2181
+ listenerName: names.stableName,
2182
+ listenerExportName,
1508
2183
  event: eventNames(eventInput),
1509
2184
  };
1510
2185
  }
@@ -1522,6 +2197,18 @@ function scheduleNames(input, options) {
1522
2197
  timezone: options.timezone?.trim() || undefined,
1523
2198
  };
1524
2199
  }
2200
+ function uploadNames(input) {
2201
+ const names = featureArtifactNames(input, "Upload");
2202
+ const uploadExportName = `${names.artifact.pascal}Upload`;
2203
+ assertGeneratedIdentifiers(input, "Upload name", [uploadExportName]);
2204
+ return {
2205
+ ...names,
2206
+ uploadName: names.stableName,
2207
+ uploadExportName,
2208
+ metadataSchemaName: `${uploadExportName}MetadataSchema`,
2209
+ metadataTypeName: `${uploadExportName}Metadata`,
2210
+ };
2211
+ }
1525
2212
  function normalizeEventReference(input) {
1526
2213
  const trimmed = input.trim();
1527
2214
  if (trimmed.includes("/"))
@@ -1649,59 +2336,119 @@ function pascalCase(value) {
1649
2336
  const camel = camelCase(value);
1650
2337
  return `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
1651
2338
  }
1652
- function resourceFiles(names, config, persistence = "memory") {
2339
+ function constantCase(value) {
2340
+ return value.replaceAll("-", "_").toUpperCase();
2341
+ }
2342
+ function resourceFiles(names, config, persistence = "memory", mode = "feature", options = {
2343
+ auth: false,
2344
+ tenant: false,
2345
+ events: false,
2346
+ softDelete: false,
2347
+ }) {
1653
2348
  const useCaseDir = resourceUseCaseDir(names, config);
1654
2349
  const infraDir = infrastructureDir(config);
1655
2350
  const featureDir = resourceFeatureDir(names, config);
1656
2351
  const files = [
1657
2352
  {
1658
2353
  path: resourceContractFilePath(names, config),
1659
- content: contractFile(names, config),
2354
+ content: contractFile(names, config, mode, options),
1660
2355
  },
1661
2356
  {
1662
- path: path.join(useCaseDir, "schemas.ts"),
1663
- content: schemasFile(names),
2357
+ path: path.join(featureDir, "schemas.ts"),
2358
+ content: schemasFile(names, mode, options),
1664
2359
  },
1665
2360
  {
1666
2361
  path: path.join(useCaseDir, `list-${names.pluralKebab}.ts`),
1667
- content: listUseCaseFile(names, config),
2362
+ content: listUseCaseFile(names, config, options),
1668
2363
  },
1669
2364
  {
1670
2365
  path: path.join(useCaseDir, `create-${names.singularKebab}.ts`),
1671
- content: createUseCaseFile(names, config),
2366
+ content: createUseCaseFile(names, config, options),
1672
2367
  },
2368
+ ...(mode === "resource"
2369
+ ? [
2370
+ {
2371
+ path: path.join(useCaseDir, `get-${names.singularKebab}.ts`),
2372
+ content: getUseCaseFile(names, config, options),
2373
+ },
2374
+ {
2375
+ path: path.join(useCaseDir, `update-${names.singularKebab}.ts`),
2376
+ content: updateUseCaseFile(names, config, options),
2377
+ },
2378
+ {
2379
+ path: path.join(useCaseDir, `delete-${names.singularKebab}.ts`),
2380
+ content: deleteUseCaseFile(names, config, options),
2381
+ },
2382
+ {
2383
+ path: path.join(featureDir, "policy.ts"),
2384
+ content: resourcePolicyFile(names, config, options),
2385
+ },
2386
+ ]
2387
+ : []),
2388
+ ...(mode === "resource" && options.events
2389
+ ? [
2390
+ {
2391
+ path: path.join(featureDir, "domain/events/index.ts"),
2392
+ content: resourceEventsFile(names),
2393
+ },
2394
+ ]
2395
+ : []),
2396
+ ...(mode === "resource" && options.auth
2397
+ ? [
2398
+ {
2399
+ path: path.join(featureDir, "tests/policy.test.ts"),
2400
+ content: resourcePolicyTestFile(names, options),
2401
+ },
2402
+ ]
2403
+ : []),
1673
2404
  {
1674
2405
  path: path.join(useCaseDir, "index.ts"),
1675
- content: useCasesIndexFile(names),
2406
+ content: useCasesIndexFile(names, mode),
1676
2407
  },
1677
2408
  {
1678
2409
  path: resourcePortFilePath(names, config),
1679
- content: repositoryPortFile(names, config),
2410
+ content: repositoryPortFile(names, config, mode, options),
1680
2411
  },
1681
2412
  {
1682
2413
  path: path.join(infraDir, names.pluralKebab, `in-memory-${names.singularKebab}-repository.ts`),
1683
- content: inMemoryRepositoryFile(names, config),
2414
+ content: inMemoryRepositoryFile(names, config, mode, options),
1684
2415
  },
1685
2416
  {
1686
2417
  path: path.join(featureDir, "routes.ts"),
1687
- content: routeGroupFile(names, config),
2418
+ content: routeGroupFile(names, config, mode, options),
1688
2419
  },
1689
2420
  {
1690
2421
  path: resourceTestFilePath(names, config),
1691
- content: testFile(names, config),
2422
+ content: testFile(names, config, mode, options),
1692
2423
  },
1693
2424
  ];
1694
2425
  if (persistence === "drizzle") {
1695
2426
  files.push({
1696
2427
  path: drizzleResourceSchemaFilePath(names, config),
1697
- content: drizzleSchemaFile(names),
2428
+ content: drizzleSchemaFile(names, options),
1698
2429
  }, {
1699
2430
  path: drizzleResourceRepositoryFilePath(names, config),
1700
- content: drizzleRepositoryFile(names, config),
2431
+ content: drizzleRepositoryFile(names, config, mode, options),
1701
2432
  });
1702
2433
  }
1703
2434
  return files;
1704
2435
  }
2436
+ function featureUiComponentFiles(names, config) {
2437
+ const componentPath = featureUiComponentFilePath(names, config);
2438
+ return [
2439
+ {
2440
+ path: componentPath,
2441
+ content: featureUiComponentFile(names, config),
2442
+ },
2443
+ ];
2444
+ }
2445
+ function featureUiIndexFile(names, config) {
2446
+ return {
2447
+ path: featureUiIndexFilePath(names, config),
2448
+ content: `export { ${names.componentExportName} } from "./${names.componentFileName}";
2449
+ `,
2450
+ };
2451
+ }
1705
2452
  function contractFiles(names, config) {
1706
2453
  return [
1707
2454
  {
@@ -1767,6 +2514,38 @@ function jobFiles(names, config) {
1767
2514
  },
1768
2515
  ];
1769
2516
  }
2517
+ function taskFiles(names, config) {
2518
+ return [
2519
+ {
2520
+ path: taskFilePath(names, config),
2521
+ content: taskFile(names, config),
2522
+ },
2523
+ ];
2524
+ }
2525
+ function notificationFiles(names, config) {
2526
+ return [
2527
+ {
2528
+ path: notificationFilePath(names, config),
2529
+ content: notificationFile(names, config),
2530
+ },
2531
+ ];
2532
+ }
2533
+ function factoryFiles(names, config) {
2534
+ return [
2535
+ {
2536
+ path: factoryFilePath(names, config),
2537
+ content: factoryFile(names, config),
2538
+ },
2539
+ ];
2540
+ }
2541
+ function seedFiles(names, config) {
2542
+ return [
2543
+ {
2544
+ path: seedFilePath(names, config),
2545
+ content: seedFile(names, config),
2546
+ },
2547
+ ];
2548
+ }
1770
2549
  function listenerFiles(names, config) {
1771
2550
  return [
1772
2551
  {
@@ -1790,6 +2569,14 @@ function scheduleFiles(names, config, options) {
1790
2569
  }
1791
2570
  return files;
1792
2571
  }
2572
+ function uploadFiles(names, config) {
2573
+ return [
2574
+ {
2575
+ path: uploadFilePath(names, config),
2576
+ content: uploadFile(names, config),
2577
+ },
2578
+ ];
2579
+ }
1793
2580
  function portFilePath(names, config) {
1794
2581
  return path.join(path.dirname(config.paths.ports), `${names.kebab}.ts`);
1795
2582
  }
@@ -1811,6 +2598,19 @@ function drizzleResourceSchemaFilePath(names, config) {
1811
2598
  function drizzleResourceRepositoryFilePath(names, config) {
1812
2599
  return path.join(infrastructureDir(config), names.pluralKebab, `drizzle-${names.singularKebab}-repository.ts`);
1813
2600
  }
2601
+ function resourceSharedErrorsPath(config) {
2602
+ return path.join(config.paths.features, "shared", "errors.ts");
2603
+ }
2604
+ function resourceContractNames(names, mode) {
2605
+ const contractNames = [
2606
+ `list${names.pluralPascal}`,
2607
+ `create${names.singularPascal}`,
2608
+ ];
2609
+ if (mode === "resource") {
2610
+ contractNames.push(`get${names.singularPascal}`, `update${names.singularPascal}`, `delete${names.singularPascal}`);
2611
+ }
2612
+ return contractNames;
2613
+ }
1814
2614
  async function detectResourcePersistence(targetDir, config) {
1815
2615
  return (await fileExists(path.join(targetDir, drizzleRepositoriesPath(config))))
1816
2616
  ? "drizzle"
@@ -1819,6 +2619,19 @@ async function detectResourcePersistence(targetDir, config) {
1819
2619
  function resourceFeatureDir(names, config) {
1820
2620
  return path.join(config.paths.features, names.pluralKebab);
1821
2621
  }
2622
+ function featureUiDir(names, config) {
2623
+ return path.join(resourceFeatureDir(names, config), "components");
2624
+ }
2625
+ function featureUiComponentFilePath(names, config) {
2626
+ return path.join(featureUiDir(names, config), `${names.componentFileName}.tsx`);
2627
+ }
2628
+ function featureUiIndexFilePath(names, config) {
2629
+ return path.join(featureUiDir(names, config), "index.ts");
2630
+ }
2631
+ function clientIndexPath(config) {
2632
+ const appRoot = path.posix.dirname(config.paths.appContext);
2633
+ return path.join(appRoot === "." ? "" : appRoot, "client/index.ts");
2634
+ }
1822
2635
  function usesFeatureOwnedContracts(config) {
1823
2636
  return (directoryPath(config.paths.contracts) ===
1824
2637
  directoryPath(config.paths.features));
@@ -1853,6 +2666,9 @@ function resourceUseCaseDir(names, config) {
1853
2666
  function resourceUseCaseIndexPath(names, config) {
1854
2667
  return path.join(resourceUseCaseDir(names, config), "index.ts");
1855
2668
  }
2669
+ function resourceSchemaFilePath(names, config) {
2670
+ return path.join(resourceFeatureDir(names, config), "schemas.ts");
2671
+ }
1856
2672
  function resourcePortFilePath(names, config) {
1857
2673
  if (usesFeatureOwnedContracts(config)) {
1858
2674
  return path.join(resourceFeatureDir(names, config), "ports.ts");
@@ -1882,6 +2698,9 @@ function featureArtifactDir(names, kind, config) {
1882
2698
  if (kind === "events") {
1883
2699
  return path.join(featureDir, "domain", "events");
1884
2700
  }
2701
+ if (kind === "factories") {
2702
+ return path.join(featureDir, "tests", "factories");
2703
+ }
1885
2704
  return path.join(featureDir, kind);
1886
2705
  }
1887
2706
  function eventFilePath(names, config) {
@@ -1890,12 +2709,27 @@ function eventFilePath(names, config) {
1890
2709
  function jobFilePath(names, config) {
1891
2710
  return path.join(featureArtifactDir(names, "jobs", config), `${names.artifact.kebab}.ts`);
1892
2711
  }
2712
+ function taskFilePath(names, config) {
2713
+ return path.join(featureArtifactDir(names, "tasks", config), `${names.artifact.kebab}.ts`);
2714
+ }
2715
+ function factoryFilePath(names, config) {
2716
+ return path.join(featureArtifactDir(names, "factories", config), `${names.artifact.kebab}.ts`);
2717
+ }
2718
+ function seedFilePath(names, config) {
2719
+ return path.join(featureArtifactDir(names, "seeds", config), `${names.artifact.kebab}.ts`);
2720
+ }
2721
+ function notificationFilePath(names, config) {
2722
+ return path.join(featureArtifactDir(names, "notifications", config), `${names.artifact.kebab}.ts`);
2723
+ }
1893
2724
  function listenerFilePath(names, config) {
1894
2725
  return path.join(featureArtifactDir(names, "listeners", config), `${names.artifact.kebab}.ts`);
1895
2726
  }
1896
2727
  function scheduleFilePath(names, config) {
1897
2728
  return path.join(featureArtifactDir(names, "schedules", config), `${names.artifact.kebab}.ts`);
1898
2729
  }
2730
+ function uploadFilePath(names, config) {
2731
+ return path.join(featureArtifactDir(names, "uploads", config), `${names.artifact.kebab}.ts`);
2732
+ }
1899
2733
  function scheduleRouteFilePath(names, config) {
1900
2734
  return path.join(config.paths.routes, "cron", names.feature.kebab, names.artifact.kebab, "route.ts");
1901
2735
  }
@@ -1927,33 +2761,83 @@ async function updateUseCaseIndex(targetDir, file, options) {
1927
2761
  await writeFile(destination, next);
1928
2762
  return "updated";
1929
2763
  }
2764
+ async function updateFeatureUiIndex(targetDir, file, options) {
2765
+ const destination = path.join(targetDir, file.path);
2766
+ const existing = await readOptionalFile(destination);
2767
+ if (existing === undefined) {
2768
+ if (!options.dryRun) {
2769
+ await mkdir(path.dirname(destination), { recursive: true });
2770
+ await writeFile(destination, file.content);
2771
+ }
2772
+ return "created";
2773
+ }
2774
+ if (existing.includes(file.content.trim())) {
2775
+ return "skipped";
2776
+ }
2777
+ const next = `${existing.trimEnd()}\n${file.content}`;
2778
+ if (!options.dryRun)
2779
+ await writeFile(destination, next);
2780
+ return "updated";
2781
+ }
1930
2782
  function featureArtifactIndexFile(kind, names, config) {
1931
2783
  const artifactPath = kind === "event"
1932
2784
  ? eventFilePath(names, config)
1933
2785
  : kind === "job"
1934
2786
  ? jobFilePath(names, config)
1935
- : kind === "listener"
1936
- ? listenerFilePath(names, config)
1937
- : scheduleFilePath(names, config);
2787
+ : kind === "task"
2788
+ ? taskFilePath(names, config)
2789
+ : kind === "factory"
2790
+ ? factoryFilePath(names, config)
2791
+ : kind === "seed"
2792
+ ? seedFilePath(names, config)
2793
+ : kind === "notification"
2794
+ ? notificationFilePath(names, config)
2795
+ : kind === "listener"
2796
+ ? listenerFilePath(names, config)
2797
+ : kind === "schedule"
2798
+ ? scheduleFilePath(names, config)
2799
+ : uploadFilePath(names, config);
1938
2800
  const registrySuffix = kind === "event"
1939
2801
  ? "Events"
1940
2802
  : kind === "job"
1941
2803
  ? "Jobs"
1942
- : kind === "listener"
1943
- ? "Listeners"
1944
- : "Schedules";
2804
+ : kind === "task"
2805
+ ? "Tasks"
2806
+ : kind === "factory"
2807
+ ? "Factories"
2808
+ : kind === "seed"
2809
+ ? "Seeds"
2810
+ : kind === "notification"
2811
+ ? "Notifications"
2812
+ : kind === "listener"
2813
+ ? "Listeners"
2814
+ : kind === "schedule"
2815
+ ? "Schedules"
2816
+ : "Uploads";
1945
2817
  const importName = kind === "event"
1946
2818
  ? names.eventExportName
1947
2819
  : kind === "job"
1948
2820
  ? names.jobExportName
1949
- : kind === "listener"
1950
- ? names.listenerExportName
1951
- : names.scheduleExportName;
2821
+ : kind === "task"
2822
+ ? names.taskExportName
2823
+ : kind === "factory"
2824
+ ? names.factoryExportName
2825
+ : kind === "seed"
2826
+ ? names.seedExportName
2827
+ : kind === "notification"
2828
+ ? names.notificationExportName
2829
+ : kind === "listener"
2830
+ ? names.listenerExportName
2831
+ : kind === "schedule"
2832
+ ? names.scheduleExportName
2833
+ : names.uploadExportName;
1952
2834
  return {
1953
2835
  path: path.join(path.dirname(artifactPath), "index.ts"),
2836
+ kind,
1954
2837
  importName,
1955
2838
  artifactPath,
1956
2839
  registryName: `${names.featureSingularCamel}${registrySuffix}`,
2840
+ registryEntryName: names.artifact.camel,
1957
2841
  };
1958
2842
  }
1959
2843
  async function updateFeatureArtifactIndex(targetDir, file, options) {
@@ -1961,9 +2845,20 @@ async function updateFeatureArtifactIndex(targetDir, file, options) {
1961
2845
  const existing = await readOptionalFile(destination);
1962
2846
  const specifier = relativeModule(file.path, file.artifactPath);
1963
2847
  const importLine = `import { ${file.importName} } from "${specifier}";`;
2848
+ const defineUploadsImport = `import { defineUploads } from "@beignet/core/uploads";`;
1964
2849
  const exportLine = `export { ${file.importName} } from "${specifier}";`;
1965
2850
  if (existing === undefined) {
1966
- const content = `${importLine}
2851
+ const content = file.kind === "upload"
2852
+ ? `${defineUploadsImport}
2853
+ ${importLine}
2854
+
2855
+ ${exportLine}
2856
+
2857
+ export const ${file.registryName} = defineUploads({
2858
+ \t${file.registryEntryName}: ${file.importName},
2859
+ });
2860
+ `
2861
+ : `${importLine}
1967
2862
 
1968
2863
  ${exportLine}
1969
2864
 
@@ -1985,12 +2880,101 @@ export const ${file.registryName} = [${file.importName}] as const;
1985
2880
  ? `${next.slice(0, registryExport.index).trimEnd()}\n${exportLine}\n\n${next.slice(registryExport.index)}`
1986
2881
  : `${next.trimEnd()}\n${exportLine}\n`;
1987
2882
  }
1988
- const registry = arrayInitializerInfo(next, file.registryName);
2883
+ if (file.kind === "upload") {
2884
+ const registry = defineUploadsInitializerInfo(next, file.registryName);
2885
+ const arrayRegistry = arrayInitializerInfo(next, file.registryName);
2886
+ if (registry) {
2887
+ if (!next.includes(defineUploadsImport)) {
2888
+ next = insertAfterImports(next, defineUploadsImport);
2889
+ }
2890
+ if (!registry.text.includes(`${file.registryEntryName}:`)) {
2891
+ next = `${next.slice(0, registry.end)}\t${file.registryEntryName}: ${file.importName},\n${next.slice(registry.end)}`;
2892
+ }
2893
+ }
2894
+ else if (arrayRegistry) {
2895
+ if (!identifiersFromArrayExpression(arrayRegistry.text).has(file.importName)) {
2896
+ const nextArray = appendToArrayExpression(arrayRegistry.text, [
2897
+ file.importName,
2898
+ ]);
2899
+ next = `${next.slice(0, arrayRegistry.start)}${nextArray}${next.slice(arrayRegistry.end)}`;
2900
+ }
2901
+ }
2902
+ else {
2903
+ if (!next.includes(defineUploadsImport)) {
2904
+ next = insertAfterImports(next, defineUploadsImport);
2905
+ }
2906
+ next = `${next.trimEnd()}\n\nexport const ${file.registryName} = defineUploads({\n\t${file.registryEntryName}: ${file.importName},\n});\n`;
2907
+ }
2908
+ }
2909
+ else {
2910
+ const registry = arrayInitializerInfo(next, file.registryName);
2911
+ if (!registry) {
2912
+ next = `${next.trimEnd()}\n\nexport const ${file.registryName} = [${file.importName}] as const;\n`;
2913
+ }
2914
+ else if (!identifiersFromArrayExpression(registry.text).has(file.importName)) {
2915
+ const nextArray = appendToArrayExpression(registry.text, [
2916
+ file.importName,
2917
+ ]);
2918
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
2919
+ }
2920
+ }
2921
+ if (next === existing)
2922
+ return "skipped";
2923
+ if (!options.dryRun)
2924
+ await writeFile(destination, next);
2925
+ return "updated";
2926
+ }
2927
+ async function updateTaskRegistry(targetDir, names, config, options) {
2928
+ const destination = path.join(targetDir, config.paths.tasks);
2929
+ const existing = await readOptionalFile(destination);
2930
+ const featureTasksPath = path.join(featureArtifactDir(names, "tasks", config), "index.ts");
2931
+ const registryName = `${names.featureSingularCamel}Tasks`;
2932
+ const importLine = `import { ${registryName} } from "${relativeModule(config.paths.tasks, featureTasksPath)}";`;
2933
+ const defineTasksImport = `import { defineTasks } from "@beignet/core/tasks";`;
2934
+ const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
2935
+ const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
2936
+ const serverImport = `import { server } from "${relativeModule(config.paths.tasks, config.paths.server)}";`;
2937
+ const registryEntry = `...${registryName}`;
2938
+ if (existing === undefined) {
2939
+ const content = `${defineTasksImport}
2940
+ ${serviceActorImport}
2941
+ ${appContextImport}
2942
+ ${importLine}
2943
+ ${serverImport}
2944
+
2945
+ export const tasks = defineTasks([
2946
+ \t${registryEntry},
2947
+ ] as const);
2948
+
2949
+ export async function createTaskContext(): Promise<AppContext> {
2950
+ \treturn server.createServiceContext({
2951
+ \t\tactor: createServiceActor("beignet-cli"),
2952
+ \t});
2953
+ }
2954
+
2955
+ export async function stopTaskContext(): Promise<void> {
2956
+ \tawait server.stop();
2957
+ }
2958
+ `;
2959
+ if (!options.dryRun) {
2960
+ await mkdir(path.dirname(destination), { recursive: true });
2961
+ await writeFile(destination, content);
2962
+ }
2963
+ return "created";
2964
+ }
2965
+ let next = existing;
2966
+ if (!next.includes(defineTasksImport)) {
2967
+ next = insertAfterImports(next, defineTasksImport);
2968
+ }
2969
+ if (!next.includes(importLine)) {
2970
+ next = insertAfterImports(next, importLine);
2971
+ }
2972
+ const registry = arrayInitializerInfo(next, "tasks");
1989
2973
  if (!registry) {
1990
- next = `${next.trimEnd()}\n\nexport const ${file.registryName} = [${file.importName}] as const;\n`;
2974
+ next = `${next.trimEnd()}\n\nexport const tasks = defineTasks([\n\t${registryEntry},\n] as const);\n`;
1991
2975
  }
1992
- else if (!identifiersFromArrayExpression(registry.text).has(file.importName)) {
1993
- const nextArray = appendToArrayExpression(registry.text, [file.importName]);
2976
+ else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
2977
+ const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
1994
2978
  next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
1995
2979
  }
1996
2980
  if (next === existing)
@@ -2015,172 +2999,557 @@ function arrayInitializerInfo(source, constName) {
2015
2999
  end: closeBracket + 1,
2016
3000
  };
2017
3001
  }
2018
- function schemasFile(names) {
3002
+ function defineUploadsInitializerInfo(source, constName) {
3003
+ const match = new RegExp(`\\bconst\\s+${constName}\\s*=\\s*defineUploads\\s*\\(`).exec(source);
3004
+ if (!match)
3005
+ return undefined;
3006
+ const openBrace = source.indexOf("{", match.index);
3007
+ if (openBrace === -1)
3008
+ return undefined;
3009
+ const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
3010
+ if (closeBrace === -1)
3011
+ return undefined;
3012
+ return {
3013
+ text: source.slice(openBrace, closeBrace + 1),
3014
+ start: openBrace,
3015
+ end: closeBrace,
3016
+ };
3017
+ }
3018
+ function schemasFile(names, mode, options) {
2019
3019
  return `import { z } from "zod";
2020
3020
 
2021
3021
  export const ${names.singularPascal}Schema = z.object({
2022
3022
  id: z.string().uuid(),
2023
- name: z.string().min(1),
3023
+ ${options.tenant ? `\ttenantId: z.string().min(1),\n` : ""} name: z.string().min(1),
3024
+ version: z.number().int().min(1),
2024
3025
  createdAt: z.string().datetime(),
3026
+ updatedAt: z.string().datetime(),
2025
3027
  });
2026
3028
 
2027
- export const List${names.pluralPascal}InputSchema = z.object({
2028
- limit: z.coerce.number().int().min(1).max(100).default(20),
2029
- offset: z.coerce.number().int().min(0).default(0),
3029
+ export const ${names.singularPascal}SortBySchema = z.enum(["createdAt", "name"]);
3030
+ export const ${names.singularPascal}SortDirectionSchema = z.enum(["asc", "desc"]);
3031
+
3032
+ const ${names.singularPascal}CursorPayloadSchema = z.object({
3033
+ sortBy: ${names.singularPascal}SortBySchema,
3034
+ sortDirection: ${names.singularPascal}SortDirectionSchema,
3035
+ sortValue: z.string(),
3036
+ id: z.string().uuid(),
2030
3037
  });
2031
3038
 
3039
+ export type ${names.singularPascal}SortBy = z.infer<
3040
+ typeof ${names.singularPascal}SortBySchema
3041
+ >;
3042
+ export type ${names.singularPascal}SortDirection = z.infer<
3043
+ typeof ${names.singularPascal}SortDirectionSchema
3044
+ >;
3045
+ export type ${names.singularPascal}Cursor = z.infer<
3046
+ typeof ${names.singularPascal}CursorPayloadSchema
3047
+ >;
3048
+
3049
+ function encodeBase64Url(value: string): string {
3050
+ const bytes = new TextEncoder().encode(value);
3051
+ let binary = "";
3052
+ for (const byte of bytes) binary += String.fromCharCode(byte);
3053
+
3054
+ return btoa(binary)
3055
+ .replaceAll("+", "-")
3056
+ .replaceAll("/", "_")
3057
+ .replaceAll("=", "");
3058
+ }
3059
+
3060
+ function decodeBase64Url(value: string): string {
3061
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
3062
+ const padding = "=".repeat((4 - (base64.length % 4)) % 4);
3063
+ const binary = atob(base64 + padding);
3064
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
3065
+
3066
+ return new TextDecoder().decode(bytes);
3067
+ }
3068
+
3069
+ export function encode${names.singularPascal}Cursor(cursor: ${names.singularPascal}Cursor): string {
3070
+ return encodeBase64Url(JSON.stringify(cursor));
3071
+ }
3072
+
3073
+ export function decode${names.singularPascal}Cursor(cursor: string): ${names.singularPascal}Cursor {
3074
+ return ${names.singularPascal}CursorPayloadSchema.parse(
3075
+ JSON.parse(decodeBase64Url(cursor)),
3076
+ );
3077
+ }
3078
+
3079
+ export const List${names.pluralPascal}InputSchema = z
3080
+ .object({
3081
+ limit: z.coerce.number().int().min(1).max(100).default(20),
3082
+ cursor: z.string().nullable().default(null),
3083
+ name: z.string().trim().min(1).max(120).optional(),
3084
+ sortBy: ${names.singularPascal}SortBySchema.default("createdAt"),
3085
+ sortDirection: ${names.singularPascal}SortDirectionSchema.default("desc"),
3086
+ })
3087
+ .superRefine((input, ctx) => {
3088
+ if (!input.cursor) return;
3089
+
3090
+ try {
3091
+ const cursor = decode${names.singularPascal}Cursor(input.cursor);
3092
+ if (
3093
+ cursor.sortBy !== input.sortBy ||
3094
+ cursor.sortDirection !== input.sortDirection
3095
+ ) {
3096
+ ctx.addIssue({
3097
+ code: z.ZodIssueCode.custom,
3098
+ message: "cursor does not match the requested sort.",
3099
+ path: ["cursor"],
3100
+ });
3101
+ }
3102
+ } catch {
3103
+ ctx.addIssue({
3104
+ code: z.ZodIssueCode.custom,
3105
+ message: "cursor is invalid.",
3106
+ path: ["cursor"],
3107
+ });
3108
+ }
3109
+ });
3110
+
2032
3111
  export const List${names.pluralPascal}OutputSchema = z.object({
2033
- ${names.pluralCamel}: z.array(${names.singularPascal}Schema),
2034
- total: z.number().int().min(0),
2035
- limit: z.number().int().min(1),
2036
- offset: z.number().int().min(0),
3112
+ items: z.array(${names.singularPascal}Schema),
3113
+ page: z.object({
3114
+ kind: z.literal("cursor"),
3115
+ limit: z.number().int().min(1),
3116
+ cursor: z.string().nullable(),
3117
+ nextCursor: z.string().nullable(),
3118
+ hasMore: z.boolean(),
3119
+ }),
2037
3120
  });
2038
3121
 
2039
3122
  export const Create${names.singularPascal}InputSchema = z.object({
2040
3123
  name: z.string().min(1).max(120),
2041
3124
  });
3125
+ ${mode === "resource"
3126
+ ? `
3127
+ export const ${names.singularPascal}IdInputSchema = z.object({
3128
+ id: z.string().uuid(),
3129
+ });
2042
3130
 
3131
+ export const Update${names.singularPascal}BodySchema = z.object({
3132
+ name: z.string().min(1).max(120),
3133
+ version: z.number().int().min(1),
3134
+ });
3135
+
3136
+ export const Update${names.singularPascal}InputSchema =
3137
+ ${names.singularPascal}IdInputSchema.merge(Update${names.singularPascal}BodySchema);
3138
+ `
3139
+ : ""}
2043
3140
  export type ${names.singularPascal} = z.infer<typeof ${names.singularPascal}Schema>;
2044
3141
  export type Create${names.singularPascal}Input = z.infer<
2045
3142
  typeof Create${names.singularPascal}InputSchema
2046
3143
  >;
3144
+ ${mode === "resource"
3145
+ ? `
3146
+ export type ${names.singularPascal}IdInput = z.infer<
3147
+ typeof ${names.singularPascal}IdInputSchema
3148
+ >;
3149
+ export type Update${names.singularPascal}Body = z.infer<
3150
+ typeof Update${names.singularPascal}BodySchema
3151
+ >;
3152
+ export type Update${names.singularPascal}Input = z.infer<
3153
+ typeof Update${names.singularPascal}InputSchema
3154
+ >;
3155
+ `
3156
+ : ""}
2047
3157
  export type List${names.pluralPascal}Input = z.infer<
2048
3158
  typeof List${names.pluralPascal}InputSchema
2049
3159
  >;
2050
3160
  `;
2051
3161
  }
2052
- function listUseCaseFile(names, config) {
3162
+ function listUseCaseFile(names, config, options) {
2053
3163
  const filePath = path.join(resourceUseCaseDir(names, config), `list-${names.pluralKebab}.ts`);
2054
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3164
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3165
+ import { normalizeCursorPage } from "@beignet/core/pagination";
3166
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2055
3167
  import {
3168
+ decode${names.singularPascal}Cursor,
2056
3169
  List${names.pluralPascal}InputSchema,
2057
3170
  List${names.pluralPascal}OutputSchema,
2058
- } from "./schemas";
3171
+ } from "../schemas";
2059
3172
 
2060
3173
  export const list${names.pluralPascal}UseCase = useCase
2061
3174
  .query("${names.pluralCamel}.list")
2062
3175
  .input(List${names.pluralPascal}InputSchema)
2063
3176
  .output(List${names.pluralPascal}OutputSchema)
2064
3177
  .run(async ({ ctx, input }) => {
2065
- const result = await ctx.ports.${names.pluralCamel}.list(input);
2066
- return {
2067
- ${names.pluralCamel}: result.${names.pluralCamel},
2068
- total: result.total,
2069
- limit: input.limit,
2070
- offset: input.offset,
2071
- };
3178
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to list ${names.pluralKebab}.");\n\t\t}\n\n` : ""} const page = normalizeCursorPage(input, {
3179
+ defaultLimit: 20,
3180
+ maxLimit: 100,
3181
+ });
3182
+
3183
+ return ctx.ports.${names.pluralCamel}.list({
3184
+ page,
3185
+ cursor: page.cursor ? decode${names.singularPascal}Cursor(page.cursor) : null,
3186
+ name: input.name,
3187
+ sortBy: input.sortBy,
3188
+ sortDirection: input.sortDirection,
3189
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
2072
3190
  });
2073
3191
  `;
2074
3192
  }
2075
- function createUseCaseFile(names, config) {
3193
+ function createUseCaseFile(names, config, options) {
2076
3194
  const filePath = path.join(resourceUseCaseDir(names, config), `create-${names.singularKebab}.ts`);
2077
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3195
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3196
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2078
3197
  import {
2079
3198
  Create${names.singularPascal}InputSchema,
2080
3199
  ${names.singularPascal}Schema,
2081
- } from "./schemas";
3200
+ } from "../schemas";
3201
+ ${options.events ? `import { ${names.singularPascal}Created } from "../domain/events";\n` : ""}
2082
3202
 
2083
3203
  export const create${names.singularPascal}UseCase = useCase
2084
3204
  .command("${names.pluralCamel}.create")
2085
3205
  .input(Create${names.singularPascal}InputSchema)
2086
3206
  .output(${names.singularPascal}Schema)
2087
- .run(async ({ ctx, input }) => ctx.ports.${names.pluralCamel}.create(input));
3207
+ .run(async ({ ctx, input }) => {
3208
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to create ${names.singularKebab}.");\n\t\t}\n\n` : ""}${options.auth ? `\t\tawait ctx.gate.authorize("${names.pluralCamel}.create");\n\n` : ""} const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.create({
3209
+ ...input,
3210
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
3211
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Created, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
3212
+ return ${names.singularCamel};
3213
+ });
2088
3214
  `;
2089
3215
  }
2090
- function useCasesIndexFile(names) {
3216
+ function getUseCaseFile(names, config, options) {
3217
+ const filePath = path.join(resourceUseCaseDir(names, config), `get-${names.singularKebab}.ts`);
3218
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3219
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3220
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3221
+ import {
3222
+ ${names.singularPascal}IdInputSchema,
3223
+ ${names.singularPascal}Schema,
3224
+ } from "../schemas";
3225
+
3226
+ export const get${names.singularPascal}UseCase = useCase
3227
+ .query("${names.pluralCamel}.get")
3228
+ .input(${names.singularPascal}IdInputSchema)
3229
+ .output(${names.singularPascal}Schema)
3230
+ .run(async ({ ctx, input }) => {
3231
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to get ${names.singularKebab}.");\n\t\t}\n\n` : ""} const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.findById(input.id${options.tenant ? ", { tenantId }" : ""});
3232
+ if (!${names.singularCamel}) {
3233
+ throw appError("${names.singularPascal}NotFound", {
3234
+ details: { id: input.id },
3235
+ });
3236
+ }
3237
+
3238
+ return ${names.singularCamel};
3239
+ });
3240
+ `;
3241
+ }
3242
+ function updateUseCaseFile(names, config, options) {
3243
+ const filePath = path.join(resourceUseCaseDir(names, config), `update-${names.singularKebab}.ts`);
3244
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3245
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3246
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3247
+ ${options.events ? `import { ${names.singularPascal}Updated } from "../domain/events";\n` : ""}import {
3248
+ Update${names.singularPascal}InputSchema,
3249
+ ${names.singularPascal}Schema,
3250
+ } from "../schemas";
3251
+
3252
+ export const update${names.singularPascal}UseCase = useCase
3253
+ .command("${names.pluralCamel}.update")
3254
+ .input(Update${names.singularPascal}InputSchema)
3255
+ .output(${names.singularPascal}Schema)
3256
+ .run(async ({ ctx, input }) => {
3257
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to update ${names.singularKebab}.");\n\t\t}\n\n` : ""} const existing = await ctx.ports.${names.pluralCamel}.findById(input.id${options.tenant ? ", { tenantId }" : ""});
3258
+ if (!existing) {
3259
+ throw appError("${names.singularPascal}NotFound", {
3260
+ details: { id: input.id },
3261
+ });
3262
+ }
3263
+ ${options.auth ? `\t\tawait ctx.gate.authorize("${names.pluralCamel}.update", existing);\n\n` : ""} if (existing.version !== input.version) {
3264
+ throw appError("${names.singularPascal}Conflict", {
3265
+ details: { id: input.id, expectedVersion: existing.version },
3266
+ });
3267
+ }
3268
+
3269
+ const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.update({
3270
+ ...input,
3271
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
3272
+ if (!${names.singularCamel}) {
3273
+ throw appError("${names.singularPascal}Conflict", {
3274
+ details: { id: input.id, expectedVersion: existing.version },
3275
+ });
3276
+ }
3277
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Updated, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
3278
+ return ${names.singularCamel};
3279
+ });
3280
+ `;
3281
+ }
3282
+ function deleteUseCaseFile(names, config, options) {
3283
+ const filePath = path.join(resourceUseCaseDir(names, config), `delete-${names.singularKebab}.ts`);
3284
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3285
+ import { z } from "zod";
3286
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3287
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3288
+ ${options.events ? `import { ${names.singularPascal}Deleted } from "../domain/events";\n` : ""}import { ${names.singularPascal}IdInputSchema } from "../schemas";
3289
+
3290
+ export const delete${names.singularPascal}UseCase = useCase
3291
+ .command("${names.pluralCamel}.delete")
3292
+ .input(${names.singularPascal}IdInputSchema)
3293
+ .output(z.void())
3294
+ .run(async ({ ctx, input }) => {
3295
+ ${options.tenant ? `\t\tconst tenantId = ctx.tenant?.id;\n\t\tif (!tenantId) {\n\t\t\tthrow new Error("Tenant is required to delete ${names.singularKebab}.");\n\t\t}\n\n` : ""}${options.auth ? `\t\tconst existing = await ctx.ports.${names.pluralCamel}.findById(input.id${options.tenant ? ", { tenantId }" : ""});\n\t\tif (!existing) {\n\t\t\tthrow appError("${names.singularPascal}NotFound", {\n\t\t\t\tdetails: { id: input.id },\n\t\t\t});\n\t\t}\n\t\tawait ctx.gate.authorize("${names.pluralCamel}.delete", existing);\n\n` : ""} const deleted = await ctx.ports.${names.pluralCamel}.delete(input.id${options.tenant ? ", { tenantId }" : ""});
3296
+ if (!deleted) {
3297
+ throw appError("${names.singularPascal}NotFound", {
3298
+ details: { id: input.id },
3299
+ });
3300
+ }
3301
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Deleted, {\n\t\t\tid: input.id,\n\t\t});\n` : ""} });
3302
+ `;
3303
+ }
3304
+ function useCasesIndexFile(names, mode) {
2091
3305
  return `export { create${names.singularPascal}UseCase } from "./create-${names.singularKebab}";
2092
- export { list${names.pluralPascal}UseCase } from "./list-${names.pluralKebab}";
2093
- export {
3306
+ ${mode === "resource" ? `export { delete${names.singularPascal}UseCase } from "./delete-${names.singularKebab}";\nexport { get${names.singularPascal}UseCase } from "./get-${names.singularKebab}";\n` : ""}export { list${names.pluralPascal}UseCase } from "./list-${names.pluralKebab}";
3307
+ ${mode === "resource" ? `export { update${names.singularPascal}UseCase } from "./update-${names.singularKebab}";\n` : ""}export {
2094
3308
  Create${names.singularPascal}InputSchema,
2095
- List${names.pluralPascal}InputSchema,
3309
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n` : ""} List${names.pluralPascal}InputSchema,
2096
3310
  List${names.pluralPascal}OutputSchema,
2097
- ${names.singularPascal}Schema,
3311
+ ${mode === "resource" ? ` Update${names.singularPascal}BodySchema,\n Update${names.singularPascal}InputSchema,\n` : ""} ${names.singularPascal}Schema,
2098
3312
  type Create${names.singularPascal}Input,
2099
- type List${names.pluralPascal}Input,
3313
+ ${mode === "resource" ? ` type ${names.singularPascal}IdInput,\n type Update${names.singularPascal}Body,\n type Update${names.singularPascal}Input,\n` : ""} type List${names.pluralPascal}Input,
2100
3314
  type ${names.singularPascal},
2101
- } from "./schemas";
3315
+ } from "../schemas";
2102
3316
  `;
2103
3317
  }
2104
- function repositoryPortFile(names, config) {
3318
+ function repositoryPortFile(names, config, mode, options) {
2105
3319
  return `import type {
3320
+ CursorPage,
3321
+ CursorPageInfo,
3322
+ PageResult,
3323
+ SortDirection,
3324
+ } from "@beignet/core/pagination";
3325
+ import type {
2106
3326
  Create${names.singularPascal}Input,
2107
- List${names.pluralPascal}Input,
2108
- ${names.singularPascal},
2109
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3327
+ ${names.singularPascal}Cursor,
3328
+ ${names.singularPascal}SortBy,
3329
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3330
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2110
3331
 
2111
- export type List${names.pluralPascal}Result = {
2112
- ${names.pluralCamel}: ${names.singularPascal}[];
2113
- total: number;
2114
- };
3332
+ export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, CursorPageInfo>;
3333
+ export type List${names.pluralPascal}Query = {
3334
+ page: CursorPage;
3335
+ cursor: ${names.singularPascal}Cursor | null;
3336
+ name?: string;
3337
+ sortBy: ${names.singularPascal}SortBy;
3338
+ sortDirection: SortDirection;
3339
+ ${options.tenant ? `\ttenantId: string;\n` : ""}};
3340
+ ${options.tenant ? `export type ${names.singularPascal}TenantFilter = { tenantId: string };\nexport type Create${names.singularPascal}RepositoryInput = Create${names.singularPascal}Input & ${names.singularPascal}TenantFilter;\n${mode === "resource" ? `export type Update${names.singularPascal}RepositoryInput = Update${names.singularPascal}Input & ${names.singularPascal}TenantFilter;\n` : ""}` : ""}
2115
3341
 
2116
3342
  export interface ${names.singularPascal}Repository {
2117
- list(input: List${names.pluralPascal}Input): Promise<List${names.pluralPascal}Result>;
2118
- create(input: Create${names.singularPascal}Input): Promise<${names.singularPascal}>;
2119
- }
3343
+ list(query: List${names.pluralPascal}Query): Promise<List${names.pluralPascal}Result>;
3344
+ create(input: ${options.tenant ? `Create${names.singularPascal}RepositoryInput` : `Create${names.singularPascal}Input`}): Promise<${names.singularPascal}>;
3345
+ ${mode === "resource" ? ` findById(id: string${options.tenant ? `, filter: ${names.singularPascal}TenantFilter` : ""}): Promise<${names.singularPascal} | null>;\n update(input: ${options.tenant ? `Update${names.singularPascal}RepositoryInput` : `Update${names.singularPascal}Input`}): Promise<${names.singularPascal} | null>;\n delete(id: string${options.tenant ? `, filter: ${names.singularPascal}TenantFilter` : ""}): Promise<boolean>;\n` : ""}}
2120
3346
  `;
2121
3347
  }
2122
- function inMemoryRepositoryFile(names, config) {
3348
+ function inMemoryRepositoryFile(names, config, mode, options) {
2123
3349
  const repositoryPortPath = resourcePortFilePath(names, config);
2124
- return `import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3350
+ const recordType = options.softDelete
3351
+ ? `type ${names.singularPascal}Record = ${names.singularPascal} & {
3352
+ \tdeletedAt: string | null;
3353
+ };
3354
+
3355
+ `
3356
+ : "";
3357
+ const mapValueType = options.softDelete
3358
+ ? `${names.singularPascal}Record`
3359
+ : names.singularPascal;
3360
+ const activeFilter = options.softDelete
3361
+ ? `\t\t\t\t.filter((${names.singularCamel}) => ${names.singularCamel}.deletedAt === null)\n`
3362
+ : "";
3363
+ const newDeletedAtField = options.softDelete
3364
+ ? `\t\t\t\tdeletedAt: null,\n`
3365
+ : "";
3366
+ const findDeletedGuard = options.softDelete
3367
+ ? `\t\t\tif (${names.singularCamel}?.deletedAt !== null) return null;\n`
3368
+ : "";
3369
+ const updateDeletedGuard = options.softDelete
3370
+ ? " || existing.deletedAt !== null"
3371
+ : "";
3372
+ const deleteImplementation = options.softDelete
3373
+ ? `${options.tenant ? `\t\t\tconst existing = ${names.pluralCamel}.get(id);\n\t\t\tif (!existing || existing.tenantId !== filter.tenantId || existing.deletedAt !== null) return false;\n` : `\t\t\tconst existing = ${names.pluralCamel}.get(id);\n\t\t\tif (!existing || existing.deletedAt !== null) return false;\n`} const now = new Date().toISOString();
3374
+ ${names.pluralCamel}.set(id, { ...existing, deletedAt: now, updatedAt: now });
3375
+ return true;`
3376
+ : `${options.tenant ? `\t\t\tconst existing = ${names.pluralCamel}.get(id);\n\t\t\tif (existing?.tenantId !== filter.tenantId) return false;\n` : ""} return ${names.pluralCamel}.delete(id);`;
3377
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3378
+ import { cursorPageResult } from "@beignet/core/pagination";
3379
+ import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3380
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2125
3381
  import type {
2126
3382
  Create${names.singularPascal}Input,
2127
- List${names.pluralPascal}Input,
2128
- ${names.singularPascal},
2129
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3383
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3384
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
3385
+
3386
+ ${recordType}function to${names.singularPascal}(${names.singularCamel}: ${mapValueType}): ${names.singularPascal} {
3387
+ return {
3388
+ id: ${names.singularCamel}.id,
3389
+ ${options.tenant ? `\t\ttenantId: ${names.singularCamel}.tenantId,\n` : ""} name: ${names.singularCamel}.name,
3390
+ version: ${names.singularCamel}.version,
3391
+ createdAt: ${names.singularCamel}.createdAt,
3392
+ updatedAt: ${names.singularCamel}.updatedAt,
3393
+ };
3394
+ }
3395
+
3396
+ function compare${names.pluralPascal}(
3397
+ left: ${mapValueType},
3398
+ right: ${mapValueType},
3399
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3400
+ ): number {
3401
+ const leftValue = left[query.sortBy];
3402
+ const rightValue = right[query.sortBy];
3403
+ const fieldComparison =
3404
+ leftValue === rightValue ? left.id.localeCompare(right.id) : leftValue.localeCompare(rightValue);
3405
+
3406
+ return query.sortDirection === "asc" ? fieldComparison : -fieldComparison;
3407
+ }
3408
+
3409
+ function isAfter${names.singularPascal}Cursor(
3410
+ ${names.singularCamel}: ${mapValueType},
3411
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3412
+ ): boolean {
3413
+ if (!query.cursor) return true;
3414
+
3415
+ const sortValue = ${names.singularCamel}[query.sortBy];
3416
+ const fieldComparison =
3417
+ sortValue === query.cursor.sortValue
3418
+ ? ${names.singularCamel}.id.localeCompare(query.cursor.id)
3419
+ : sortValue.localeCompare(query.cursor.sortValue);
3420
+
3421
+ return query.sortDirection === "asc" ? fieldComparison > 0 : fieldComparison < 0;
3422
+ }
3423
+
3424
+ function cursorFor${names.singularPascal}(
3425
+ ${names.singularCamel}: ${mapValueType},
3426
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3427
+ ): string {
3428
+ return encode${names.singularPascal}Cursor({
3429
+ sortBy: query.sortBy,
3430
+ sortDirection: query.sortDirection,
3431
+ sortValue: ${names.singularCamel}[query.sortBy],
3432
+ id: ${names.singularCamel}.id,
3433
+ });
3434
+ }
2130
3435
 
2131
3436
  export function createInMemory${names.singularPascal}Repository(
2132
3437
  seed: ${names.singularPascal}[] = [],
2133
3438
  ): ${names.singularPascal}Repository {
2134
- const ${names.pluralCamel} = new Map(seed.map((${names.singularCamel}) => [${names.singularCamel}.id, ${names.singularCamel}]));
3439
+ const ${names.pluralCamel} = new Map<string, ${mapValueType}>(
3440
+ seed.map((${names.singularCamel}) => [
3441
+ ${names.singularCamel}.id,
3442
+ { ...${names.singularCamel}${options.softDelete ? ", deletedAt: null" : ""} },
3443
+ ]),
3444
+ );
2135
3445
 
2136
3446
  return {
2137
- async list(input: List${names.pluralPascal}Input) {
2138
- const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values()).sort(
2139
- (left, right) => right.createdAt.localeCompare(left.createdAt),
2140
- );
3447
+ async list(query) {
3448
+ const name = query.name?.toLocaleLowerCase();
3449
+ const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values())
3450
+ ${activeFilter}${options.tenant ? `\t\t\t\t.filter((${names.singularCamel}) => ${names.singularCamel}.tenantId === query.tenantId)\n` : ""} .filter((${names.singularCamel}) => !name || ${names.singularCamel}.name.toLocaleLowerCase().includes(name))
3451
+ .sort((left, right) => compare${names.pluralPascal}(left, right, query))
3452
+ .filter((${names.singularCamel}) => isAfter${names.singularPascal}Cursor(${names.singularCamel}, query));
3453
+ const pageItems = all${names.pluralPascal}.slice(0, query.page.limit);
3454
+ const nextCursor =
3455
+ all${names.pluralPascal}.length > query.page.limit && pageItems.length > 0
3456
+ ? cursorFor${names.singularPascal}(pageItems[pageItems.length - 1]!, query)
3457
+ : null;
2141
3458
 
2142
- return {
2143
- ${names.pluralCamel}: all${names.pluralPascal}.slice(
2144
- input.offset,
2145
- input.offset + input.limit,
2146
- ),
2147
- total: all${names.pluralPascal}.length,
2148
- };
3459
+ return cursorPageResult(
3460
+ pageItems.map(to${names.singularPascal}),
3461
+ query.page,
3462
+ nextCursor,
3463
+ );
2149
3464
  },
2150
- async create(input: Create${names.singularPascal}Input) {
3465
+ async create(input) {
3466
+ const now = new Date().toISOString();
2151
3467
  const ${names.singularCamel}: ${names.singularPascal} = {
2152
3468
  id: crypto.randomUUID(),
2153
- name: input.name,
2154
- createdAt: new Date().toISOString(),
3469
+ ${options.tenant ? `\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
3470
+ version: 1,
3471
+ createdAt: now,
3472
+ updatedAt: now,
2155
3473
  };
2156
- ${names.pluralCamel}.set(${names.singularCamel}.id, ${names.singularCamel});
3474
+ ${names.pluralCamel}.set(${names.singularCamel}.id, {
3475
+ ...${names.singularCamel},
3476
+ ${newDeletedAtField} });
2157
3477
  return ${names.singularCamel};
2158
3478
  },
3479
+ ${mode === "resource" ? ` async findById(id: string${options.tenant ? ", filter" : ""}) {\n const ${names.singularCamel} = ${names.pluralCamel}.get(id) ?? null;\n${options.tenant ? `\t\t\tif (${names.singularCamel}?.tenantId !== filter.tenantId) return null;\n` : ""}${findDeletedGuard} return ${names.singularCamel} ? to${names.singularPascal}(${names.singularCamel}) : null;\n },\n async update(input) {\n const existing = ${names.pluralCamel}.get(input.id);\n if (!existing${options.tenant ? " || existing.tenantId !== input.tenantId" : ""}${updateDeletedGuard} || existing.version !== input.version) return null;\n\n const next: ${mapValueType} = {\n ...existing,\n name: input.name,\n version: existing.version + 1,\n updatedAt: new Date().toISOString(),\n };\n ${names.pluralCamel}.set(input.id, next);\n return to${names.singularPascal}(next);\n },\n async delete(id: string${options.tenant ? ", filter" : ""}) {\n${deleteImplementation}\n },\n` : ""}
2159
3480
  };
2160
3481
  }
2161
3482
  `;
2162
3483
  }
2163
- function drizzleSchemaFile(names) {
2164
- return `import { sqliteTable, text } from "drizzle-orm/sqlite-core";
3484
+ function drizzleSchemaFile(names, options) {
3485
+ return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2165
3486
 
2166
3487
  export const ${names.pluralCamel} = sqliteTable("${names.pluralKebab.replaceAll("-", "_")}", {
2167
3488
  id: text("id").primaryKey(),
2168
- name: text("name").notNull(),
3489
+ ${options.tenant ? `\ttenantId: text("tenant_id").notNull(),\n` : ""} name: text("name").notNull(),
3490
+ version: integer("version").notNull(),
2169
3491
  createdAt: text("created_at").notNull(),
2170
- });
3492
+ updatedAt: text("updated_at").notNull(),
3493
+ ${options.softDelete ? `\tdeletedAt: text("deleted_at"),\n` : ""}});
2171
3494
  `;
2172
3495
  }
2173
- function drizzleRepositoryFile(names, config) {
3496
+ function drizzleResourceWhere(names, options, predicates) {
3497
+ const allPredicates = [
3498
+ ...predicates,
3499
+ ...(options.softDelete
3500
+ ? [`isNull(schema.${names.pluralCamel}.deletedAt)`]
3501
+ : []),
3502
+ ];
3503
+ if (allPredicates.length === 0)
3504
+ return "";
3505
+ if (allPredicates.length === 1)
3506
+ return allPredicates[0] ?? "";
3507
+ return `and(${allPredicates.join(", ")})`;
3508
+ }
3509
+ function drizzleRepositoryFile(names, config, mode, options) {
2174
3510
  const repositoryPortPath = resourcePortFilePath(names, config);
2175
3511
  const schemaPath = drizzleSchemaIndexPath(config);
2176
- return `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
2177
- import { count, desc } from "drizzle-orm";
3512
+ const findWhere = drizzleResourceWhere(names, options, [
3513
+ `eq(schema.${names.pluralCamel}.id, id)`,
3514
+ ...(options.tenant
3515
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
3516
+ : []),
3517
+ ]);
3518
+ const updateWhere = drizzleResourceWhere(names, options, [
3519
+ `eq(schema.${names.pluralCamel}.id, input.id)`,
3520
+ `eq(schema.${names.pluralCamel}.version, input.version)`,
3521
+ ...(options.tenant
3522
+ ? [`eq(schema.${names.pluralCamel}.tenantId, input.tenantId)`]
3523
+ : []),
3524
+ ]);
3525
+ const deleteWhere = drizzleResourceWhere(names, options, [
3526
+ `eq(schema.${names.pluralCamel}.id, id)`,
3527
+ ...(options.tenant
3528
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
3529
+ : []),
3530
+ ]);
3531
+ const drizzleImports = [
3532
+ "and",
3533
+ "asc",
3534
+ "desc",
3535
+ "eq",
3536
+ "gt",
3537
+ options.softDelete ? "isNull" : undefined,
3538
+ "lt",
3539
+ "or",
3540
+ "sql",
3541
+ "type SQL",
3542
+ ].filter((name) => Boolean(name));
3543
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3544
+ import { cursorPageResult } from "@beignet/core/pagination";
3545
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
3546
+ import { ${drizzleImports.join(", ")} } from "drizzle-orm";
2178
3547
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3548
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2179
3549
  import type {
2180
3550
  Create${names.singularPascal}Input,
2181
- List${names.pluralPascal}Input,
2182
- ${names.singularPascal},
2183
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3551
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3552
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2184
3553
  import * as schema from "${aliasModule(schemaPath)}";
2185
3554
 
2186
3555
  type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelect;
@@ -2188,38 +3557,104 @@ type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelec
2188
3557
  function to${names.singularPascal}(row: ${names.singularPascal}Row): ${names.singularPascal} {
2189
3558
  return {
2190
3559
  id: row.id,
2191
- name: row.name,
3560
+ ${options.tenant ? `\t\ttenantId: row.tenantId,\n` : ""} name: row.name,
3561
+ version: row.version,
2192
3562
  createdAt: row.createdAt,
3563
+ updatedAt: row.updatedAt,
2193
3564
  };
2194
3565
  }
2195
3566
 
3567
+ type List${names.pluralPascal}Query = Parameters<${names.singularPascal}Repository["list"]>[0];
3568
+
3569
+ function ${names.singularCamel}SortColumn(query: List${names.pluralPascal}Query) {
3570
+ return query.sortBy === "name" ? schema.${names.pluralCamel}.name : schema.${names.pluralCamel}.createdAt;
3571
+ }
3572
+
3573
+ function ${names.singularCamel}CursorFilter(
3574
+ query: List${names.pluralPascal}Query,
3575
+ ): SQL<unknown> | undefined {
3576
+ if (!query.cursor) return undefined;
3577
+
3578
+ const column = ${names.singularCamel}SortColumn(query);
3579
+ const compare = query.sortDirection === "asc" ? gt : lt;
3580
+
3581
+ return or(
3582
+ compare(column, query.cursor.sortValue),
3583
+ and(
3584
+ eq(column, query.cursor.sortValue),
3585
+ compare(schema.${names.pluralCamel}.id, query.cursor.id),
3586
+ ),
3587
+ ) as SQL<unknown>;
3588
+ }
3589
+
3590
+ function ${names.singularCamel}ListWhere(
3591
+ query: List${names.pluralPascal}Query,
3592
+ ): SQL<unknown> | undefined {
3593
+ const filters: SQL<unknown>[] = [
3594
+ ${options.tenant ? `\t\teq(schema.${names.pluralCamel}.tenantId, query.tenantId),\n` : ""}${options.softDelete ? `\t\tisNull(schema.${names.pluralCamel}.deletedAt),\n` : ""} ];
3595
+ const cursor = ${names.singularCamel}CursorFilter(query);
3596
+
3597
+ if (query.name) {
3598
+ const pattern = \`%\${query.name.toLocaleLowerCase()}%\`;
3599
+ filters.push(sql\`lower(\${schema.${names.pluralCamel}.name}) like \${pattern}\`);
3600
+ }
3601
+ if (cursor) filters.push(cursor);
3602
+
3603
+ return filters.length > 0 ? and(...filters) : undefined;
3604
+ }
3605
+
3606
+ function ${names.singularCamel}OrderBy(query: List${names.pluralPascal}Query) {
3607
+ const order = query.sortDirection === "asc" ? asc : desc;
3608
+
3609
+ return [order(${names.singularCamel}SortColumn(query)), order(schema.${names.pluralCamel}.id)] as const;
3610
+ }
3611
+
3612
+ function cursorFor${names.singularPascal}(
3613
+ row: ${names.singularPascal}Row,
3614
+ query: List${names.pluralPascal}Query,
3615
+ ): string {
3616
+ return encode${names.singularPascal}Cursor({
3617
+ sortBy: query.sortBy,
3618
+ sortDirection: query.sortDirection,
3619
+ sortValue: row[query.sortBy],
3620
+ id: row.id,
3621
+ });
3622
+ }
3623
+
2196
3624
  export function createDrizzle${names.singularPascal}Repository(
2197
3625
  db: DrizzleTursoDatabase<typeof schema>,
2198
3626
  ): ${names.singularPascal}Repository {
2199
3627
  return {
2200
- async list(input: List${names.pluralPascal}Input) {
3628
+ async list(query) {
3629
+ const where = ${names.singularCamel}ListWhere(query);
2201
3630
  const rows = await db
2202
3631
  .select()
2203
3632
  .from(schema.${names.pluralCamel})
2204
- .orderBy(desc(schema.${names.pluralCamel}.createdAt))
2205
- .limit(input.limit)
2206
- .offset(input.offset);
2207
- const [{ total }] = await db
2208
- .select({ total: count() })
2209
- .from(schema.${names.pluralCamel});
2210
-
2211
- return {
2212
- ${names.pluralCamel}: rows.map(to${names.singularPascal}),
2213
- total,
2214
- };
3633
+ .where(where)
3634
+ .orderBy(...${names.singularCamel}OrderBy(query))
3635
+ .limit(query.page.limit + 1);
3636
+ const pageRows = rows.slice(0, query.page.limit);
3637
+ const nextCursor =
3638
+ rows.length > query.page.limit && pageRows.length > 0
3639
+ ? cursorFor${names.singularPascal}(pageRows[pageRows.length - 1]!, query)
3640
+ : null;
3641
+
3642
+ return cursorPageResult(
3643
+ pageRows.map(to${names.singularPascal}),
3644
+ query.page,
3645
+ nextCursor,
3646
+ );
2215
3647
  },
2216
- async create(input: Create${names.singularPascal}Input) {
3648
+ async create(input) {
3649
+ const now = new Date().toISOString();
2217
3650
  const [row] = await db
2218
3651
  .insert(schema.${names.pluralCamel})
2219
3652
  .values({
2220
3653
  id: crypto.randomUUID(),
2221
- name: input.name,
2222
- createdAt: new Date().toISOString(),
3654
+ ${options.tenant ? `\t\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
3655
+ version: 1,
3656
+ createdAt: now,
3657
+ updatedAt: now,
2223
3658
  })
2224
3659
  .returning();
2225
3660
 
@@ -2229,17 +3664,20 @@ export function createDrizzle${names.singularPascal}Repository(
2229
3664
 
2230
3665
  return to${names.singularPascal}(row);
2231
3666
  },
3667
+ ${mode === "resource" ? ` async findById(id: string${options.tenant ? ", filter" : ""}) {\n const [row] = await db\n .select()\n .from(schema.${names.pluralCamel})\n .where(${findWhere})\n .limit(1);\n\n return row ? to${names.singularPascal}(row) : null;\n },\n async update(input) {\n const [row] = await db\n .update(schema.${names.pluralCamel})\n .set({\n name: input.name,\n version: input.version + 1,\n updatedAt: new Date().toISOString(),\n })\n .where(${updateWhere})\n .returning();\n\n return row ? to${names.singularPascal}(row) : null;\n },\n async delete(id: string${options.tenant ? ", filter" : ""}) {\n${options.softDelete ? `\t\t\tconst now = new Date().toISOString();\n\t\t\tconst rows = await db\n\t\t\t\t.update(schema.${names.pluralCamel})\n\t\t\t\t.set({ deletedAt: now, updatedAt: now })\n\t\t\t\t.where(${deleteWhere})\n\t\t\t\t.returning({ id: schema.${names.pluralCamel}.id });\n` : `\t\t\tconst rows = await db\n\t\t\t\t.delete(schema.${names.pluralCamel})\n\t\t\t\t.where(${deleteWhere})\n\t\t\t\t.returning({ id: schema.${names.pluralCamel}.id });\n`}\n return rows.length > 0;\n },\n` : ""}
2232
3668
  };
2233
3669
  }
2234
3670
  `;
2235
3671
  }
2236
- function contractFile(names, config) {
2237
- return `import { createContractGroup } from "@beignet/core/contracts";
3672
+ function contractFile(names, config, mode, options) {
3673
+ return `import { defineContractGroup } from "@beignet/core/contracts";
2238
3674
  import { z } from "zod";
2239
- import {
2240
- create${names.singularPascal}UseCase,
2241
- list${names.pluralPascal}UseCase,
2242
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
3675
+ ${mode === "resource" ? `import { errors } from "${aliasModule(resourceSharedErrorsPath(config))}";\n` : ""}import {
3676
+ Create${names.singularPascal}InputSchema,
3677
+ List${names.pluralPascal}InputSchema,
3678
+ List${names.pluralPascal}OutputSchema,
3679
+ ${names.singularPascal}Schema,
3680
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n Update${names.singularPascal}BodySchema,\n` : ""}} from "${aliasModule(resourceSchemaFilePath(names, config))}";
2243
3681
 
2244
3682
  const ErrorResponseSchema = z.object({
2245
3683
  code: z.string(),
@@ -2247,7 +3685,7 @@ const ErrorResponseSchema = z.object({
2247
3685
  requestId: z.string().optional(),
2248
3686
  });
2249
3687
 
2250
- const ${names.pluralCamel} = createContractGroup()
3688
+ const ${names.pluralCamel} = defineContractGroup()
2251
3689
  .namespace("${names.pluralCamel}")
2252
3690
  .responses({
2253
3691
  500: ErrorResponseSchema,
@@ -2255,21 +3693,49 @@ const ${names.pluralCamel} = createContractGroup()
2255
3693
 
2256
3694
  export const list${names.pluralPascal} = ${names.pluralCamel}
2257
3695
  .get("/api/${names.pluralKebab}")
2258
- .query(list${names.pluralPascal}UseCase.inputSchema)
3696
+ .query(List${names.pluralPascal}InputSchema)
2259
3697
  .responses({
2260
- 200: list${names.pluralPascal}UseCase.outputSchema,
3698
+ 200: List${names.pluralPascal}OutputSchema,
2261
3699
  });
2262
3700
 
2263
3701
  export const create${names.singularPascal} = ${names.pluralCamel}
2264
3702
  .post("/api/${names.pluralKebab}")
2265
- .body(create${names.singularPascal}UseCase.inputSchema)
3703
+ .body(Create${names.singularPascal}InputSchema)
3704
+ ${options.auth ? ` .meta({\n auth: "required",\n authorization: { ability: "${names.pluralCamel}.create" },\n })\n .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden })\n` : ""} .responses({
3705
+ 201: ${names.singularPascal}Schema,
3706
+ });
3707
+ ${mode === "resource"
3708
+ ? `
3709
+ export const get${names.singularPascal} = ${names.pluralCamel}
3710
+ .get("/api/${names.pluralKebab}/:id")
3711
+ .pathParams(${names.singularPascal}IdInputSchema)
3712
+ .errors({ ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound })
3713
+ .responses({
3714
+ 200: ${names.singularPascal}Schema,
3715
+ });
3716
+
3717
+ export const update${names.singularPascal} = ${names.pluralCamel}
3718
+ .patch("/api/${names.pluralKebab}/:id")
3719
+ .pathParams(${names.singularPascal}IdInputSchema)
3720
+ .body(Update${names.singularPascal}BodySchema)
3721
+ ${options.auth ? `\t.meta({\n\t\tauth: "required",\n\t\tauthorization: { ability: "${names.pluralCamel}.update" },\n\t})\n` : ""} .errors({${options.auth ? " Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden," : ""} ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound, ${names.singularPascal}Conflict: errors.${names.singularPascal}Conflict })
2266
3722
  .responses({
2267
- 201: create${names.singularPascal}UseCase.outputSchema,
3723
+ 200: ${names.singularPascal}Schema,
2268
3724
  });
3725
+
3726
+ export const delete${names.singularPascal} = ${names.pluralCamel}
3727
+ .delete("/api/${names.pluralKebab}/:id")
3728
+ .pathParams(${names.singularPascal}IdInputSchema)
3729
+ ${options.auth ? `\t.meta({\n\t\tauth: "required",\n\t\tauthorization: { ability: "${names.pluralCamel}.delete" },\n\t})\n` : ""} .errors({${options.auth ? " Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden," : ""} ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound })
3730
+ .responses({
3731
+ 204: null,
3732
+ });
3733
+ `
3734
+ : ""}
2269
3735
  `;
2270
3736
  }
2271
3737
  function standaloneContractFile(names) {
2272
- return `import { createContractGroup } from "@beignet/core/contracts";
3738
+ return `import { defineContractGroup } from "@beignet/core/contracts";
2273
3739
  import { z } from "zod";
2274
3740
 
2275
3741
  const ErrorResponseSchema = z.object({
@@ -2278,7 +3744,7 @@ const ErrorResponseSchema = z.object({
2278
3744
  requestId: z.string().optional(),
2279
3745
  });
2280
3746
 
2281
- const ${names.pluralCamel} = createContractGroup()
3747
+ const ${names.pluralCamel} = defineContractGroup()
2282
3748
  .namespace("${names.pluralCamel}")
2283
3749
  .responses({
2284
3750
  500: ErrorResponseSchema,
@@ -2299,10 +3765,14 @@ export const list${names.pluralPascal} = ${names.pluralCamel}
2299
3765
  )
2300
3766
  .responses({
2301
3767
  200: z.object({
2302
- ${names.pluralCamel}: z.array(${names.singularCamel}Schema),
2303
- total: z.number().int().min(0),
2304
- limit: z.number().int().min(1),
2305
- offset: z.number().int().min(0),
3768
+ items: z.array(${names.singularCamel}Schema),
3769
+ page: z.object({
3770
+ kind: z.literal("offset"),
3771
+ limit: z.number().int().min(1),
3772
+ offset: z.number().int().min(0),
3773
+ total: z.number().int().min(0),
3774
+ hasMore: z.boolean(),
3775
+ }),
2306
3776
  }),
2307
3777
  });
2308
3778
 
@@ -2310,7 +3780,8 @@ export const ${names.pluralCamel}Contracts = [list${names.pluralPascal}];
2310
3780
  `;
2311
3781
  }
2312
3782
  function standaloneUseCaseFile(names, config, filePath) {
2313
- return `import { z } from "zod";
3783
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3784
+ import { z } from "zod";
2314
3785
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2315
3786
 
2316
3787
  export const ${names.action.pascal}InputSchema = z.object({});
@@ -2338,29 +3809,27 @@ function useCaseTestFile(names, config) {
2338
3809
  return `import { describe, expect, it } from "bun:test";
2339
3810
  import { createUseCaseTester } from "@beignet/core/application";
2340
3811
  import { createInMemoryDevtools } from "@beignet/devtools";
2341
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
3812
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
3813
+ import { createTestAnonymousActor } from "@beignet/core/ports/testing";
2342
3814
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2343
3815
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
2344
3816
  import { ${names.exportName} } from "${relativeModule(filePath, useCaseFilePath(names, config))}";
2345
3817
 
2346
3818
  describe("${names.exportName}", () => {
2347
3819
  it("runs ${names.action.camel}", async () => {
2348
- const testPorts = {
2349
- ...appPorts,
2350
- uow: createNoopUnitOfWork(
2351
- () => appPorts as unknown as AppContext["ports"],
2352
- ) as AppContext["ports"]["uow"],
2353
- devtools: createInMemoryDevtools(),
2354
- storage: createMemoryStorage(),
2355
- } as AppContext["ports"];
2356
- const actor = createAnonymousActor();
2357
- const tester = createUseCaseTester<AppContext>(() => ({
2358
- requestId: "test-request",
2359
- actor,
2360
- auth: null,
2361
- gate: testPorts.gate.bind({ actor, auth: null }),
2362
- ports: testPorts,
2363
- }));
3820
+ const testFixture = createTestPorts<AppContext["ports"]>({
3821
+ base: appPorts,
3822
+ overrides: {
3823
+ gate: appPorts.gate,
3824
+ devtools: createInMemoryDevtools(),
3825
+ },
3826
+ });
3827
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
3828
+ ports: testFixture.ports,
3829
+ actor: createTestAnonymousActor(),
3830
+ tenant: null,
3831
+ });
3832
+ const tester = createUseCaseTester<AppContext>(createTestContext);
2364
3833
 
2365
3834
  const result = await tester.run(${names.exportName}, {});
2366
3835
 
@@ -2421,6 +3890,134 @@ export const ${names.policyName} = definePolicy({
2421
3890
  });
2422
3891
  `;
2423
3892
  }
3893
+ function resourcePolicyFile(names, _config, options) {
3894
+ return `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
3895
+ import { definePolicy, deny } from "@beignet/core/ports";
3896
+
3897
+ export type AuthorizationContext = {
3898
+ actor: ActivityActor;
3899
+ tenant?: ActivityTenant;
3900
+ };
3901
+
3902
+ type ${names.singularPascal}PolicyResource = {
3903
+ id: string;
3904
+ tenantId?: string;
3905
+ };
3906
+
3907
+ function isAuthenticated(ctx: AuthorizationContext) {
3908
+ return ctx.actor.type === "user" && Boolean(ctx.actor.id);
3909
+ }
3910
+
3911
+ function canWrite(ctx: AuthorizationContext) {
3912
+ if (!isAuthenticated(ctx)) return deny("You must be signed in.");
3913
+ ${options.tenant ? `\tif (!ctx.tenant?.id) return deny("A tenant is required.");\n` : ""} return true;
3914
+ }
3915
+
3916
+ export const ${names.singularCamel}Policy = definePolicy({
3917
+ "${names.pluralCamel}.view": (_ctx: AuthorizationContext) => true,
3918
+ "${names.pluralCamel}.create": (ctx: AuthorizationContext) =>
3919
+ canWrite(ctx),
3920
+ "${names.pluralCamel}.update": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
3921
+ const decision = canWrite(ctx);
3922
+ if (decision !== true) return decision;
3923
+ ${options.tenant ? `\t\tif (${names.singularCamel}.tenantId !== ctx.tenant?.id) {\n\t\t\treturn deny("You cannot update a ${names.singularKebab} from another tenant.");\n\t\t}\n` : ""}
3924
+ return true;
3925
+ },
3926
+ "${names.pluralCamel}.delete": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
3927
+ const decision = canWrite(ctx);
3928
+ if (decision !== true) return decision;
3929
+ ${options.tenant ? `\t\tif (${names.singularCamel}.tenantId !== ctx.tenant?.id) {\n\t\t\treturn deny("You cannot delete a ${names.singularKebab} from another tenant.");\n\t\t}\n` : ""}
3930
+ return true;
3931
+ },
3932
+ "${names.pluralCamel}.manage": (_ctx: AuthorizationContext) =>
3933
+ deny("You are not allowed to manage this ${names.singularKebab}."),
3934
+ });
3935
+ `;
3936
+ }
3937
+ function resourceEventsFile(names) {
3938
+ return `import { defineEvent } from "@beignet/core/events";
3939
+ import { z } from "zod";
3940
+
3941
+ export const ${names.singularPascal}Created = defineEvent("${names.pluralCamel}.created", {
3942
+ payload: z.object({
3943
+ id: z.string().uuid(),
3944
+ }),
3945
+ });
3946
+
3947
+ export const ${names.singularPascal}Updated = defineEvent("${names.pluralCamel}.updated", {
3948
+ payload: z.object({
3949
+ id: z.string().uuid(),
3950
+ }),
3951
+ });
3952
+
3953
+ export const ${names.singularPascal}Deleted = defineEvent("${names.pluralCamel}.deleted", {
3954
+ payload: z.object({
3955
+ id: z.string().uuid(),
3956
+ }),
3957
+ });
3958
+
3959
+ export const ${names.singularCamel}Events = [
3960
+ ${names.singularPascal}Created,
3961
+ ${names.singularPascal}Updated,
3962
+ ${names.singularPascal}Deleted,
3963
+ ] as const;
3964
+ `;
3965
+ }
3966
+ function resourcePolicyTestFile(names, options) {
3967
+ return `import { describe, expect, it } from "bun:test";
3968
+ import { createPolicyTester } from "@beignet/core/ports/testing";
3969
+ import { ${names.singularCamel}Policy } from "../policy";
3970
+ import type { ${names.singularPascal} } from "../schemas";
3971
+
3972
+ function create${names.singularPascal}(): ${names.singularPascal} {
3973
+ const now = new Date().toISOString();
3974
+ return {
3975
+ id: "00000000-0000-4000-8000-000000000001",
3976
+ ${options.tenant ? `\t\ttenantId: "tenant_test",\n` : ""} name: "Test ${names.singularPascal}",
3977
+ version: 1,
3978
+ createdAt: now,
3979
+ updatedAt: now,
3980
+ };
3981
+ }
3982
+
3983
+ describe("${names.singularCamel}Policy", () => {
3984
+ it("documents generated ${names.pluralKebab} authorization rules", async () => {
3985
+ const tester = createPolicyTester({
3986
+ policies: [${names.singularCamel}Policy],
3987
+ });
3988
+ const ${names.singularCamel} = create${names.singularPascal}();
3989
+ const ctx = {
3990
+ actor: { type: "user" as const, id: "user_test" },
3991
+ ${options.tenant ? `\t\t\ttenant: { id: "tenant_test" },\n` : ""} };
3992
+
3993
+ await expect(
3994
+ tester.assertMatrix([
3995
+ {
3996
+ name: "authenticated actor can create ${names.singularKebab}",
3997
+ ctx,
3998
+ ability: "${names.pluralCamel}.create",
3999
+ expected: "allow",
4000
+ },
4001
+ {
4002
+ name: "authenticated actor can update ${names.singularKebab}",
4003
+ ctx,
4004
+ ability: "${names.pluralCamel}.update",
4005
+ subject: ${names.singularCamel},
4006
+ expected: "allow",
4007
+ },
4008
+ {
4009
+ name: "authenticated actor can delete ${names.singularKebab}",
4010
+ ctx,
4011
+ ability: "${names.pluralCamel}.delete",
4012
+ subject: ${names.singularCamel},
4013
+ expected: "allow",
4014
+ },
4015
+ ]),
4016
+ ).resolves.toBeUndefined();
4017
+ });
4018
+ });
4019
+ `;
4020
+ }
2424
4021
  function eventFile(names) {
2425
4022
  return `import { defineEvent } from "@beignet/core/events";
2426
4023
  import { z } from "zod";
@@ -2437,11 +4034,9 @@ export const ${names.eventExportName} = defineEvent("${names.eventName}", {
2437
4034
  `;
2438
4035
  }
2439
4036
  function jobFile(names, config) {
2440
- return `import { createJobHandlers } from "@beignet/core/jobs";
4037
+ return `import { retry } from "@beignet/core/jobs";
2441
4038
  import { z } from "zod";
2442
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2443
-
2444
- const jobs = createJobHandlers<AppContext>();
4039
+ import { defineJob } from "${aliasModule(config.paths.jobsBuilder)}";
2445
4040
 
2446
4041
  export const ${names.payloadSchemaName} = z.object({
2447
4042
  \tid: z.string().uuid(),
@@ -2449,11 +4044,13 @@ export const ${names.payloadSchemaName} = z.object({
2449
4044
 
2450
4045
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2451
4046
 
2452
- export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
4047
+ export const ${names.jobExportName} = defineJob("${names.jobName}", {
2453
4048
  \tpayload: ${names.payloadSchemaName},
2454
- \tretry: {
4049
+ \tretry: retry.exponential({
2455
4050
  \t\tattempts: 3,
2456
- \t},
4051
+ \t\tinitialDelay: "1s",
4052
+ \t\tmaxDelay: "1m",
4053
+ \t}),
2457
4054
  \tasync handle({ payload, ctx }) {
2458
4055
  \t\tctx.ports.logger.info("Job handled", {
2459
4056
  \t\t\tjobName: "${names.jobName}",
@@ -2463,15 +4060,94 @@ export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
2463
4060
  });
2464
4061
  `;
2465
4062
  }
4063
+ function taskFile(names, config) {
4064
+ return `import { z } from "zod";
4065
+ import { defineTask } from "${aliasModule(config.paths.tasksBuilder)}";
4066
+
4067
+ export const ${names.inputSchemaName} = z.object({
4068
+ \tdryRun: z.boolean().default(true),
4069
+ });
4070
+
4071
+ export type ${names.inputTypeName} = z.infer<typeof ${names.inputSchemaName}>;
4072
+
4073
+ export const ${names.taskExportName} = defineTask("${names.taskName}", {
4074
+ \tinput: ${names.inputSchemaName},
4075
+ \tdescription: "Operational ${names.feature.kebab} ${names.artifact.kebab} task.",
4076
+ \tasync handle({ input, ctx }) {
4077
+ \t\tctx.ports.logger.info("Task handled", {
4078
+ \t\t\ttaskName: "${names.taskName}",
4079
+ \t\t\tdryRun: input.dryRun,
4080
+ \t\t});
4081
+
4082
+ \t\treturn {
4083
+ \t\t\tdryRun: input.dryRun,
4084
+ \t\t};
4085
+ \t},
4086
+ });
4087
+ `;
4088
+ }
4089
+ function factoryFile(names, config) {
4090
+ const resource = featureResourceNames(names);
4091
+ return `import { createFactory } from "@beignet/core/testing";
4092
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4093
+
4094
+ export const ${names.factoryExportName} = createFactory("${names.factoryName}", {
4095
+ \tdefaults: ({ sequence }) => ({
4096
+ \t\tname: "${resource.singularPascal} " + sequence,
4097
+ \t}),
4098
+ \tpersist: (ctx: AppContext, input) => ctx.ports.${resource.pluralCamel}.create(input),
4099
+ });
4100
+ `;
4101
+ }
4102
+ function seedFile(names, config) {
4103
+ const resource = featureResourceNames(names);
4104
+ return `import { defineSeed } from "@beignet/core/testing";
4105
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4106
+
4107
+ export const ${names.seedExportName} = defineSeed("${names.seedName}", {
4108
+ \tdescription: "Creates starter ${names.feature.kebab} data.",
4109
+ \trun: async (ctx: AppContext) => {
4110
+ \t\tawait ctx.ports.${resource.pluralCamel}.create({
4111
+ \t\t\tname: "Demo ${resource.singularPascal}",
4112
+ \t\t});
4113
+ \t},
4114
+ });
4115
+ `;
4116
+ }
4117
+ function notificationFile(names, config) {
4118
+ return `import { defineMailNotificationChannel } from "@beignet/core/notifications";
4119
+ import { z } from "zod";
4120
+ import { defineNotification } from "${aliasModule(config.paths.notificationsBuilder)}";
4121
+
4122
+ export const ${names.payloadSchemaName} = z.object({
4123
+ \tid: z.string().uuid(),
4124
+ \tsubject: z.string(),
4125
+ \trecipientEmail: z.string().email(),
4126
+ });
4127
+
4128
+ export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
4129
+
4130
+ export const ${names.notificationExportName} = defineNotification(
4131
+ \t"${names.notificationName}",
4132
+ \t{
4133
+ \t\tpayload: ${names.payloadSchemaName},
4134
+ \t\tchannels: {
4135
+ \t\t\temail: defineMailNotificationChannel(({ payload }) => ({
4136
+ \t\t\t\tto: payload.recipientEmail,
4137
+ \t\t\t\tsubject: payload.subject,
4138
+ \t\t\t\ttext: \`${names.notificationName} notification for \${payload.id}.\`,
4139
+ \t\t\t})),
4140
+ \t\t},
4141
+ \t},
4142
+ );
4143
+ `;
4144
+ }
2466
4145
  function listenerFile(names, config) {
2467
4146
  const filePath = listenerFilePath(names, config);
2468
- return `import { createEventHandlers } from "@beignet/core/events";
2469
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4147
+ return `import { defineListener } from "${aliasModule(config.paths.listenersBuilder)}";
2470
4148
  import { ${names.event.eventExportName} } from "${relativeModule(filePath, eventFilePath(names.event, config))}";
2471
4149
 
2472
- const events = createEventHandlers<AppContext>();
2473
-
2474
- export const ${names.listenerExportName} = events.defineListener(${names.event.eventExportName}, {
4150
+ export const ${names.listenerExportName} = defineListener(${names.event.eventExportName}, {
2475
4151
  \tname: "${names.listenerName}",
2476
4152
  \tasync handle({ payload, ctx }) {
2477
4153
  \t\tctx.ports.logger.info("Listener handled", {
@@ -2483,11 +4159,8 @@ export const ${names.listenerExportName} = events.defineListener(${names.event.e
2483
4159
  `;
2484
4160
  }
2485
4161
  function scheduleFile(names, config) {
2486
- return `import { createScheduleHandlers } from "@beignet/core/schedules";
2487
- import { z } from "zod";
2488
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2489
-
2490
- const schedules = createScheduleHandlers<AppContext>();
4162
+ return `import { z } from "zod";
4163
+ import { defineSchedule } from "${aliasModule(config.paths.schedulesBuilder)}";
2491
4164
 
2492
4165
  export const ${names.payloadSchemaName} = z.object({
2493
4166
  \tdate: z.string(),
@@ -2495,7 +4168,7 @@ export const ${names.payloadSchemaName} = z.object({
2495
4168
 
2496
4169
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2497
4170
 
2498
- export const ${names.scheduleExportName} = schedules.defineSchedule(
4171
+ export const ${names.scheduleExportName} = defineSchedule(
2499
4172
  \t"${names.scheduleName}",
2500
4173
  \t{
2501
4174
  \t\tcron: "${names.cron}",
@@ -2517,108 +4190,164 @@ ${names.timezone ? `\t\ttimezone: "${names.timezone}",\n` : ""}\t\tpayload: ${na
2517
4190
  );
2518
4191
  `;
2519
4192
  }
2520
- function scheduleRouteFile(names, config) {
2521
- return `import { createInlineScheduleRunner } from "@beignet/core/schedules";
4193
+ function uploadFile(names, config) {
4194
+ return `import { defineUpload } from "@beignet/core/uploads";
4195
+ import { z } from "zod";
2522
4196
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2523
- import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
2524
- import { env } from "@/lib/env";
2525
- import { server } from "${aliasModule(config.paths.server)}";
2526
4197
 
2527
- async function run${names.artifact.pascal}(request: Request) {
2528
- \tconst cronSecret = env.CRON_SECRET;
4198
+ export const ${names.metadataSchemaName} = z.object({
4199
+ \tresourceId: z.string().min(1),
4200
+ });
2529
4201
 
2530
- \tif (!cronSecret) {
2531
- \t\treturn Response.json(
2532
- \t\t\t{
2533
- \t\t\t\tok: false,
2534
- \t\t\t\terror: "CRON_SECRET is not configured.",
2535
- \t\t\t\tscheduleName: ${names.scheduleExportName}.name,
2536
- \t\t\t},
2537
- \t\t\t{ status: 500 },
2538
- \t\t);
2539
- \t}
2540
-
2541
- \tif (
2542
- \t\trequest.headers.get("authorization") !== \`Bearer \${cronSecret}\`
2543
- \t) {
2544
- \t\treturn Response.json({ error: "Unauthorized" }, { status: 401 });
2545
- \t}
2546
-
2547
- \tconst ctx = await server.createContextFromNext();
2548
- \tconst runner = createInlineScheduleRunner<AppContext>({
2549
- \t\tctx,
2550
- \t\tonStart({ run, schedule }) {
2551
- \t\t\tctx.ports.devtools.record({
2552
- \t\t\t\ttype: "schedule",
2553
- \t\t\t\twatcher: "schedules",
2554
- \t\t\t\trequestId: ctx.requestId,
2555
- \t\t\t\tscheduleName: schedule.name,
2556
- \t\t\t\tstatus: "started",
2557
- \t\t\t\tcron: schedule.cron,
2558
- \t\t\t\ttimezone: schedule.timezone,
2559
- \t\t\t\tdetails: {
2560
- \t\t\t\t\tsource: run.source,
2561
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
2562
- \t\t\t\t},
2563
- \t\t\t});
2564
- \t\t},
2565
- \t\tonSuccess({ run, schedule }) {
2566
- \t\t\tctx.ports.devtools.record({
2567
- \t\t\t\ttype: "schedule",
2568
- \t\t\t\twatcher: "schedules",
2569
- \t\t\t\trequestId: ctx.requestId,
2570
- \t\t\t\tscheduleName: schedule.name,
2571
- \t\t\t\tstatus: "completed",
2572
- \t\t\t\tcron: schedule.cron,
2573
- \t\t\t\ttimezone: schedule.timezone,
2574
- \t\t\t\tdetails: {
2575
- \t\t\t\t\tsource: run.source,
2576
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
2577
- \t\t\t\t},
2578
- \t\t\t});
2579
- \t\t},
2580
- \t\tonError({ error, run, schedule }) {
2581
- \t\t\tctx.ports.devtools.record({
2582
- \t\t\t\ttype: "schedule",
2583
- \t\t\t\twatcher: "schedules",
2584
- \t\t\t\trequestId: ctx.requestId,
2585
- \t\t\t\tscheduleName: schedule.name,
2586
- \t\t\t\tstatus: "failed",
2587
- \t\t\t\tcron: schedule.cron,
2588
- \t\t\t\ttimezone: schedule.timezone,
2589
- \t\t\t\tdetails: {
2590
- \t\t\t\t\terror,
2591
- \t\t\t\t\tsource: run.source,
2592
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
2593
- \t\t\t\t},
2594
- \t\t\t});
2595
- \t\t\tctx.ports.logger.error("Schedule failed", {
2596
- \t\t\t\terror,
2597
- \t\t\t\tscheduleName: schedule.name,
2598
- \t\t\t});
2599
- \t\t},
2600
- \t\tonHookError({ error, hook, schedule }) {
2601
- \t\t\tctx.ports.logger.warn("Schedule lifecycle hook failed", {
2602
- \t\t\t\terror,
2603
- \t\t\t\thook,
2604
- \t\t\t\tscheduleName: schedule.name,
2605
- \t\t\t});
2606
- \t\t},
2607
- \t});
4202
+ export type ${names.metadataTypeName} = z.infer<typeof ${names.metadataSchemaName}>;
2608
4203
 
2609
- \ttry {
2610
- \t\tawait runner.run(${names.scheduleExportName}, {
2611
- \t\t\tsource: "cron-route",
4204
+ export const ${names.uploadExportName} = defineUpload<
4205
+ \t"${names.uploadName}",
4206
+ \ttypeof ${names.metadataSchemaName},
4207
+ \tAppContext,
4208
+ \t{ objectKeys: string[] }
4209
+ >("${names.uploadName}", {
4210
+ \tmetadata: ${names.metadataSchemaName},
4211
+ \tfile: {
4212
+ \t\tcontentTypes: ["application/pdf", "text/plain"],
4213
+ \t\tmaxSizeBytes: 5 * 1024 * 1024,
4214
+ \t\tmaxFiles: 1,
4215
+ \t\tvisibility: "private",
4216
+ \t\tcacheControl: "private, max-age=0",
4217
+ \t},
4218
+ \tauthorize({ ctx }) {
4219
+ \t\treturn ctx.actor.type === "user";
4220
+ \t},
4221
+ \tkey({ ctx, metadata, uploadId, file }) {
4222
+ \t\tconst tenantId = ctx.tenant?.id ?? "default";
4223
+ \t\tconst extension = file.name.includes(".") ? file.name.split(".").pop() : undefined;
4224
+ \t\tconst suffix = extension ? \`.\${extension}\` : "";
4225
+ \t\treturn "${names.feature.kebab}/" + tenantId + "/" + metadata.resourceId + "/" + uploadId + suffix;
4226
+ \t},
4227
+ \tstorageMetadata({ ctx, metadata }) {
4228
+ \t\treturn {
4229
+ \t\t\ttenantId: ctx.tenant?.id ?? "default",
4230
+ \t\t\tresourceId: metadata.resourceId,
4231
+ \t\t};
4232
+ \t},
4233
+ \tasync onComplete({ ctx, metadata, files }) {
4234
+ \t\tctx.ports.logger.info("Upload completed", {
4235
+ \t\t\tuploadName: "${names.uploadName}",
4236
+ \t\t\tresourceId: metadata.resourceId,
4237
+ \t\t\tobjectKeys: files.map((file) => file.key),
2612
4238
  \t\t});
2613
- \t} catch {
2614
- \t\treturn Response.json({ error: "Schedule failed" }, { status: 500 });
2615
- \t}
2616
4239
 
2617
- \treturn Response.json({ ok: true });
4240
+ \t\treturn {
4241
+ \t\t\tobjectKeys: files.map((file) => file.key),
4242
+ \t\t};
4243
+ \t},
4244
+ });
4245
+ `;
2618
4246
  }
4247
+ function featureUiComponentFile(names, config) {
4248
+ return `"use client";
4249
+
4250
+ import { contractErrorMessage } from "@beignet/core/client";
4251
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4252
+ import { useState } from "react";
4253
+ import { rq } from "${aliasModule(clientIndexPath(config))}";
4254
+ import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
4255
+
4256
+ export function ${names.componentExportName}() {
4257
+ \tconst [name, setName] = useState("");
4258
+ \tconst queryClient = useQueryClient();
4259
+ \tconst ${names.pluralCamel}Query = useQuery(
4260
+ \t\trq(list${names.pluralPascal}).queryOptions({ query: {} }),
4261
+ \t);
4262
+ \tconst create${names.singularPascal}Mutation = useMutation(
4263
+ \t\trq(create${names.singularPascal}).mutationOptions({
4264
+ \t\t\tonSuccess: async () => {
4265
+ \t\t\t\tsetName("");
4266
+ \t\t\t\tawait rq(list${names.pluralPascal}).invalidate(queryClient);
4267
+ \t\t\t},
4268
+ \t\t}),
4269
+ \t);
2619
4270
 
2620
- export const GET = run${names.artifact.pascal};
2621
- export const POST = run${names.artifact.pascal};
4271
+ \treturn (
4272
+ \t\t<section className="${names.pluralKebab}-panel">
4273
+ \t\t\t<form
4274
+ \t\t\t\tclassName="${names.pluralKebab}-composer"
4275
+ \t\t\t\tonSubmit={(event) => {
4276
+ \t\t\t\t\tevent.preventDefault();
4277
+ \t\t\t\t\tconst trimmedName = name.trim();
4278
+ \t\t\t\t\tif (!trimmedName) return;
4279
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.reset();
4280
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.mutate({
4281
+ \t\t\t\t\t\tbody: { name: trimmedName },
4282
+ \t\t\t\t\t});
4283
+ \t\t\t\t}}
4284
+ \t\t\t>
4285
+ \t\t\t\t<label htmlFor="${names.singularKebab}-name">New ${names.singularKebab}</label>
4286
+ \t\t\t\t<div className="${names.pluralKebab}-composer-row">
4287
+ \t\t\t\t\t<input
4288
+ \t\t\t\t\t\tid="${names.singularKebab}-name"
4289
+ \t\t\t\t\t\tvalue={name}
4290
+ \t\t\t\t\t\tonChange={(event) => setName(event.currentTarget.value)}
4291
+ \t\t\t\t\t\tplaceholder="Name"
4292
+ \t\t\t\t\t/>
4293
+ \t\t\t\t\t<button
4294
+ \t\t\t\t\t\ttype="submit"
4295
+ \t\t\t\t\t\tdisabled={!name.trim() || create${names.singularPascal}Mutation.isPending}
4296
+ \t\t\t\t\t>
4297
+ \t\t\t\t\t\t{create${names.singularPascal}Mutation.isPending ? "Creating" : "Create"}
4298
+ \t\t\t\t\t</button>
4299
+ \t\t\t\t</div>
4300
+ \t\t\t\t{create${names.singularPascal}Mutation.isError ? (
4301
+ \t\t\t\t\t<p role="alert">
4302
+ \t\t\t\t\t\t{contractErrorMessage(
4303
+ \t\t\t\t\t\t\tcreate${names.singularPascal}Mutation.error,
4304
+ \t\t\t\t\t\t\t"Could not create ${names.singularKebab}.",
4305
+ \t\t\t\t\t\t)}
4306
+ \t\t\t\t\t</p>
4307
+ \t\t\t\t) : null}
4308
+ \t\t\t</form>
4309
+
4310
+ \t\t\t<div className="${names.pluralKebab}-list">
4311
+ \t\t\t\t<div className="${names.pluralKebab}-list-heading">
4312
+ \t\t\t\t\t<h2>${names.pluralPascal}</h2>
4313
+ \t\t\t\t\t<span>{${names.pluralCamel}Query.data?.items.length ?? 0} shown</span>
4314
+ \t\t\t\t</div>
4315
+ \t\t\t\t{${names.pluralCamel}Query.isLoading ? <p>Loading...</p> : null}
4316
+ \t\t\t\t{${names.pluralCamel}Query.isError ? (
4317
+ \t\t\t\t\t<p role="alert">Could not load ${names.pluralKebab}.</p>
4318
+ \t\t\t\t) : null}
4319
+ \t\t\t\t{${names.pluralCamel}Query.data?.items.length === 0 ? (
4320
+ \t\t\t\t\t<p>No ${names.pluralKebab} yet.</p>
4321
+ \t\t\t\t) : null}
4322
+ \t\t\t\t<ul>
4323
+ \t\t\t\t\t{${names.pluralCamel}Query.data?.items.map((${names.singularCamel}) => (
4324
+ \t\t\t\t\t\t<li key={${names.singularCamel}.id}>
4325
+ \t\t\t\t\t\t\t<strong>{${names.singularCamel}.name}</strong>
4326
+ \t\t\t\t\t\t\t<small>{new Date(${names.singularCamel}.createdAt).toLocaleDateString()}</small>
4327
+ \t\t\t\t\t\t</li>
4328
+ \t\t\t\t\t))}
4329
+ \t\t\t\t</ul>
4330
+ \t\t\t</div>
4331
+ \t\t</section>
4332
+ \t);
4333
+ }
4334
+ `;
4335
+ }
4336
+ function scheduleRouteFile(names, config) {
4337
+ return `import { createScheduleRoute } from "@beignet/next";
4338
+ import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
4339
+ import { env } from "@/lib/env";
4340
+ import { server } from "${aliasModule(config.paths.server)}";
4341
+
4342
+ export const runtime = "nodejs";
4343
+
4344
+ export const { GET, POST } = createScheduleRoute({
4345
+ \tserver,
4346
+ \tschedules: [${names.scheduleExportName}],
4347
+ \tschedule: ${names.scheduleExportName}.name,
4348
+ \tsecret: env.CRON_SECRET,
4349
+ \tsource: "cron-route",
4350
+ });
2622
4351
  `;
2623
4352
  }
2624
4353
  function useCaseIndexExport(names) {
@@ -2631,73 +4360,138 @@ function useCaseIndexExport(names) {
2631
4360
  } from "./${names.action.kebab}";
2632
4361
  `;
2633
4362
  }
2634
- function routeGroupFile(names, config) {
2635
- return `import { defineRouteGroup } from "@beignet/next";
2636
- import type { AppContext } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), config.paths.appContext)}";
2637
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceContractFilePath(names, config))}";
4363
+ function routeGroupFile(names, config, mode, _options) {
4364
+ const routeFilePath = path.join(config.paths.features, names.pluralKebab, "routes.ts");
4365
+ const contractsPath = resourceContractFilePath(names, config);
4366
+ const useCasesPath = resourceUseCaseIndexPath(names, config);
4367
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
4368
+ import { defineRouteGroup } from "@beignet/next";
4369
+ import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
4370
+ import {
4371
+ create${names.singularPascal},
4372
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
4373
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${relativeModule(routeFilePath, contractsPath)}";
2638
4374
  import {
2639
4375
  create${names.singularPascal}UseCase,
2640
- list${names.pluralPascal}UseCase,
2641
- } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceUseCaseIndexPath(names, config))}";
4376
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
4377
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${relativeModule(routeFilePath, useCasesPath)}";
2642
4378
 
2643
4379
  export const ${names.singularCamel}Routes = defineRouteGroup<AppContext>({
2644
4380
  name: "${names.pluralKebab}",
2645
4381
  routes: [
2646
- {
2647
- contract: list${names.pluralPascal},
2648
- handle: async ({ ctx, query }) => ({
2649
- status: 200,
2650
- body: await list${names.pluralPascal}UseCase.run({ ctx, input: query }),
2651
- }),
2652
- },
2653
- {
2654
- contract: create${names.singularPascal},
2655
- handle: async ({ ctx, body }) => ({
2656
- status: 201,
2657
- body: await create${names.singularPascal}UseCase.run({ ctx, input: body }),
2658
- }),
2659
- },
2660
- ],
4382
+ { contract: list${names.pluralPascal}, useCase: list${names.pluralPascal}UseCase },
4383
+ { contract: create${names.singularPascal}, useCase: create${names.singularPascal}UseCase },
4384
+ ${mode === "resource" ? ` { contract: get${names.singularPascal}, useCase: get${names.singularPascal}UseCase },\n { contract: update${names.singularPascal}, useCase: update${names.singularPascal}UseCase },\n { contract: delete${names.singularPascal}, useCase: delete${names.singularPascal}UseCase },\n` : ""} ],
2661
4385
  });
2662
4386
  `;
2663
4387
  }
2664
- function testFile(names, config) {
4388
+ function testFile(names, config, mode, options) {
2665
4389
  const repositoryPath = path.join(path.dirname(config.paths.infrastructurePorts), names.pluralKebab, `in-memory-${names.singularKebab}-repository.ts`);
4390
+ const serverContextPath = path.join(path.dirname(config.paths.server), "context.ts");
4391
+ const requestTarget = options.tenant ? "requester" : "app";
2666
4392
  return `import { describe, expect, it } from "bun:test";
2667
4393
  import { createUseCaseTester } from "@beignet/core/application";
4394
+ ${mode === "resource" ? `import { isAppError } from "@beignet/core/errors";\n` : ""}import { defineRoutes } from "@beignet/web";
4395
+ import { createTestApp${options.tenant ? ", createTestRequester" : ""} } from "@beignet/web/testing";
2668
4396
  import { createInMemoryDevtools } from "@beignet/devtools";
2669
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
4397
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
4398
+ import { ${options.auth ? "createTestUserActor" : "createTestAnonymousActor"}${options.tenant ? ", createTestTenant" : ""} } from "@beignet/core/ports/testing";
2670
4399
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2671
4400
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
4401
+ import { appContext } from "${aliasModule(serverContextPath)}";
4402
+ import {
4403
+ create${names.singularPascal},
4404
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
4405
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${aliasModule(resourceContractFilePath(names, config))}";
2672
4406
  import { createInMemory${names.singularPascal}Repository } from "${aliasModule(repositoryPath)}";
4407
+ import { ${names.singularCamel}Routes } from "${aliasModule(path.join(resourceFeatureDir(names, config), "routes.ts"))}";
2673
4408
  import {
2674
4409
  create${names.singularPascal}UseCase,
2675
- list${names.pluralPascal}UseCase,
2676
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
4410
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
4411
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
2677
4412
 
2678
4413
  describe("${names.pluralCamel} resource", () => {
2679
- it("creates and lists ${names.pluralCamel}", async () => {
2680
- const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();
2681
- const testPorts = {
2682
- ...appPorts,
2683
- ${names.pluralCamel},
2684
- uow: createNoopUnitOfWork(() => ({
2685
- ...(appPorts as unknown as AppContext["ports"]),
4414
+ it("${mode === "resource" ? `exercises ${names.pluralCamel} CRUD` : `creates and lists ${names.pluralCamel}`}", async () => {
4415
+ const seed${names.pluralPascal} = [
4416
+ {
4417
+ id: "00000000-0000-4000-8000-000000000101",
4418
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Alpha ${names.singularPascal}",
4419
+ version: 1,
4420
+ createdAt: "2024-01-01T00:00:00.000Z",
4421
+ updatedAt: "2024-01-01T00:00:00.000Z",
4422
+ },
4423
+ {
4424
+ id: "00000000-0000-4000-8000-000000000102",
4425
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Beta ${names.singularPascal}",
4426
+ version: 1,
4427
+ createdAt: "2024-01-02T00:00:00.000Z",
4428
+ updatedAt: "2024-01-02T00:00:00.000Z",
4429
+ },
4430
+ {
4431
+ id: "00000000-0000-4000-8000-000000000103",
4432
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Gamma ${names.singularPascal}",
4433
+ version: 1,
4434
+ createdAt: "2024-01-03T00:00:00.000Z",
4435
+ updatedAt: "2024-01-03T00:00:00.000Z",
4436
+ },
4437
+ ${options.tenant ? `\t\t\t{\n\t\t\t\tid: "00000000-0000-4000-8000-000000000104",\n\t\t\t\ttenantId: "tenant_other",\n\t\t\t\tname: "Other Tenant ${names.singularPascal}",\n\t\t\t\tversion: 1,\n\t\t\t\tcreatedAt: "2024-01-04T00:00:00.000Z",\n\t\t\t\tupdatedAt: "2024-01-04T00:00:00.000Z",\n\t\t\t},\n` : ""} ];
4438
+ const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository(seed${names.pluralPascal});
4439
+ const testFixture = createTestPorts<AppContext["ports"]>({
4440
+ base: appPorts,
4441
+ overrides: {
2686
4442
  ${names.pluralCamel},
2687
- })) as AppContext["ports"]["uow"],
2688
- devtools: createInMemoryDevtools(),
2689
- storage: createMemoryStorage(),
2690
- } as AppContext["ports"];
2691
- const actor = createAnonymousActor();
2692
- const tester = createUseCaseTester<AppContext>(() => ({
2693
- requestId: "test-request",
2694
- actor,
2695
- auth: null,
2696
- gate: testPorts.gate.bind({ actor, auth: null }),
2697
- ports: testPorts,
2698
- }));
4443
+ ${options.auth
4444
+ ? `\t\t\t\tauth: {
4445
+ getSession: async () => ({
4446
+ user: { id: "user_test", name: "Test User" },
4447
+ }),
4448
+ },
4449
+ gate: appPorts.gate,\n`
4450
+ : `\t\t\t\tauth: {
4451
+ getSession: async () => null,
4452
+ },\n`} devtools: createInMemoryDevtools(),
4453
+ },
4454
+ transaction: {
4455
+ ports: (ports) => ({
4456
+ ...ports,
4457
+ ${names.pluralCamel},
4458
+ }),
4459
+ },
4460
+ });
4461
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
4462
+ ports: testFixture.ports,
4463
+ actor: ${options.auth ? `createTestUserActor("user_test")` : "createTestAnonymousActor()"},
4464
+ tenant: ${options.tenant ? `createTestTenant("tenant_test")` : "null"},
4465
+ });
4466
+ const tester = createUseCaseTester<AppContext>(createTestContext);
2699
4467
 
2700
4468
  const ctx = await tester.ctx();
4469
+ const defaultPage = await tester.run(list${names.pluralPascal}UseCase, {}, { ctx });
4470
+ const filteredPage = await tester.run(
4471
+ list${names.pluralPascal}UseCase,
4472
+ { name: "alp" },
4473
+ { ctx },
4474
+ );
4475
+ const firstPage = await tester.run(
4476
+ list${names.pluralPascal}UseCase,
4477
+ { limit: 2 },
4478
+ { ctx },
4479
+ );
4480
+ const secondPage = await tester.run(
4481
+ list${names.pluralPascal}UseCase,
4482
+ { limit: 2, cursor: firstPage.page.nextCursor },
4483
+ { ctx },
4484
+ );
4485
+ const nameAscendingPage = await tester.run(
4486
+ list${names.pluralPascal}UseCase,
4487
+ { sortBy: "name", sortDirection: "asc" },
4488
+ { ctx },
4489
+ );
4490
+ const nameDescendingPage = await tester.run(
4491
+ list${names.pluralPascal}UseCase,
4492
+ { sortBy: "name", sortDirection: "desc" },
4493
+ { ctx },
4494
+ );
2701
4495
  const created = await tester.run(
2702
4496
  create${names.singularPascal}UseCase,
2703
4497
  { name: "First ${names.singularPascal}" },
@@ -2705,13 +4499,111 @@ describe("${names.pluralCamel} resource", () => {
2705
4499
  );
2706
4500
  const result = await tester.run(
2707
4501
  list${names.pluralPascal}UseCase,
2708
- { limit: 20, offset: 0 },
4502
+ { name: "First" },
2709
4503
  { ctx },
2710
4504
  );
2711
4505
 
4506
+ expect(defaultPage.page).toMatchObject({
4507
+ kind: "cursor",
4508
+ limit: 20,
4509
+ cursor: null,
4510
+ nextCursor: null,
4511
+ hasMore: false,
4512
+ });
4513
+ expect(defaultPage.items.map((item) => item.name)).toEqual([
4514
+ "Gamma ${names.singularPascal}",
4515
+ "Beta ${names.singularPascal}",
4516
+ "Alpha ${names.singularPascal}",
4517
+ ]);
4518
+ expect(filteredPage.items.map((item) => item.name)).toEqual([
4519
+ "Alpha ${names.singularPascal}",
4520
+ ]);
4521
+ expect(firstPage.items.map((item) => item.name)).toEqual([
4522
+ "Gamma ${names.singularPascal}",
4523
+ "Beta ${names.singularPascal}",
4524
+ ]);
4525
+ expect(firstPage.page.nextCursor).toEqual(expect.any(String));
4526
+ expect(secondPage.items.map((item) => item.name)).toEqual([
4527
+ "Alpha ${names.singularPascal}",
4528
+ ]);
4529
+ expect(secondPage.page.nextCursor).toBeNull();
4530
+ expect(nameAscendingPage.items.map((item) => item.name)).toEqual([
4531
+ "Alpha ${names.singularPascal}",
4532
+ "Beta ${names.singularPascal}",
4533
+ "Gamma ${names.singularPascal}",
4534
+ ]);
4535
+ expect(nameDescendingPage.items.map((item) => item.name)).toEqual([
4536
+ "Gamma ${names.singularPascal}",
4537
+ "Beta ${names.singularPascal}",
4538
+ "Alpha ${names.singularPascal}",
4539
+ ]);
2712
4540
  expect(created.name).toBe("First ${names.singularPascal}");
2713
- expect(result.total).toBe(1);
2714
- expect(result.${names.pluralCamel}).toEqual([created]);
4541
+ expect(result.items).toEqual([created]);
4542
+ ${mode === "resource"
4543
+ ? `
4544
+ const fetched = await tester.run(
4545
+ get${names.singularPascal}UseCase,
4546
+ { id: created.id },
4547
+ { ctx },
4548
+ );
4549
+ const updated = await tester.run(
4550
+ update${names.singularPascal}UseCase,
4551
+ { id: created.id, name: "Updated ${names.singularPascal}", version: fetched.version },
4552
+ { ctx },
4553
+ );
4554
+ try {
4555
+ await tester.run(
4556
+ update${names.singularPascal}UseCase,
4557
+ { id: created.id, name: "Stale ${names.singularPascal}", version: fetched.version },
4558
+ { ctx },
4559
+ );
4560
+ throw new Error("Expected ${names.singularPascal}Conflict");
4561
+ } catch (error) {
4562
+ expect(isAppError(error)).toBe(true);
4563
+ if (isAppError(error)) {
4564
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_CONFLICT");
4565
+ }
4566
+ }
4567
+ await tester.run(delete${names.singularPascal}UseCase, { id: created.id }, { ctx });
4568
+ const afterDelete = await tester.run(
4569
+ list${names.pluralPascal}UseCase,
4570
+ { name: "Updated" },
4571
+ { ctx },
4572
+ );
4573
+
4574
+ expect(fetched).toEqual(created);
4575
+ expect(updated.name).toBe("Updated ${names.singularPascal}");
4576
+ expect(updated.version).toBe(fetched.version + 1);
4577
+ expect(afterDelete.items).toEqual([]);
4578
+
4579
+ try {
4580
+ await tester.run(get${names.singularPascal}UseCase, { id: created.id }, { ctx });
4581
+ throw new Error("Expected ${names.singularPascal}NotFound");
4582
+ } catch (error) {
4583
+ expect(isAppError(error)).toBe(true);
4584
+ if (isAppError(error)) {
4585
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_NOT_FOUND");
4586
+ }
4587
+ }
4588
+ `
4589
+ : ""}
4590
+
4591
+ const app = await createTestApp({
4592
+ ports: testFixture.ports,
4593
+ routes: defineRoutes<AppContext>([${names.singularCamel}Routes]),
4594
+ context: appContext,
4595
+ });
4596
+ ${options.tenant ? `\t\tconst requester = createTestRequester(app, {\n\t\t\theaders: { "x-tenant-id": "tenant_test" },\n\t\t});\n` : ""}
4597
+ const createdViaRoute = await ${requestTarget}.request(create${names.singularPascal}, {
4598
+ body: { name: "Second ${names.singularPascal}" },
4599
+ });
4600
+ ${mode === "resource" ? ` const fetchedViaRoute = await ${requestTarget}.request(get${names.singularPascal}, {\n path: { id: createdViaRoute.id },\n });\n const updatedViaRoute = await ${requestTarget}.request(update${names.singularPascal}, {\n path: { id: createdViaRoute.id },\n body: { name: "Updated via route", version: fetchedViaRoute.version },\n });\n` : ""} const listedViaRoute = await ${requestTarget}.request(list${names.pluralPascal}, {
4601
+ query: { name: "${mode === "resource" ? "Updated via route" : "Second"}" },
4602
+ });
4603
+ ${mode === "resource" ? ` const deletedViaRoute = await ${requestTarget}.request(delete${names.singularPascal}, {\n path: { id: createdViaRoute.id },\n });\n const listedAfterDeleteViaRoute = await ${requestTarget}.request(list${names.pluralPascal}, {\n query: { name: "Updated via route" },\n });\n` : ""} await app.stop();
4604
+
4605
+ expect(createdViaRoute.name).toBe("Second ${names.singularPascal}");
4606
+ ${mode === "resource" ? ` expect(fetchedViaRoute).toEqual(createdViaRoute);\n expect(updatedViaRoute.name).toBe("Updated via route");\n expect(updatedViaRoute.version).toBe(fetchedViaRoute.version + 1);\n expect(deletedViaRoute).toBeUndefined();\n expect(listedViaRoute.items).toContainEqual(updatedViaRoute);\n expect(listedAfterDeleteViaRoute.items).toEqual([]);\n` : ` expect(listedViaRoute.items).toContainEqual(createdViaRoute);\n`}
2715
4607
  });
2716
4608
  });
2717
4609
  `;