@beignet/cli 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/README.md +379 -61
  3. package/dist/ansi.d.ts +10 -0
  4. package/dist/ansi.d.ts.map +1 -0
  5. package/dist/ansi.js +20 -0
  6. package/dist/ansi.js.map +1 -0
  7. package/dist/choices.d.ts +49 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +53 -0
  10. package/dist/choices.js.map +1 -0
  11. package/dist/completion.d.ts +47 -0
  12. package/dist/completion.d.ts.map +1 -0
  13. package/dist/completion.js +123 -0
  14. package/dist/completion.js.map +1 -0
  15. package/dist/config.d.ts +8 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +8 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/create-prompts.d.ts +41 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +78 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +10 -5
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +28 -47
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +1 -0
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +37 -2
  30. package/dist/db.js.map +1 -1
  31. package/dist/github-annotations.d.ts +18 -0
  32. package/dist/github-annotations.d.ts.map +1 -0
  33. package/dist/github-annotations.js +22 -0
  34. package/dist/github-annotations.js.map +1 -0
  35. package/dist/index.d.ts +1 -9
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +665 -603
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +21 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +1938 -131
  42. package/dist/inspect.js.map +1 -1
  43. package/dist/lib.d.ts +20 -0
  44. package/dist/lib.d.ts.map +1 -0
  45. package/dist/lib.js +17 -0
  46. package/dist/lib.js.map +1 -0
  47. package/dist/lint.d.ts +10 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +340 -33
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +20 -3
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +1896 -382
  54. package/dist/make.js.map +1 -1
  55. package/dist/outbox.d.ts +24 -0
  56. package/dist/outbox.d.ts.map +1 -0
  57. package/dist/outbox.js +138 -0
  58. package/dist/outbox.js.map +1 -0
  59. package/dist/schedule.d.ts +36 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +155 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/task.d.ts +26 -0
  64. package/dist/task.d.ts.map +1 -0
  65. package/dist/task.js +106 -0
  66. package/dist/task.js.map +1 -0
  67. package/dist/templates/base.d.ts +13 -0
  68. package/dist/templates/base.d.ts.map +1 -0
  69. package/dist/templates/base.js +300 -0
  70. package/dist/templates/base.js.map +1 -0
  71. package/dist/templates/db.d.ts +14 -0
  72. package/dist/templates/db.d.ts.map +1 -0
  73. package/dist/templates/db.js +962 -0
  74. package/dist/templates/db.js.map +1 -0
  75. package/dist/templates/index.d.ts +14 -0
  76. package/dist/templates/index.d.ts.map +1 -0
  77. package/dist/templates/index.js +123 -0
  78. package/dist/templates/index.js.map +1 -0
  79. package/dist/templates/server.d.ts +26 -0
  80. package/dist/templates/server.d.ts.map +1 -0
  81. package/dist/templates/server.js +520 -0
  82. package/dist/templates/server.js.map +1 -0
  83. package/dist/templates/shadcn.d.ts +5 -0
  84. package/dist/templates/shadcn.d.ts.map +1 -0
  85. package/dist/templates/shadcn.js +555 -0
  86. package/dist/templates/shadcn.js.map +1 -0
  87. package/dist/templates/shared.d.ts +49 -0
  88. package/dist/templates/shared.d.ts.map +1 -0
  89. package/dist/templates/shared.js +57 -0
  90. package/dist/templates/shared.js.map +1 -0
  91. package/dist/templates/shell.d.ts +5 -0
  92. package/dist/templates/shell.d.ts.map +1 -0
  93. package/dist/templates/shell.js +1190 -0
  94. package/dist/templates/shell.js.map +1 -0
  95. package/dist/templates/todos.d.ts +17 -0
  96. package/dist/templates/todos.d.ts.map +1 -0
  97. package/dist/templates/todos.js +607 -0
  98. package/dist/templates/todos.js.map +1 -0
  99. package/dist/version.d.ts +8 -0
  100. package/dist/version.d.ts.map +1 -0
  101. package/dist/version.js +18 -0
  102. package/dist/version.js.map +1 -0
  103. package/package.json +9 -8
  104. package/src/ansi.ts +30 -0
  105. package/src/choices.ts +83 -0
  106. package/src/completion.ts +169 -0
  107. package/src/config.ts +16 -0
  108. package/src/create-prompts.ts +114 -0
  109. package/src/create.ts +40 -64
  110. package/src/db.ts +60 -4
  111. package/src/github-annotations.ts +37 -0
  112. package/src/index.ts +1075 -817
  113. package/src/inspect.ts +2859 -115
  114. package/src/lib.ts +45 -0
  115. package/src/lint.ts +493 -39
  116. package/src/make.ts +2389 -406
  117. package/src/outbox.ts +249 -0
  118. package/src/schedule.ts +272 -0
  119. package/src/task.ts +169 -0
  120. package/src/templates/base.ts +377 -0
  121. package/src/templates/db.ts +963 -0
  122. package/src/templates/index.ts +148 -0
  123. package/src/templates/server.ts +528 -0
  124. package/src/templates/shadcn.ts +570 -0
  125. package/src/templates/shared.ts +90 -0
  126. package/src/templates/shell.ts +1219 -0
  127. package/src/templates/todos.ts +607 -0
  128. package/src/version.ts +20 -0
  129. package/dist/create-bin.d.ts +0 -3
  130. package/dist/create-bin.d.ts.map +0 -1
  131. package/dist/create-bin.js +0 -9
  132. package/dist/create-bin.js.map +0 -1
  133. package/dist/templates.d.ts +0 -55
  134. package/dist/templates.d.ts.map +0 -1
  135. package/dist/templates.js +0 -3520
  136. package/dist/templates.js.map +0 -1
  137. package/src/create-bin.ts +0 -11
  138. package/src/templates.ts +0 -3774
package/dist/make.js CHANGED
@@ -1,31 +1,54 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
4
- export const makeFeatureAddonChoices = [
5
- "policy",
6
- "event",
7
- "events",
8
- "job",
9
- "jobs",
10
- "notification",
11
- "notifications",
12
- "upload",
13
- "uploads",
14
- ];
5
+ export { makeFeatureAddonChoices } from "./choices.js";
15
6
  export async function makeResource(options) {
7
+ return makeResourceSlice(options, "resource");
8
+ }
9
+ export async function makeFeature(options) {
10
+ const addons = normalizeMakeFeatureAddons(options.with ?? []);
11
+ if (addons.length === 0) {
12
+ return makeFeatureResource(options);
13
+ }
14
+ if (options.dryRun) {
15
+ return makeFeatureSlice(options, addons);
16
+ }
17
+ await makeFeatureSlice({ ...options, dryRun: true }, addons);
18
+ return makeFeatureSlice(options, addons);
19
+ }
20
+ async function makeFeatureResource(options) {
21
+ try {
22
+ return await makeResourceSlice(options, "feature");
23
+ }
24
+ catch (error) {
25
+ if (error instanceof Error && error.message.includes("make resource")) {
26
+ throw new Error(error.message.replaceAll("make resource", "make feature"));
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+ async function makeResourceSlice(options, mode) {
16
32
  const targetDir = path.resolve(options.cwd ?? process.cwd());
17
33
  const names = resourceNames(options.name);
18
34
  const config = options.config
19
35
  ? resolveConfig(options.config)
20
36
  : await loadBeignetConfig(targetDir);
21
37
  await assertStandardApp(targetDir, config);
38
+ if (mode === "resource" &&
39
+ !(await fileExists(path.join(targetDir, resourceSharedErrorsPath(config))))) {
40
+ throw new Error(`beignet make resource expects an app error catalog. Missing ${resourceSharedErrorsPath(config)}.`);
41
+ }
22
42
  const persistence = await detectResourcePersistence(targetDir, config);
23
- const generatedFiles = resourceFiles(names, config, persistence);
43
+ const generationOptions = resourceGenerationOptions(options, mode);
44
+ const generatedFiles = resourceFiles(names, config, persistence, mode, generationOptions);
24
45
  const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
25
46
  force: Boolean(options.force),
26
47
  });
27
48
  const plannedWiringUpdates = await updateResourceWiring(targetDir, names, config, persistence, {
28
49
  dryRun: true,
50
+ mode,
51
+ generationOptions,
29
52
  });
30
53
  const createdFiles = plannedFiles
31
54
  .filter((file) => file.result === "created")
@@ -46,9 +69,12 @@ export async function makeResource(options) {
46
69
  updatedFiles.push(...(await updateResourceWiring(targetDir, names, config, {
47
70
  persistence,
48
71
  dryRun: false,
72
+ mode,
73
+ generationOptions,
49
74
  })));
50
75
  }
51
76
  return {
77
+ schemaVersion: 1,
52
78
  name: names.pluralKebab,
53
79
  targetDir,
54
80
  dryRun: Boolean(options.dryRun),
@@ -58,27 +84,16 @@ export async function makeResource(options) {
58
84
  skippedFiles,
59
85
  };
60
86
  }
61
- export async function makeFeature(options) {
62
- const addons = normalizeMakeFeatureAddons(options.with ?? []);
63
- if (addons.length === 0) {
64
- return makeFeatureResource(options);
65
- }
66
- if (options.dryRun) {
67
- return makeFeatureSlice(options, addons);
68
- }
69
- await makeFeatureSlice({ ...options, dryRun: true }, addons);
70
- return makeFeatureSlice(options, addons);
71
- }
72
- async function makeFeatureResource(options) {
73
- try {
74
- return await makeResource(options);
75
- }
76
- catch (error) {
77
- if (error instanceof Error && error.message.includes("make resource")) {
78
- throw new Error(error.message.replaceAll("make resource", "make feature"));
79
- }
80
- throw error;
87
+ function resourceGenerationOptions(options, mode) {
88
+ if (mode !== "resource") {
89
+ return { auth: false, tenant: false, events: false, softDelete: false };
81
90
  }
91
+ return {
92
+ auth: Boolean(options.auth),
93
+ tenant: Boolean(options.tenant),
94
+ events: Boolean(options.events),
95
+ softDelete: Boolean(options.softDelete),
96
+ };
82
97
  }
83
98
  async function makeFeatureSlice(options, addons) {
84
99
  let result = await makeFeatureResource(options);
@@ -97,6 +112,9 @@ async function makeFeatureAddon(addon, feature, options) {
97
112
  if (addon === "policy") {
98
113
  return makePolicy({ ...shared, name: feature });
99
114
  }
115
+ if (addon === "task") {
116
+ return makeTask({ ...shared, name: `${feature}/backfill` });
117
+ }
100
118
  if (addon === "event") {
101
119
  return makeEvent({ ...shared, name: `${feature}/created` });
102
120
  }
@@ -106,6 +124,9 @@ async function makeFeatureAddon(addon, feature, options) {
106
124
  if (addon === "notification") {
107
125
  return makeNotification({ ...shared, name: `${feature}/created` });
108
126
  }
127
+ if (addon === "ui") {
128
+ return makeFeatureUi({ ...shared, name: feature });
129
+ }
109
130
  return makeUpload({ ...shared, name: `${feature}/attachment` });
110
131
  }
111
132
  function normalizeMakeFeatureAddons(addons) {
@@ -113,6 +134,8 @@ function normalizeMakeFeatureAddons(addons) {
113
134
  for (const addon of addons) {
114
135
  if (addon === "events")
115
136
  normalized.add("event");
137
+ else if (addon === "tasks")
138
+ normalized.add("task");
116
139
  else if (addon === "jobs")
117
140
  normalized.add("job");
118
141
  else if (addon === "notifications")
@@ -126,9 +149,11 @@ function normalizeMakeFeatureAddons(addons) {
126
149
  }
127
150
  const featureAddonOrder = [
128
151
  "policy",
152
+ "task",
129
153
  "event",
130
154
  "job",
131
155
  "notification",
156
+ "ui",
132
157
  "upload",
133
158
  ];
134
159
  function mergeMakeResults(left, right) {
@@ -166,6 +191,7 @@ export async function makeContract(options) {
166
191
  skippedFiles.push(file.path);
167
192
  }
168
193
  return {
194
+ schemaVersion: 1,
169
195
  name: names.pluralKebab,
170
196
  targetDir,
171
197
  dryRun: Boolean(options.dryRun),
@@ -209,6 +235,7 @@ export async function makeUseCase(options) {
209
235
  if (indexResult === "skipped")
210
236
  skippedFiles.push(indexFile.path);
211
237
  return {
238
+ schemaVersion: 1,
212
239
  name: `${names.feature.kebab}/${names.action.kebab}`,
213
240
  targetDir,
214
241
  dryRun: Boolean(options.dryRun),
@@ -247,6 +274,7 @@ export async function makeTest(options) {
247
274
  updatedFiles.push("package.json");
248
275
  }
249
276
  return {
277
+ schemaVersion: 1,
250
278
  name: `${names.feature.kebab}/${names.action.kebab}`,
251
279
  targetDir,
252
280
  dryRun: Boolean(options.dryRun),
@@ -298,6 +326,7 @@ export async function makePort(options) {
298
326
  updatedFiles.push(config.paths.infrastructurePorts);
299
327
  }
300
328
  return {
329
+ schemaVersion: 1,
301
330
  name: names.kebab,
302
331
  targetDir,
303
332
  dryRun: Boolean(options.dryRun),
@@ -336,6 +365,7 @@ export async function makeAdapter(options) {
336
365
  updatedFiles.push(config.paths.infrastructurePorts);
337
366
  }
338
367
  return {
368
+ schemaVersion: 1,
339
369
  name: names.kebab,
340
370
  targetDir,
341
371
  dryRun: Boolean(options.dryRun),
@@ -369,6 +399,7 @@ export async function makePolicy(options) {
369
399
  skippedFiles.push(file.path);
370
400
  }
371
401
  return {
402
+ schemaVersion: 1,
372
403
  name: names.kebab,
373
404
  targetDir,
374
405
  dryRun: Boolean(options.dryRun),
@@ -411,13 +442,52 @@ export async function makeJob(options) {
411
442
  force: Boolean(options.force),
412
443
  dryRun: Boolean(options.dryRun),
413
444
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
414
- files: jobFiles(names, config),
445
+ files: [
446
+ ...(await missingCapabilityBuilderFiles(targetDir, "jobs", config)),
447
+ ...jobFiles(names, config),
448
+ ],
415
449
  index: featureArtifactIndexFile("job", names, config),
416
450
  dependencies: {
417
451
  "@beignet/core": beignetDependencyVersion,
418
452
  },
419
453
  });
420
454
  }
455
+ export async function makeTask(options) {
456
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
457
+ const names = taskNames(options.name);
458
+ const config = options.config
459
+ ? resolveConfig(options.config)
460
+ : await loadBeignetConfig(targetDir);
461
+ await assertFeatureArtifactApp(targetDir, config, "task");
462
+ const result = await makeFeatureArtifact({
463
+ targetDir,
464
+ config,
465
+ force: Boolean(options.force),
466
+ dryRun: Boolean(options.dryRun),
467
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
468
+ files: [
469
+ ...(await missingCapabilityBuilderFiles(targetDir, "tasks", config)),
470
+ ...taskFiles(names, config),
471
+ ],
472
+ index: featureArtifactIndexFile("task", names, config),
473
+ dependencies: {
474
+ "@beignet/core": beignetDependencyVersion,
475
+ },
476
+ });
477
+ const registryResult = await updateTaskRegistry(targetDir, names, config, {
478
+ dryRun: Boolean(options.dryRun),
479
+ });
480
+ if (registryResult === "created")
481
+ result.createdFiles.push(config.paths.tasks);
482
+ if (registryResult === "updated")
483
+ result.updatedFiles.push(config.paths.tasks);
484
+ if (registryResult === "skipped")
485
+ result.skippedFiles.push(config.paths.tasks);
486
+ if (!result.files.includes(config.paths.tasks)) {
487
+ result.files.push(config.paths.tasks);
488
+ }
489
+ return result;
490
+ }
421
491
  export async function makeFactory(options) {
422
492
  const targetDir = path.resolve(options.cwd ?? process.cwd());
423
493
  const names = factoryNames(options.name);
@@ -471,7 +541,10 @@ export async function makeNotification(options) {
471
541
  force: Boolean(options.force),
472
542
  dryRun: Boolean(options.dryRun),
473
543
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
474
- files: notificationFiles(names, config),
544
+ files: [
545
+ ...(await missingCapabilityBuilderFiles(targetDir, "notifications", config)),
546
+ ...notificationFiles(names, config),
547
+ ],
475
548
  index: featureArtifactIndexFile("notification", names, config),
476
549
  dependencies: {
477
550
  "@beignet/core": beignetDependencyVersion,
@@ -492,7 +565,10 @@ export async function makeListener(options) {
492
565
  force: Boolean(options.force),
493
566
  dryRun: Boolean(options.dryRun),
494
567
  name: `${names.feature.kebab}/${names.artifact.kebab}`,
495
- files: listenerFiles(names, config),
568
+ files: [
569
+ ...(await missingCapabilityBuilderFiles(targetDir, "listeners", config)),
570
+ ...listenerFiles(names, config),
571
+ ],
496
572
  index: featureArtifactIndexFile("listener", names, config),
497
573
  dependencies: {
498
574
  "@beignet/core": beignetDependencyVersion,
@@ -520,8 +596,16 @@ export async function makeSchedule(options) {
520
596
  await assertFeatureArtifactApp(targetDir, config, "schedule", {
521
597
  requireServer: Boolean(options.route),
522
598
  });
523
- const files = scheduleFiles(names, config, { route: Boolean(options.route) });
524
- return makeFeatureArtifact({
599
+ // Plan the CRON_SECRET wiring before writing any files so an anchor miss
600
+ // in lib/env.ts aborts the generator without leaving partial output.
601
+ const cronSecretPlan = options.route
602
+ ? await planCronSecretWiring(targetDir)
603
+ : undefined;
604
+ const files = [
605
+ ...(await missingCapabilityBuilderFiles(targetDir, "schedules", config)),
606
+ ...scheduleFiles(names, config, { route: Boolean(options.route) }),
607
+ ];
608
+ const result = await makeFeatureArtifact({
525
609
  targetDir,
526
610
  config,
527
611
  force: Boolean(options.force),
@@ -533,6 +617,100 @@ export async function makeSchedule(options) {
533
617
  "@beignet/core": beignetDependencyVersion,
534
618
  },
535
619
  });
620
+ const registryResult = await updateScheduleRegistry(targetDir, names, config, { dryRun: Boolean(options.dryRun) });
621
+ if (registryResult === "created")
622
+ result.createdFiles.push(config.paths.schedules);
623
+ if (registryResult === "updated")
624
+ result.updatedFiles.push(config.paths.schedules);
625
+ if (registryResult === "skipped")
626
+ result.skippedFiles.push(config.paths.schedules);
627
+ if (!result.files.includes(config.paths.schedules)) {
628
+ result.files.push(config.paths.schedules);
629
+ }
630
+ if (cronSecretPlan) {
631
+ const applied = await applyCronSecretWiring(targetDir, cronSecretPlan, {
632
+ dryRun: Boolean(options.dryRun),
633
+ });
634
+ result.createdFiles.push(...applied.createdFiles);
635
+ result.updatedFiles.push(...applied.updatedFiles);
636
+ result.skippedFiles.push(...applied.skippedFiles);
637
+ }
638
+ return result;
639
+ }
640
+ const cronSecretEnvFile = "lib/env.ts";
641
+ const cronSecretEnvEntry = `\t\tCRON_SECRET: z.string().min(1).optional(),`;
642
+ // The literal APP_URL line every generated lib/env.ts ships with. CRON_SECRET
643
+ // slots in directly below it, matching the documented env shape.
644
+ const cronSecretEnvAnchor = `\t\tAPP_URL: z.string().url().default("http://localhost:3000"),`;
645
+ async function planCronSecretWiring(targetDir) {
646
+ const plan = { files: [], skippedFiles: [] };
647
+ const manualInstruction = `Add ${cronSecretEnvEntry.trim()} to the createEnv server block in ${cronSecretEnvFile} manually, or restore the generated env file before running make schedule --route.`;
648
+ const envSource = await readOptionalFile(path.join(targetDir, cronSecretEnvFile));
649
+ if (envSource === undefined) {
650
+ throw new Error(`Could not find the generated env file ${cronSecretEnvFile}. ${manualInstruction}`);
651
+ }
652
+ if (/\bCRON_SECRET\b/.test(envSource)) {
653
+ plan.skippedFiles.push(cronSecretEnvFile);
654
+ }
655
+ else if (!envSource.includes(cronSecretEnvAnchor)) {
656
+ throw new Error(`Could not find the generated APP_URL server entry in ${cronSecretEnvFile}. ${manualInstruction}`);
657
+ }
658
+ else {
659
+ plan.files.push({
660
+ path: cronSecretEnvFile,
661
+ content: envSource.replace(cronSecretEnvAnchor, `${cronSecretEnvAnchor}\n${cronSecretEnvEntry}`),
662
+ result: "updated",
663
+ });
664
+ }
665
+ const secretLine = `CRON_SECRET=${randomBytes(16).toString("hex")}`;
666
+ const envExample = await readOptionalFile(path.join(targetDir, ".env.example"));
667
+ if (envExample === undefined) {
668
+ plan.files.push({
669
+ path: ".env.example",
670
+ content: `${secretLine}\n`,
671
+ result: "created",
672
+ });
673
+ }
674
+ else if (/^CRON_SECRET=/m.test(envExample)) {
675
+ plan.skippedFiles.push(".env.example");
676
+ }
677
+ else {
678
+ plan.files.push({
679
+ path: ".env.example",
680
+ content: `${envExample.replace(/\n*$/, "\n")}${secretLine}\n`,
681
+ result: "updated",
682
+ });
683
+ }
684
+ const envLocal = await readOptionalFile(path.join(targetDir, ".env.local"));
685
+ if (envLocal !== undefined) {
686
+ if (/^CRON_SECRET=/m.test(envLocal)) {
687
+ plan.skippedFiles.push(".env.local");
688
+ }
689
+ else {
690
+ plan.files.push({
691
+ path: ".env.local",
692
+ content: `${envLocal.replace(/\n*$/, "\n")}${secretLine}\n`,
693
+ result: "updated",
694
+ });
695
+ }
696
+ }
697
+ return plan;
698
+ }
699
+ async function applyCronSecretWiring(targetDir, plan, options) {
700
+ if (!options.dryRun) {
701
+ for (const file of plan.files) {
702
+ await writeFile(path.join(targetDir, file.path), file.content);
703
+ }
704
+ }
705
+ return {
706
+ createdFiles: plan.files
707
+ .filter((file) => file.result === "created")
708
+ .map((file) => file.path),
709
+ updatedFiles: plan.files
710
+ .filter((file) => file.result === "updated")
711
+ .map((file) => file.path),
712
+ skippedFiles: plan.skippedFiles,
713
+ };
536
714
  }
537
715
  export async function makeUpload(options) {
538
716
  const targetDir = path.resolve(options.cwd ?? process.cwd());
@@ -554,6 +732,58 @@ export async function makeUpload(options) {
554
732
  },
555
733
  });
556
734
  }
735
+ async function makeFeatureUi(options) {
736
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
737
+ const names = featureUiNames(options.name);
738
+ const config = options.config
739
+ ? resolveConfig(options.config)
740
+ : await loadBeignetConfig(targetDir);
741
+ await assertFeatureUiApp(targetDir, config);
742
+ const componentFiles = featureUiComponentFiles(names, config);
743
+ const indexFile = featureUiIndexFile(names, config);
744
+ const plannedFiles = await planGeneratedFiles(targetDir, componentFiles, {
745
+ force: Boolean(options.force),
746
+ });
747
+ const createdFiles = plannedFiles
748
+ .filter((file) => file.result === "created")
749
+ .map((file) => file.path);
750
+ const updatedFiles = plannedFiles
751
+ .filter((file) => file.result === "updated")
752
+ .map((file) => file.path);
753
+ const skippedFiles = plannedFiles
754
+ .filter((file) => file.result === "skipped")
755
+ .map((file) => file.path);
756
+ if (!options.dryRun) {
757
+ for (const file of plannedFiles) {
758
+ await writePlannedGeneratedFile(targetDir, file);
759
+ }
760
+ }
761
+ const indexResult = await updateFeatureUiIndex(targetDir, indexFile, {
762
+ dryRun: Boolean(options.dryRun),
763
+ });
764
+ if (indexResult === "created")
765
+ createdFiles.push(indexFile.path);
766
+ if (indexResult === "updated")
767
+ updatedFiles.push(indexFile.path);
768
+ if (indexResult === "skipped")
769
+ skippedFiles.push(indexFile.path);
770
+ if (await updatePackageDependencies(targetDir, {
771
+ "@beignet/react-query": beignetDependencyVersion,
772
+ "@tanstack/react-query": tanstackReactQueryDependencyVersion,
773
+ }, { dryRun: Boolean(options.dryRun) })) {
774
+ updatedFiles.push("package.json");
775
+ }
776
+ return {
777
+ schemaVersion: 1,
778
+ name: names.pluralKebab,
779
+ targetDir,
780
+ dryRun: Boolean(options.dryRun),
781
+ files: [...componentFiles.map((file) => file.path), indexFile.path],
782
+ createdFiles,
783
+ updatedFiles,
784
+ skippedFiles,
785
+ };
786
+ }
557
787
  async function assertStandardApp(targetDir, config) {
558
788
  const requiredFiles = [
559
789
  config.paths.appContext,
@@ -646,6 +876,18 @@ async function assertFeatureArtifactApp(targetDir, config, artifact, options = {
646
876
  }
647
877
  }
648
878
  }
879
+ async function assertFeatureUiApp(targetDir, config) {
880
+ const clientPath = clientIndexPath(config);
881
+ const requiredFiles = [config.paths.appContext, clientPath];
882
+ for (const file of requiredFiles) {
883
+ try {
884
+ await stat(path.join(targetDir, file));
885
+ }
886
+ catch {
887
+ throw new Error(`beignet make feature --with ui expects a standard Beignet app with React Query client helpers. Missing ${file}.`);
888
+ }
889
+ }
890
+ }
649
891
  async function assertPersistenceArtifactApp(targetDir, names, config, artifact) {
650
892
  const requiredFiles = [
651
893
  config.paths.appContext,
@@ -712,6 +954,62 @@ async function readOptionalFile(filePath) {
712
954
  return undefined;
713
955
  }
714
956
  }
957
+ const capabilityBuilders = {
958
+ listeners: {
959
+ factoryName: "createListeners",
960
+ defineName: "defineListener",
961
+ module: "@beignet/core/events",
962
+ },
963
+ jobs: {
964
+ factoryName: "createJobs",
965
+ defineName: "defineJob",
966
+ module: "@beignet/core/jobs",
967
+ },
968
+ schedules: {
969
+ factoryName: "createSchedules",
970
+ defineName: "defineSchedule",
971
+ module: "@beignet/core/schedules",
972
+ },
973
+ notifications: {
974
+ factoryName: "createNotifications",
975
+ defineName: "defineNotification",
976
+ module: "@beignet/core/notifications",
977
+ },
978
+ tasks: {
979
+ factoryName: "createTasks",
980
+ defineName: "defineTask",
981
+ module: "@beignet/core/tasks",
982
+ },
983
+ };
984
+ function capabilityBuilderPath(capability, config) {
985
+ if (capability === "listeners")
986
+ return config.paths.listenersBuilder;
987
+ if (capability === "jobs")
988
+ return config.paths.jobsBuilder;
989
+ if (capability === "schedules")
990
+ return config.paths.schedulesBuilder;
991
+ if (capability === "notifications")
992
+ return config.paths.notificationsBuilder;
993
+ return config.paths.tasksBuilder;
994
+ }
995
+ function capabilityBuilderFile(capability, config) {
996
+ const builder = capabilityBuilders[capability];
997
+ return {
998
+ path: capabilityBuilderPath(capability, config),
999
+ content: `import { ${builder.factoryName} } from "${builder.module}";
1000
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
1001
+
1002
+ export const { ${builder.defineName} } = ${builder.factoryName}<AppContext>();
1003
+ `,
1004
+ };
1005
+ }
1006
+ async function missingCapabilityBuilderFiles(targetDir, capability, config) {
1007
+ const file = capabilityBuilderFile(capability, config);
1008
+ if (await fileExists(path.join(targetDir, file.path))) {
1009
+ return [];
1010
+ }
1011
+ return [file];
1012
+ }
715
1013
  async function makeFeatureArtifact(options) {
716
1014
  const plannedFiles = await planGeneratedFiles(options.targetDir, options.files, {
717
1015
  force: options.force,
@@ -743,6 +1041,7 @@ async function makeFeatureArtifact(options) {
743
1041
  updatedFiles.push("package.json");
744
1042
  }
745
1043
  return {
1044
+ schemaVersion: 1,
746
1045
  name: options.name,
747
1046
  targetDir: options.targetDir,
748
1047
  dryRun: options.dryRun,
@@ -772,6 +1071,9 @@ function beignetDependencyVersion(packageJson) {
772
1071
  packageJson.dependencies?.["@beignet/next"] ??
773
1072
  "*");
774
1073
  }
1074
+ function tanstackReactQueryDependencyVersion(packageJson) {
1075
+ return packageJson.dependencies?.["@tanstack/react-query"] ?? "^5.100.7";
1076
+ }
775
1077
  async function updateResourceWiring(targetDir, names, config, persistenceOrOptions, maybeOptions) {
776
1078
  const persistence = typeof persistenceOrOptions === "string"
777
1079
  ? persistenceOrOptions
@@ -779,6 +1081,14 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
779
1081
  const options = typeof persistenceOrOptions === "string"
780
1082
  ? maybeOptions
781
1083
  : persistenceOrOptions;
1084
+ const mode = typeof persistenceOrOptions === "string"
1085
+ ? (maybeOptions?.mode ?? "feature")
1086
+ : (persistenceOrOptions.mode ?? "feature");
1087
+ const generationOptions = typeof persistenceOrOptions === "string"
1088
+ ? (maybeOptions?.generationOptions ??
1089
+ resourceGenerationOptions({ name: names.pluralKebab }, mode))
1090
+ : (persistenceOrOptions.generationOptions ??
1091
+ resourceGenerationOptions({ name: names.pluralKebab }, mode));
782
1092
  const updated = new Set();
783
1093
  if (await updatePackageJson(targetDir, {
784
1094
  ...options,
@@ -786,13 +1096,24 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
786
1096
  })) {
787
1097
  updated.add("package.json");
788
1098
  }
789
- if (await updatePortsIndex(targetDir, names, config, options)) {
1099
+ if (await updatePortsIndex(targetDir, names, config, {
1100
+ ...options,
1101
+ generationOptions,
1102
+ })) {
790
1103
  updated.add(config.paths.ports);
791
1104
  }
792
1105
  if (persistence === "memory" &&
793
1106
  (await updateInfrastructurePorts(targetDir, names, config, options))) {
794
1107
  updated.add(config.paths.infrastructurePorts);
795
1108
  }
1109
+ if (generationOptions.auth &&
1110
+ (await updateInfrastructureGatePolicies(targetDir, names, config, options))) {
1111
+ updated.add(config.paths.infrastructurePorts);
1112
+ }
1113
+ if (persistence === "drizzle" &&
1114
+ (await updateInfrastructureDeferredPorts(targetDir, names, config, options))) {
1115
+ updated.add(config.paths.infrastructurePorts);
1116
+ }
796
1117
  if (persistence === "drizzle" &&
797
1118
  (await updateDrizzleSchemaIndex(targetDir, names, config, options))) {
798
1119
  updated.add(drizzleSchemaIndexPath(config));
@@ -805,7 +1126,10 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
805
1126
  (await updateServerTransactionPorts(targetDir, names, config, options))) {
806
1127
  updated.add(config.paths.server);
807
1128
  }
808
- if (await updateOpenApiRoute(targetDir, names, config, options)) {
1129
+ if (await updateSharedErrors(targetDir, names, config, { ...options, mode })) {
1130
+ updated.add(resourceSharedErrorsPath(config));
1131
+ }
1132
+ if (await updateOpenApiRoute(targetDir, names, config, { ...options, mode })) {
809
1133
  updated.add(config.paths.openapiRoute);
810
1134
  }
811
1135
  const routesFile = await updateServerRoutes(targetDir, names, config, options);
@@ -840,6 +1164,16 @@ async function updatePortsIndex(targetDir, names, config, options) {
840
1164
  if (!next.includes(importLine)) {
841
1165
  next = insertAfterImports(next, importLine);
842
1166
  }
1167
+ if (options.generationOptions?.auth) {
1168
+ const policyPath = path.join(config.paths.features, names.pluralKebab, "policy.ts");
1169
+ const policyImportLine = `import type { AuthorizationContext as ${names.singularPascal}AuthorizationContext, ${names.singularCamel}Policy } from "${relativeModule(config.paths.ports, policyPath)}";`;
1170
+ if (!next.includes(policyImportLine)) {
1171
+ next = insertAfterImports(next, policyImportLine);
1172
+ }
1173
+ next = appendUnionTypeMember(next, "AppAuthorizationContext", `${names.singularPascal}AuthorizationContext`);
1174
+ next = appendTupleTypeMember(next, "AppGate", `typeof ${names.singularCamel}Policy`);
1175
+ next = appendGatePolicyMember(next, `typeof ${names.singularCamel}Policy`);
1176
+ }
843
1177
  const entry = `\t${names.pluralCamel}: ${names.singularPascal}Repository;`;
844
1178
  if (!next.includes(entry.trim())) {
845
1179
  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.`);
@@ -934,7 +1268,7 @@ async function updateInfrastructurePorts(targetDir, names, config, options) {
934
1268
  }
935
1269
  const repositoryConst = `const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();`;
936
1270
  if (!next.includes(repositoryConst)) {
937
- 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.`);
1271
+ 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.`);
938
1272
  }
939
1273
  const entry = `\t${names.pluralCamel},`;
940
1274
  const definePortsBody = definePortsBodySource(next);
@@ -954,6 +1288,22 @@ async function updateInfrastructurePorts(targetDir, names, config, options) {
954
1288
  await writeFile(filePath, next);
955
1289
  return true;
956
1290
  }
1291
+ async function updateInfrastructureGatePolicies(targetDir, names, config, options) {
1292
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1293
+ const original = await readFile(filePath, "utf8");
1294
+ let next = original;
1295
+ const policyPath = path.join(config.paths.features, names.pluralKebab, "policy.ts");
1296
+ const importLine = `import { ${names.singularCamel}Policy } from "${relativeModule(config.paths.infrastructurePorts, policyPath)}";`;
1297
+ if (!next.includes(importLine)) {
1298
+ next = insertAfterImports(next, importLine);
1299
+ }
1300
+ 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.`);
1301
+ if (next === original)
1302
+ return false;
1303
+ if (!options.dryRun)
1304
+ await writeFile(filePath, next);
1305
+ return true;
1306
+ }
957
1307
  async function updateDrizzleSchemaIndex(targetDir, names, config, options) {
958
1308
  const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
959
1309
  if (!(await fileExists(filePath)))
@@ -992,6 +1342,19 @@ async function updateDrizzleRepositories(targetDir, names, config, options) {
992
1342
  await writeFile(filePath, next);
993
1343
  return true;
994
1344
  }
1345
+ async function updateInfrastructureDeferredPorts(targetDir, names, config, options) {
1346
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1347
+ const original = await readFile(filePath, "utf8");
1348
+ // Drizzle resources are provided by the app database provider at startup,
1349
+ // so deferred-form ports files defer the new repository key. Identity-form
1350
+ // ports files are left untouched.
1351
+ const next = appendDeferredPortKey(original, names.pluralCamel);
1352
+ if (next === original)
1353
+ return false;
1354
+ if (!options.dryRun)
1355
+ await writeFile(filePath, next);
1356
+ return true;
1357
+ }
995
1358
  async function updateServerTransactionPorts(targetDir, names, config, options) {
996
1359
  const filePath = path.join(targetDir, config.paths.server);
997
1360
  const original = await readFile(filePath, "utf8");
@@ -1020,13 +1383,12 @@ async function updateOpenApiRoute(targetDir, names, config, options) {
1020
1383
  const firstArg = firstCreateOpenAPIHandlerArgInfo(original);
1021
1384
  if (!firstArg?.text.startsWith("["))
1022
1385
  return false;
1023
- const listName = `list${names.pluralPascal}`;
1024
- const createName = `create${names.singularPascal}`;
1386
+ const contractNames = resourceContractNames(names, options.mode);
1025
1387
  const listedContracts = identifiersFromArrayExpression(firstArg.text);
1026
- const missingContracts = [listName, createName].filter((contractName) => !listedContracts.has(contractName));
1388
+ const missingContracts = contractNames.filter((contractName) => !listedContracts.has(contractName));
1027
1389
  if (missingContracts.length === 0)
1028
1390
  return false;
1029
- const importLine = `import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
1391
+ const importLine = `import { ${contractNames.join(", ")} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
1030
1392
  const nextArray = appendToArrayExpression(firstArg.text, missingContracts);
1031
1393
  let next = `${original.slice(0, firstArg.start)}${nextArray}${original.slice(firstArg.end)}`;
1032
1394
  if (!next.includes(importLine)) {
@@ -1038,6 +1400,53 @@ async function updateOpenApiRoute(targetDir, names, config, options) {
1038
1400
  await writeFile(filePath, next);
1039
1401
  return true;
1040
1402
  }
1403
+ async function updateSharedErrors(targetDir, names, config, options) {
1404
+ if (options.mode !== "resource")
1405
+ return false;
1406
+ const filePath = path.join(targetDir, resourceSharedErrorsPath(config));
1407
+ if (!(await fileExists(filePath)))
1408
+ return false;
1409
+ const original = await readFile(filePath, "utf8");
1410
+ const notFoundErrorName = `${names.singularPascal}NotFound`;
1411
+ const conflictErrorName = `${names.singularPascal}Conflict`;
1412
+ const missingErrorNames = [notFoundErrorName, conflictErrorName].filter((errorName) => !new RegExp(`\\b${errorName}\\b`).test(original));
1413
+ if (missingErrorNames.length === 0)
1414
+ return false;
1415
+ const expectedErrorNames = missingErrorNames.join(" and ");
1416
+ const defineErrorsStart = original.indexOf("defineErrors(");
1417
+ if (defineErrorsStart === -1) {
1418
+ throw new Error(`Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
1419
+ }
1420
+ const openBrace = original.indexOf("{", defineErrorsStart);
1421
+ if (openBrace === -1) {
1422
+ throw new Error(`Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
1423
+ }
1424
+ const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
1425
+ if (closeBrace === -1) {
1426
+ 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.`);
1427
+ }
1428
+ const entries = [];
1429
+ if (missingErrorNames.includes(notFoundErrorName)) {
1430
+ entries.push(`${notFoundErrorName}: {
1431
+ \t\tcode: "${constantCase(names.singularKebab)}_NOT_FOUND",
1432
+ \t\tstatus: 404,
1433
+ \t\tmessage: "${names.singularPascal} not found",
1434
+ \t},`);
1435
+ }
1436
+ if (missingErrorNames.includes(conflictErrorName)) {
1437
+ entries.push(`${conflictErrorName}: {
1438
+ \t\tcode: "${constantCase(names.singularKebab)}_CONFLICT",
1439
+ \t\tstatus: 409,
1440
+ \t\tmessage: "${names.singularPascal} was changed by another request",
1441
+ \t},`);
1442
+ }
1443
+ const next = insertBeforeClosingBrace(original, openBrace, closeBrace, entries.join("\n"));
1444
+ if (next === original)
1445
+ return false;
1446
+ if (!options.dryRun)
1447
+ await writeFile(filePath, next);
1448
+ return true;
1449
+ }
1041
1450
  async function updateServerRoutes(targetDir, names, config, options) {
1042
1451
  const filePath = path.join(targetDir, config.paths.server);
1043
1452
  const original = await readFile(filePath, "utf8");
@@ -1299,6 +1708,67 @@ function typeBodySource(source, typeName) {
1299
1708
  return undefined;
1300
1709
  return source.slice(openBrace + 1, closeBrace);
1301
1710
  }
1711
+ function appendUnionTypeMember(source, typeName, member) {
1712
+ const match = new RegExp(`(export\\s+type\\s+${typeName}\\s*=)([\\s\\S]*?);`).exec(source);
1713
+ if (!match)
1714
+ return source;
1715
+ const declaration = match[0];
1716
+ if (new RegExp(`\\b${member}\\b`).test(declaration))
1717
+ return source;
1718
+ const nextDeclaration = declaration.replace(/;\s*$/, ` | ${member};`);
1719
+ return `${source.slice(0, match.index)}${nextDeclaration}${source.slice(match.index + declaration.length)}`;
1720
+ }
1721
+ function appendTupleTypeMember(source, typeName, member) {
1722
+ const match = new RegExp(`export\\s+type\\s+${typeName}\\s*=\\s*`).exec(source);
1723
+ if (!match)
1724
+ return source;
1725
+ const tupleStart = source.indexOf("[", match.index);
1726
+ if (tupleStart === -1)
1727
+ return source;
1728
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
1729
+ if (tupleEnd === -1)
1730
+ return source;
1731
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
1732
+ if (tuple.includes(member))
1733
+ return source;
1734
+ const nextTuple = appendToArrayExpression(tuple, [member]);
1735
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(tupleEnd + 1)}`;
1736
+ }
1737
+ function appendGatePolicyMember(source, member) {
1738
+ const gateMatch = /\bGatePort\s*</.exec(source);
1739
+ if (!gateMatch)
1740
+ return source;
1741
+ const tupleStart = source.indexOf("[", gateMatch.index);
1742
+ if (tupleStart === -1)
1743
+ return source;
1744
+ const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
1745
+ if (tupleEnd === -1)
1746
+ return source;
1747
+ const tuple = source.slice(tupleStart, tupleEnd + 1);
1748
+ if (tuple.includes(member))
1749
+ return source;
1750
+ const nextTuple = appendToArrayExpression(tuple, [member]);
1751
+ return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(tupleEnd + 1)}`;
1752
+ }
1753
+ function appendCreateGatePolicy(source, policy, failureMessage) {
1754
+ const policyMatch = /\bpolicies\s*:\s*\[/.exec(source);
1755
+ if (!policyMatch) {
1756
+ throw new Error(failureMessage);
1757
+ }
1758
+ const arrayStart = source.indexOf("[", policyMatch.index);
1759
+ if (arrayStart === -1) {
1760
+ throw new Error(failureMessage);
1761
+ }
1762
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
1763
+ if (arrayEnd === -1) {
1764
+ throw new Error(failureMessage);
1765
+ }
1766
+ const policies = source.slice(arrayStart, arrayEnd + 1);
1767
+ if (policies.includes(policy))
1768
+ return source;
1769
+ const nextPolicies = appendToArrayExpression(policies, [policy]);
1770
+ return `${source.slice(0, arrayStart)}${nextPolicies}${source.slice(arrayEnd + 1)}`;
1771
+ }
1302
1772
  function insertDefinePortsProperty(source, property, message) {
1303
1773
  const body = definePortsBodyRange(source);
1304
1774
  if (!body)
@@ -1348,7 +1818,15 @@ function definePortsBodySource(source) {
1348
1818
  return source.slice(body.openBrace + 1, body.closeBrace);
1349
1819
  }
1350
1820
  function definePortsBodyRange(source) {
1351
- const match = /definePorts(?:<[^>]+>)?\(\s*\{/.exec(source);
1821
+ const outer = definePortsOuterBodyRange(source);
1822
+ if (!outer)
1823
+ return undefined;
1824
+ // The curried `definePorts<AppPorts>()({ bound, deferred })` form binds app
1825
+ // ports inside `bound`; generated bindings belong there.
1826
+ return (topLevelBoundObjectRange(source, outer.openBrace, outer.closeBrace) ?? outer);
1827
+ }
1828
+ function definePortsOuterBodyRange(source) {
1829
+ const match = /definePorts(?:<[^>]+>)?\(\s*(?:\)\s*\(\s*)?\{/.exec(source);
1352
1830
  if (!match)
1353
1831
  return undefined;
1354
1832
  const openBrace = source.indexOf("{", match.index);
@@ -1357,6 +1835,39 @@ function definePortsBodyRange(source) {
1357
1835
  return undefined;
1358
1836
  return { openBrace, closeBrace };
1359
1837
  }
1838
+ function appendDeferredPortKey(source, portKey) {
1839
+ const outer = definePortsOuterBodyRange(source);
1840
+ if (!outer)
1841
+ return source;
1842
+ const body = source.slice(outer.openBrace + 1, outer.closeBrace);
1843
+ if (!hasTopLevelObjectProperty(body, "deferred"))
1844
+ return source;
1845
+ const deferredMatch = /\bdeferred\s*:\s*\[/.exec(body);
1846
+ if (!deferredMatch)
1847
+ return source;
1848
+ const arrayStart = source.indexOf("[", outer.openBrace + 1 + deferredMatch.index);
1849
+ const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
1850
+ if (arrayEnd === -1)
1851
+ return source;
1852
+ const tuple = source.slice(arrayStart, arrayEnd + 1);
1853
+ if (new RegExp(`["']${portKey}["']`).test(tuple))
1854
+ return source;
1855
+ const nextTuple = appendToArrayExpression(tuple, [`"${portKey}"`]);
1856
+ return `${source.slice(0, arrayStart)}${nextTuple}${source.slice(arrayEnd + 1)}`;
1857
+ }
1858
+ function topLevelBoundObjectRange(source, openBrace, closeBrace) {
1859
+ const body = source.slice(openBrace + 1, closeBrace);
1860
+ if (!hasTopLevelObjectProperty(body, "bound"))
1861
+ return undefined;
1862
+ const boundMatch = /\bbound\s*:\s*\{/.exec(body);
1863
+ if (!boundMatch)
1864
+ return undefined;
1865
+ const boundOpen = source.indexOf("{", openBrace + 1 + boundMatch.index);
1866
+ const boundClose = matchingBraceIndex(source, boundOpen);
1867
+ if (boundClose === -1)
1868
+ return undefined;
1869
+ return { openBrace: boundOpen, closeBrace: boundClose };
1870
+ }
1360
1871
  function hasTopLevelObjectProperty(source, propertyName) {
1361
1872
  let depth = 0;
1362
1873
  let inString;
@@ -1586,6 +2097,18 @@ function resourceNames(input) {
1586
2097
  ]);
1587
2098
  return names;
1588
2099
  }
2100
+ function featureUiNames(input) {
2101
+ const names = resourceNames(input);
2102
+ const resolvedNames = {
2103
+ ...names,
2104
+ componentExportName: `${names.pluralPascal}Panel`,
2105
+ componentFileName: `${names.pluralKebab}-panel`,
2106
+ };
2107
+ assertGeneratedIdentifiers(input, "Feature UI name", [
2108
+ resolvedNames.componentExportName,
2109
+ ]);
2110
+ return resolvedNames;
2111
+ }
1589
2112
  function featureResourceNames(names) {
1590
2113
  return resourceNames(names.feature.kebab);
1591
2114
  }
@@ -1703,6 +2226,18 @@ function jobNames(input) {
1703
2226
  payloadTypeName: `${jobExportName}Payload`,
1704
2227
  };
1705
2228
  }
2229
+ function taskNames(input) {
2230
+ const names = featureArtifactNames(input, "Task");
2231
+ const taskExportName = `${names.artifact.camel}Task`;
2232
+ assertGeneratedIdentifiers(input, "Task name", [taskExportName]);
2233
+ return {
2234
+ ...names,
2235
+ taskName: names.stableName,
2236
+ taskExportName,
2237
+ inputSchemaName: `${names.artifact.pascal}TaskInputSchema`,
2238
+ inputTypeName: `${names.artifact.pascal}TaskInput`,
2239
+ };
2240
+ }
1706
2241
  function factoryNames(input) {
1707
2242
  const names = featureArtifactNames(input, "Factory");
1708
2243
  const factoryExportName = `${names.artifact.camel}Factory`;
@@ -1901,59 +2436,119 @@ function pascalCase(value) {
1901
2436
  const camel = camelCase(value);
1902
2437
  return `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
1903
2438
  }
1904
- function resourceFiles(names, config, persistence = "memory") {
2439
+ function constantCase(value) {
2440
+ return value.replaceAll("-", "_").toUpperCase();
2441
+ }
2442
+ function resourceFiles(names, config, persistence = "memory", mode = "feature", options = {
2443
+ auth: false,
2444
+ tenant: false,
2445
+ events: false,
2446
+ softDelete: false,
2447
+ }) {
1905
2448
  const useCaseDir = resourceUseCaseDir(names, config);
1906
2449
  const infraDir = infrastructureDir(config);
1907
2450
  const featureDir = resourceFeatureDir(names, config);
1908
2451
  const files = [
1909
2452
  {
1910
2453
  path: resourceContractFilePath(names, config),
1911
- content: contractFile(names, config),
2454
+ content: contractFile(names, config, mode, options),
1912
2455
  },
1913
2456
  {
1914
- path: path.join(useCaseDir, "schemas.ts"),
1915
- content: schemasFile(names),
2457
+ path: path.join(featureDir, "schemas.ts"),
2458
+ content: schemasFile(names, mode, options),
1916
2459
  },
1917
2460
  {
1918
2461
  path: path.join(useCaseDir, `list-${names.pluralKebab}.ts`),
1919
- content: listUseCaseFile(names, config),
2462
+ content: listUseCaseFile(names, config, options),
1920
2463
  },
1921
2464
  {
1922
2465
  path: path.join(useCaseDir, `create-${names.singularKebab}.ts`),
1923
- content: createUseCaseFile(names, config),
2466
+ content: createUseCaseFile(names, config, options),
1924
2467
  },
2468
+ ...(mode === "resource"
2469
+ ? [
2470
+ {
2471
+ path: path.join(useCaseDir, `get-${names.singularKebab}.ts`),
2472
+ content: getUseCaseFile(names, config, options),
2473
+ },
2474
+ {
2475
+ path: path.join(useCaseDir, `update-${names.singularKebab}.ts`),
2476
+ content: updateUseCaseFile(names, config, options),
2477
+ },
2478
+ {
2479
+ path: path.join(useCaseDir, `delete-${names.singularKebab}.ts`),
2480
+ content: deleteUseCaseFile(names, config, options),
2481
+ },
2482
+ {
2483
+ path: path.join(featureDir, "policy.ts"),
2484
+ content: resourcePolicyFile(names, config, options),
2485
+ },
2486
+ ]
2487
+ : []),
2488
+ ...(mode === "resource" && options.events
2489
+ ? [
2490
+ {
2491
+ path: path.join(featureDir, "domain/events/index.ts"),
2492
+ content: resourceEventsFile(names),
2493
+ },
2494
+ ]
2495
+ : []),
2496
+ ...(mode === "resource" && options.auth
2497
+ ? [
2498
+ {
2499
+ path: path.join(featureDir, "tests/policy.test.ts"),
2500
+ content: resourcePolicyTestFile(names, options),
2501
+ },
2502
+ ]
2503
+ : []),
1925
2504
  {
1926
2505
  path: path.join(useCaseDir, "index.ts"),
1927
- content: useCasesIndexFile(names),
2506
+ content: useCasesIndexFile(names, mode),
1928
2507
  },
1929
2508
  {
1930
2509
  path: resourcePortFilePath(names, config),
1931
- content: repositoryPortFile(names, config),
2510
+ content: repositoryPortFile(names, config, mode, options),
1932
2511
  },
1933
2512
  {
1934
2513
  path: path.join(infraDir, names.pluralKebab, `in-memory-${names.singularKebab}-repository.ts`),
1935
- content: inMemoryRepositoryFile(names, config),
2514
+ content: inMemoryRepositoryFile(names, config, mode, options),
1936
2515
  },
1937
2516
  {
1938
2517
  path: path.join(featureDir, "routes.ts"),
1939
- content: routeGroupFile(names, config),
2518
+ content: routeGroupFile(names, config, mode, options),
1940
2519
  },
1941
2520
  {
1942
2521
  path: resourceTestFilePath(names, config),
1943
- content: testFile(names, config),
2522
+ content: testFile(names, config, mode, options),
1944
2523
  },
1945
2524
  ];
1946
2525
  if (persistence === "drizzle") {
1947
2526
  files.push({
1948
2527
  path: drizzleResourceSchemaFilePath(names, config),
1949
- content: drizzleSchemaFile(names),
2528
+ content: drizzleSchemaFile(names, options),
1950
2529
  }, {
1951
2530
  path: drizzleResourceRepositoryFilePath(names, config),
1952
- content: drizzleRepositoryFile(names, config),
2531
+ content: drizzleRepositoryFile(names, config, mode, options),
1953
2532
  });
1954
2533
  }
1955
2534
  return files;
1956
2535
  }
2536
+ function featureUiComponentFiles(names, config) {
2537
+ const componentPath = featureUiComponentFilePath(names, config);
2538
+ return [
2539
+ {
2540
+ path: componentPath,
2541
+ content: featureUiComponentFile(names, config),
2542
+ },
2543
+ ];
2544
+ }
2545
+ function featureUiIndexFile(names, config) {
2546
+ return {
2547
+ path: featureUiIndexFilePath(names, config),
2548
+ content: `export { ${names.componentExportName} } from "./${names.componentFileName}";
2549
+ `,
2550
+ };
2551
+ }
1957
2552
  function contractFiles(names, config) {
1958
2553
  return [
1959
2554
  {
@@ -2019,6 +2614,14 @@ function jobFiles(names, config) {
2019
2614
  },
2020
2615
  ];
2021
2616
  }
2617
+ function taskFiles(names, config) {
2618
+ return [
2619
+ {
2620
+ path: taskFilePath(names, config),
2621
+ content: taskFile(names, config),
2622
+ },
2623
+ ];
2624
+ }
2022
2625
  function notificationFiles(names, config) {
2023
2626
  return [
2024
2627
  {
@@ -2095,6 +2698,19 @@ function drizzleResourceSchemaFilePath(names, config) {
2095
2698
  function drizzleResourceRepositoryFilePath(names, config) {
2096
2699
  return path.join(infrastructureDir(config), names.pluralKebab, `drizzle-${names.singularKebab}-repository.ts`);
2097
2700
  }
2701
+ function resourceSharedErrorsPath(config) {
2702
+ return path.join(config.paths.features, "shared", "errors.ts");
2703
+ }
2704
+ function resourceContractNames(names, mode) {
2705
+ const contractNames = [
2706
+ `list${names.pluralPascal}`,
2707
+ `create${names.singularPascal}`,
2708
+ ];
2709
+ if (mode === "resource") {
2710
+ contractNames.push(`get${names.singularPascal}`, `update${names.singularPascal}`, `delete${names.singularPascal}`);
2711
+ }
2712
+ return contractNames;
2713
+ }
2098
2714
  async function detectResourcePersistence(targetDir, config) {
2099
2715
  return (await fileExists(path.join(targetDir, drizzleRepositoriesPath(config))))
2100
2716
  ? "drizzle"
@@ -2103,6 +2719,19 @@ async function detectResourcePersistence(targetDir, config) {
2103
2719
  function resourceFeatureDir(names, config) {
2104
2720
  return path.join(config.paths.features, names.pluralKebab);
2105
2721
  }
2722
+ function featureUiDir(names, config) {
2723
+ return path.join(resourceFeatureDir(names, config), "components");
2724
+ }
2725
+ function featureUiComponentFilePath(names, config) {
2726
+ return path.join(featureUiDir(names, config), `${names.componentFileName}.tsx`);
2727
+ }
2728
+ function featureUiIndexFilePath(names, config) {
2729
+ return path.join(featureUiDir(names, config), "index.ts");
2730
+ }
2731
+ function clientIndexPath(config) {
2732
+ const appRoot = path.posix.dirname(config.paths.appContext);
2733
+ return path.join(appRoot === "." ? "" : appRoot, "client/index.ts");
2734
+ }
2106
2735
  function usesFeatureOwnedContracts(config) {
2107
2736
  return (directoryPath(config.paths.contracts) ===
2108
2737
  directoryPath(config.paths.features));
@@ -2137,6 +2766,9 @@ function resourceUseCaseDir(names, config) {
2137
2766
  function resourceUseCaseIndexPath(names, config) {
2138
2767
  return path.join(resourceUseCaseDir(names, config), "index.ts");
2139
2768
  }
2769
+ function resourceSchemaFilePath(names, config) {
2770
+ return path.join(resourceFeatureDir(names, config), "schemas.ts");
2771
+ }
2140
2772
  function resourcePortFilePath(names, config) {
2141
2773
  if (usesFeatureOwnedContracts(config)) {
2142
2774
  return path.join(resourceFeatureDir(names, config), "ports.ts");
@@ -2177,6 +2809,9 @@ function eventFilePath(names, config) {
2177
2809
  function jobFilePath(names, config) {
2178
2810
  return path.join(featureArtifactDir(names, "jobs", config), `${names.artifact.kebab}.ts`);
2179
2811
  }
2812
+ function taskFilePath(names, config) {
2813
+ return path.join(featureArtifactDir(names, "tasks", config), `${names.artifact.kebab}.ts`);
2814
+ }
2180
2815
  function factoryFilePath(names, config) {
2181
2816
  return path.join(featureArtifactDir(names, "factories", config), `${names.artifact.kebab}.ts`);
2182
2817
  }
@@ -2226,52 +2861,76 @@ async function updateUseCaseIndex(targetDir, file, options) {
2226
2861
  await writeFile(destination, next);
2227
2862
  return "updated";
2228
2863
  }
2229
- function featureArtifactIndexFile(kind, names, config) {
2230
- const artifactPath = kind === "event"
2231
- ? eventFilePath(names, config)
2232
- : kind === "job"
2233
- ? jobFilePath(names, config)
2234
- : kind === "factory"
2235
- ? factoryFilePath(names, config)
2236
- : kind === "seed"
2237
- ? seedFilePath(names, config)
2238
- : kind === "notification"
2239
- ? notificationFilePath(names, config)
2240
- : kind === "listener"
2241
- ? listenerFilePath(names, config)
2242
- : kind === "schedule"
2243
- ? scheduleFilePath(names, config)
2244
- : uploadFilePath(names, config);
2864
+ async function updateFeatureUiIndex(targetDir, file, options) {
2865
+ const destination = path.join(targetDir, file.path);
2866
+ const existing = await readOptionalFile(destination);
2867
+ if (existing === undefined) {
2868
+ if (!options.dryRun) {
2869
+ await mkdir(path.dirname(destination), { recursive: true });
2870
+ await writeFile(destination, file.content);
2871
+ }
2872
+ return "created";
2873
+ }
2874
+ if (existing.includes(file.content.trim())) {
2875
+ return "skipped";
2876
+ }
2877
+ const next = `${existing.trimEnd()}\n${file.content}`;
2878
+ if (!options.dryRun)
2879
+ await writeFile(destination, next);
2880
+ return "updated";
2881
+ }
2882
+ function featureArtifactIndexFile(kind, names, config) {
2883
+ const artifactPath = kind === "event"
2884
+ ? eventFilePath(names, config)
2885
+ : kind === "job"
2886
+ ? jobFilePath(names, config)
2887
+ : kind === "task"
2888
+ ? taskFilePath(names, config)
2889
+ : kind === "factory"
2890
+ ? factoryFilePath(names, config)
2891
+ : kind === "seed"
2892
+ ? seedFilePath(names, config)
2893
+ : kind === "notification"
2894
+ ? notificationFilePath(names, config)
2895
+ : kind === "listener"
2896
+ ? listenerFilePath(names, config)
2897
+ : kind === "schedule"
2898
+ ? scheduleFilePath(names, config)
2899
+ : uploadFilePath(names, config);
2245
2900
  const registrySuffix = kind === "event"
2246
2901
  ? "Events"
2247
2902
  : kind === "job"
2248
2903
  ? "Jobs"
2249
- : kind === "factory"
2250
- ? "Factories"
2251
- : kind === "seed"
2252
- ? "Seeds"
2253
- : kind === "notification"
2254
- ? "Notifications"
2255
- : kind === "listener"
2256
- ? "Listeners"
2257
- : kind === "schedule"
2258
- ? "Schedules"
2259
- : "Uploads";
2904
+ : kind === "task"
2905
+ ? "Tasks"
2906
+ : kind === "factory"
2907
+ ? "Factories"
2908
+ : kind === "seed"
2909
+ ? "Seeds"
2910
+ : kind === "notification"
2911
+ ? "Notifications"
2912
+ : kind === "listener"
2913
+ ? "Listeners"
2914
+ : kind === "schedule"
2915
+ ? "Schedules"
2916
+ : "Uploads";
2260
2917
  const importName = kind === "event"
2261
2918
  ? names.eventExportName
2262
2919
  : kind === "job"
2263
2920
  ? names.jobExportName
2264
- : kind === "factory"
2265
- ? names.factoryExportName
2266
- : kind === "seed"
2267
- ? names.seedExportName
2268
- : kind === "notification"
2269
- ? names.notificationExportName
2270
- : kind === "listener"
2271
- ? names.listenerExportName
2272
- : kind === "schedule"
2273
- ? names.scheduleExportName
2274
- : names.uploadExportName;
2921
+ : kind === "task"
2922
+ ? names.taskExportName
2923
+ : kind === "factory"
2924
+ ? names.factoryExportName
2925
+ : kind === "seed"
2926
+ ? names.seedExportName
2927
+ : kind === "notification"
2928
+ ? names.notificationExportName
2929
+ : kind === "listener"
2930
+ ? names.listenerExportName
2931
+ : kind === "schedule"
2932
+ ? names.scheduleExportName
2933
+ : names.uploadExportName;
2275
2934
  return {
2276
2935
  path: path.join(path.dirname(artifactPath), "index.ts"),
2277
2936
  kind,
@@ -2365,6 +3024,117 @@ export const ${file.registryName} = [${file.importName}] as const;
2365
3024
  await writeFile(destination, next);
2366
3025
  return "updated";
2367
3026
  }
3027
+ async function updateTaskRegistry(targetDir, names, config, options) {
3028
+ const destination = path.join(targetDir, config.paths.tasks);
3029
+ const existing = await readOptionalFile(destination);
3030
+ const featureTasksPath = path.join(featureArtifactDir(names, "tasks", config), "index.ts");
3031
+ const registryName = `${names.featureSingularCamel}Tasks`;
3032
+ const importLine = `import { ${registryName} } from "${relativeModule(config.paths.tasks, featureTasksPath)}";`;
3033
+ const defineTasksImport = `import { defineTasks } from "@beignet/core/tasks";`;
3034
+ const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
3035
+ const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
3036
+ const serverImport = `import { server } from "${relativeModule(config.paths.tasks, config.paths.server)}";`;
3037
+ const registryEntry = `...${registryName}`;
3038
+ if (existing === undefined) {
3039
+ const content = `${defineTasksImport}
3040
+ ${serviceActorImport}
3041
+ ${appContextImport}
3042
+ ${importLine}
3043
+ ${serverImport}
3044
+
3045
+ export const tasks = defineTasks([
3046
+ \t${registryEntry},
3047
+ ] as const);
3048
+
3049
+ export async function createTaskContext(): Promise<AppContext> {
3050
+ \treturn server.createServiceContext({
3051
+ \t\tactor: createServiceActor("beignet-cli"),
3052
+ \t});
3053
+ }
3054
+
3055
+ export async function stopTaskContext(): Promise<void> {
3056
+ \tawait server.stop();
3057
+ }
3058
+ `;
3059
+ if (!options.dryRun) {
3060
+ await mkdir(path.dirname(destination), { recursive: true });
3061
+ await writeFile(destination, content);
3062
+ }
3063
+ return "created";
3064
+ }
3065
+ let next = existing;
3066
+ if (!next.includes(defineTasksImport)) {
3067
+ next = insertAfterImports(next, defineTasksImport);
3068
+ }
3069
+ if (!next.includes(importLine)) {
3070
+ next = insertAfterImports(next, importLine);
3071
+ }
3072
+ const registry = arrayInitializerInfo(next, "tasks");
3073
+ if (!registry) {
3074
+ next = `${next.trimEnd()}\n\nexport const tasks = defineTasks([\n\t${registryEntry},\n] as const);\n`;
3075
+ }
3076
+ else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
3077
+ const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
3078
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
3079
+ }
3080
+ if (next === existing)
3081
+ return "skipped";
3082
+ if (!options.dryRun)
3083
+ await writeFile(destination, next);
3084
+ return "updated";
3085
+ }
3086
+ async function updateScheduleRegistry(targetDir, names, config, options) {
3087
+ const destination = path.join(targetDir, config.paths.schedules);
3088
+ const existing = await readOptionalFile(destination);
3089
+ const featureSchedulesPath = path.join(featureArtifactDir(names, "schedules", config), "index.ts");
3090
+ const registryName = `${names.featureSingularCamel}Schedules`;
3091
+ const importLine = `import { ${registryName} } from "${aliasModule(featureSchedulesPath)}";`;
3092
+ const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
3093
+ const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
3094
+ const serverImport = `import { server } from "${relativeModule(config.paths.schedules, config.paths.server)}";`;
3095
+ const registryEntry = `...${registryName}`;
3096
+ if (existing === undefined) {
3097
+ const content = `${serviceActorImport}
3098
+ ${appContextImport}
3099
+ ${importLine}
3100
+ ${serverImport}
3101
+
3102
+ export const schedules = [${registryEntry}] as const;
3103
+
3104
+ export async function createScheduleContext(): Promise<AppContext> {
3105
+ \treturn server.createServiceContext({
3106
+ \t\tactor: createServiceActor("beignet-schedule"),
3107
+ \t});
3108
+ }
3109
+
3110
+ export async function stopScheduleContext(): Promise<void> {
3111
+ \tawait server.stop();
3112
+ }
3113
+ `;
3114
+ if (!options.dryRun) {
3115
+ await mkdir(path.dirname(destination), { recursive: true });
3116
+ await writeFile(destination, content);
3117
+ }
3118
+ return "created";
3119
+ }
3120
+ let next = existing;
3121
+ if (!next.includes(importLine)) {
3122
+ next = insertAfterImports(next, importLine);
3123
+ }
3124
+ const registry = arrayInitializerInfo(next, "schedules");
3125
+ if (!registry) {
3126
+ next = `${next.trimEnd()}\n\nexport const schedules = [${registryEntry}] as const;\n`;
3127
+ }
3128
+ else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
3129
+ const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
3130
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
3131
+ }
3132
+ if (next === existing)
3133
+ return "skipped";
3134
+ if (!options.dryRun)
3135
+ await writeFile(destination, next);
3136
+ return "updated";
3137
+ }
2368
3138
  function arrayInitializerInfo(source, constName) {
2369
3139
  const match = new RegExp(`\\bconst\\s+${constName}\\s*=`).exec(source);
2370
3140
  if (!match)
@@ -2397,27 +3167,106 @@ function defineUploadsInitializerInfo(source, constName) {
2397
3167
  end: closeBrace,
2398
3168
  };
2399
3169
  }
2400
- function schemasFile(names) {
3170
+ function schemasFile(names, mode, options) {
2401
3171
  return `import { z } from "zod";
2402
3172
 
2403
3173
  export const ${names.singularPascal}Schema = z.object({
2404
3174
  id: z.string().uuid(),
2405
- name: z.string().min(1),
3175
+ ${options.tenant ? `\ttenantId: z.string().min(1),\n` : ""} name: z.string().min(1),
3176
+ version: z.number().int().min(1),
2406
3177
  createdAt: z.string().datetime(),
3178
+ updatedAt: z.string().datetime(),
2407
3179
  });
2408
3180
 
2409
- export const List${names.pluralPascal}InputSchema = z.object({
2410
- limit: z.coerce.number().int().min(1).max(100).default(20),
2411
- offset: z.coerce.number().int().min(0).default(0),
3181
+ export const ${names.singularPascal}SortBySchema = z.enum(["createdAt", "name"]);
3182
+ export const ${names.singularPascal}SortDirectionSchema = z.enum(["asc", "desc"]);
3183
+
3184
+ const ${names.singularPascal}CursorPayloadSchema = z.object({
3185
+ sortBy: ${names.singularPascal}SortBySchema,
3186
+ sortDirection: ${names.singularPascal}SortDirectionSchema,
3187
+ sortValue: z.string(),
3188
+ id: z.string().uuid(),
2412
3189
  });
2413
3190
 
3191
+ export type ${names.singularPascal}SortBy = z.infer<
3192
+ typeof ${names.singularPascal}SortBySchema
3193
+ >;
3194
+ export type ${names.singularPascal}SortDirection = z.infer<
3195
+ typeof ${names.singularPascal}SortDirectionSchema
3196
+ >;
3197
+ export type ${names.singularPascal}Cursor = z.infer<
3198
+ typeof ${names.singularPascal}CursorPayloadSchema
3199
+ >;
3200
+
3201
+ function encodeBase64Url(value: string): string {
3202
+ const bytes = new TextEncoder().encode(value);
3203
+ let binary = "";
3204
+ for (const byte of bytes) binary += String.fromCharCode(byte);
3205
+
3206
+ return btoa(binary)
3207
+ .replaceAll("+", "-")
3208
+ .replaceAll("/", "_")
3209
+ .replaceAll("=", "");
3210
+ }
3211
+
3212
+ function decodeBase64Url(value: string): string {
3213
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
3214
+ const padding = "=".repeat((4 - (base64.length % 4)) % 4);
3215
+ const binary = atob(base64 + padding);
3216
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
3217
+
3218
+ return new TextDecoder().decode(bytes);
3219
+ }
3220
+
3221
+ export function encode${names.singularPascal}Cursor(cursor: ${names.singularPascal}Cursor): string {
3222
+ return encodeBase64Url(JSON.stringify(cursor));
3223
+ }
3224
+
3225
+ export function decode${names.singularPascal}Cursor(cursor: string): ${names.singularPascal}Cursor {
3226
+ return ${names.singularPascal}CursorPayloadSchema.parse(
3227
+ JSON.parse(decodeBase64Url(cursor)),
3228
+ );
3229
+ }
3230
+
3231
+ export const List${names.pluralPascal}InputSchema = z
3232
+ .object({
3233
+ limit: z.coerce.number().int().min(1).max(100).default(20),
3234
+ cursor: z.string().nullable().default(null),
3235
+ name: z.string().trim().min(1).max(120).optional(),
3236
+ sortBy: ${names.singularPascal}SortBySchema.default("createdAt"),
3237
+ sortDirection: ${names.singularPascal}SortDirectionSchema.default("desc"),
3238
+ })
3239
+ .superRefine((input, ctx) => {
3240
+ if (!input.cursor) return;
3241
+
3242
+ try {
3243
+ const cursor = decode${names.singularPascal}Cursor(input.cursor);
3244
+ if (
3245
+ cursor.sortBy !== input.sortBy ||
3246
+ cursor.sortDirection !== input.sortDirection
3247
+ ) {
3248
+ ctx.addIssue({
3249
+ code: z.ZodIssueCode.custom,
3250
+ message: "cursor does not match the requested sort.",
3251
+ path: ["cursor"],
3252
+ });
3253
+ }
3254
+ } catch {
3255
+ ctx.addIssue({
3256
+ code: z.ZodIssueCode.custom,
3257
+ message: "cursor is invalid.",
3258
+ path: ["cursor"],
3259
+ });
3260
+ }
3261
+ });
3262
+
2414
3263
  export const List${names.pluralPascal}OutputSchema = z.object({
2415
3264
  items: z.array(${names.singularPascal}Schema),
2416
3265
  page: z.object({
2417
- kind: z.literal("offset"),
3266
+ kind: z.literal("cursor"),
2418
3267
  limit: z.number().int().min(1),
2419
- offset: z.number().int().min(0),
2420
- total: z.number().int().min(0),
3268
+ cursor: z.string().nullable(),
3269
+ nextCursor: z.string().nullable(),
2421
3270
  hasMore: z.boolean(),
2422
3271
  }),
2423
3272
  });
@@ -2425,147 +3274,434 @@ export const List${names.pluralPascal}OutputSchema = z.object({
2425
3274
  export const Create${names.singularPascal}InputSchema = z.object({
2426
3275
  name: z.string().min(1).max(120),
2427
3276
  });
3277
+ ${mode === "resource"
3278
+ ? `
3279
+ export const ${names.singularPascal}IdInputSchema = z.object({
3280
+ id: z.string().uuid(),
3281
+ });
2428
3282
 
3283
+ export const Update${names.singularPascal}BodySchema = z.object({
3284
+ name: z.string().min(1).max(120),
3285
+ version: z.number().int().min(1),
3286
+ });
3287
+
3288
+ export const Update${names.singularPascal}InputSchema =
3289
+ ${names.singularPascal}IdInputSchema.merge(Update${names.singularPascal}BodySchema);
3290
+ `
3291
+ : ""}
2429
3292
  export type ${names.singularPascal} = z.infer<typeof ${names.singularPascal}Schema>;
2430
3293
  export type Create${names.singularPascal}Input = z.infer<
2431
3294
  typeof Create${names.singularPascal}InputSchema
2432
3295
  >;
3296
+ ${mode === "resource"
3297
+ ? `
3298
+ export type ${names.singularPascal}IdInput = z.infer<
3299
+ typeof ${names.singularPascal}IdInputSchema
3300
+ >;
3301
+ export type Update${names.singularPascal}Body = z.infer<
3302
+ typeof Update${names.singularPascal}BodySchema
3303
+ >;
3304
+ export type Update${names.singularPascal}Input = z.infer<
3305
+ typeof Update${names.singularPascal}InputSchema
3306
+ >;
3307
+ `
3308
+ : ""}
2433
3309
  export type List${names.pluralPascal}Input = z.infer<
2434
3310
  typeof List${names.pluralPascal}InputSchema
2435
3311
  >;
2436
3312
  `;
2437
3313
  }
2438
- function listUseCaseFile(names, config) {
3314
+ function listUseCaseFile(names, config, options) {
2439
3315
  const filePath = path.join(resourceUseCaseDir(names, config), `list-${names.pluralKebab}.ts`);
2440
- return `import { normalizeOffsetPage } from "@beignet/core/pagination";
3316
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3317
+ import { normalizeCursorPage } from "@beignet/core/pagination";
2441
3318
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2442
3319
  import {
3320
+ decode${names.singularPascal}Cursor,
2443
3321
  List${names.pluralPascal}InputSchema,
2444
3322
  List${names.pluralPascal}OutputSchema,
2445
- } from "./schemas";
3323
+ } from "../schemas";
2446
3324
 
2447
3325
  export const list${names.pluralPascal}UseCase = useCase
2448
3326
  .query("${names.pluralCamel}.list")
2449
3327
  .input(List${names.pluralPascal}InputSchema)
2450
3328
  .output(List${names.pluralPascal}OutputSchema)
2451
3329
  .run(async ({ ctx, input }) => {
2452
- const page = normalizeOffsetPage(input, {
3330
+ ${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, {
2453
3331
  defaultLimit: 20,
2454
3332
  maxLimit: 100,
2455
3333
  });
2456
3334
 
2457
- return ctx.ports.${names.pluralCamel}.list(page);
3335
+ return ctx.ports.${names.pluralCamel}.list({
3336
+ page,
3337
+ cursor: page.cursor ? decode${names.singularPascal}Cursor(page.cursor) : null,
3338
+ name: input.name,
3339
+ sortBy: input.sortBy,
3340
+ sortDirection: input.sortDirection,
3341
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
2458
3342
  });
2459
3343
  `;
2460
3344
  }
2461
- function createUseCaseFile(names, config) {
3345
+ function createUseCaseFile(names, config, options) {
2462
3346
  const filePath = path.join(resourceUseCaseDir(names, config), `create-${names.singularKebab}.ts`);
2463
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3347
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3348
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2464
3349
  import {
2465
3350
  Create${names.singularPascal}InputSchema,
2466
3351
  ${names.singularPascal}Schema,
2467
- } from "./schemas";
3352
+ } from "../schemas";
3353
+ ${options.events ? `import { ${names.singularPascal}Created } from "../domain/events";\n` : ""}
2468
3354
 
2469
3355
  export const create${names.singularPascal}UseCase = useCase
2470
3356
  .command("${names.pluralCamel}.create")
2471
3357
  .input(Create${names.singularPascal}InputSchema)
2472
3358
  .output(${names.singularPascal}Schema)
2473
- .run(async ({ ctx, input }) => ctx.ports.${names.pluralCamel}.create(input));
3359
+ .run(async ({ ctx, input }) => {
3360
+ ${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({
3361
+ ...input,
3362
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
3363
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Created, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
3364
+ return ${names.singularCamel};
3365
+ });
3366
+ `;
3367
+ }
3368
+ function getUseCaseFile(names, config, options) {
3369
+ const filePath = path.join(resourceUseCaseDir(names, config), `get-${names.singularKebab}.ts`);
3370
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3371
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3372
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3373
+ import {
3374
+ ${names.singularPascal}IdInputSchema,
3375
+ ${names.singularPascal}Schema,
3376
+ } from "../schemas";
3377
+
3378
+ export const get${names.singularPascal}UseCase = useCase
3379
+ .query("${names.pluralCamel}.get")
3380
+ .input(${names.singularPascal}IdInputSchema)
3381
+ .output(${names.singularPascal}Schema)
3382
+ .run(async ({ ctx, input }) => {
3383
+ ${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 }" : ""});
3384
+ if (!${names.singularCamel}) {
3385
+ throw appError("${names.singularPascal}NotFound", {
3386
+ details: { id: input.id },
3387
+ });
3388
+ }
3389
+
3390
+ return ${names.singularCamel};
3391
+ });
3392
+ `;
3393
+ }
3394
+ function updateUseCaseFile(names, config, options) {
3395
+ const filePath = path.join(resourceUseCaseDir(names, config), `update-${names.singularKebab}.ts`);
3396
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3397
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3398
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3399
+ ${options.events ? `import { ${names.singularPascal}Updated } from "../domain/events";\n` : ""}import {
3400
+ Update${names.singularPascal}InputSchema,
3401
+ ${names.singularPascal}Schema,
3402
+ } from "../schemas";
3403
+
3404
+ export const update${names.singularPascal}UseCase = useCase
3405
+ .command("${names.pluralCamel}.update")
3406
+ .input(Update${names.singularPascal}InputSchema)
3407
+ .output(${names.singularPascal}Schema)
3408
+ .run(async ({ ctx, input }) => {
3409
+ ${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 }" : ""});
3410
+ if (!existing) {
3411
+ throw appError("${names.singularPascal}NotFound", {
3412
+ details: { id: input.id },
3413
+ });
3414
+ }
3415
+ ${options.auth ? `\t\tawait ctx.gate.authorize("${names.pluralCamel}.update", existing);\n\n` : ""} if (existing.version !== input.version) {
3416
+ throw appError("${names.singularPascal}Conflict", {
3417
+ details: { id: input.id, expectedVersion: existing.version },
3418
+ });
3419
+ }
3420
+
3421
+ const ${names.singularCamel} = await ctx.ports.${names.pluralCamel}.update({
3422
+ ...input,
3423
+ ${options.tenant ? `\t\t\ttenantId,\n` : ""} });
3424
+ if (!${names.singularCamel}) {
3425
+ throw appError("${names.singularPascal}Conflict", {
3426
+ details: { id: input.id, expectedVersion: existing.version },
3427
+ });
3428
+ }
3429
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Updated, {\n\t\t\tid: ${names.singularCamel}.id,\n\t\t});\n` : ""}
3430
+ return ${names.singularCamel};
3431
+ });
2474
3432
  `;
2475
3433
  }
2476
- function useCasesIndexFile(names) {
3434
+ function deleteUseCaseFile(names, config, options) {
3435
+ const filePath = path.join(resourceUseCaseDir(names, config), `delete-${names.singularKebab}.ts`);
3436
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3437
+ import { z } from "zod";
3438
+ import { appError } from "${relativeModule(filePath, resourceSharedErrorsPath(config))}";
3439
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
3440
+ ${options.events ? `import { ${names.singularPascal}Deleted } from "../domain/events";\n` : ""}import { ${names.singularPascal}IdInputSchema } from "../schemas";
3441
+
3442
+ export const delete${names.singularPascal}UseCase = useCase
3443
+ .command("${names.pluralCamel}.delete")
3444
+ .input(${names.singularPascal}IdInputSchema)
3445
+ .output(z.void())
3446
+ .run(async ({ ctx, input }) => {
3447
+ ${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 }" : ""});
3448
+ if (!deleted) {
3449
+ throw appError("${names.singularPascal}NotFound", {
3450
+ details: { id: input.id },
3451
+ });
3452
+ }
3453
+ ${options.events ? `\n\t\tawait ctx.ports.eventBus.publish(${names.singularPascal}Deleted, {\n\t\t\tid: input.id,\n\t\t});\n` : ""} });
3454
+ `;
3455
+ }
3456
+ function useCasesIndexFile(names, mode) {
2477
3457
  return `export { create${names.singularPascal}UseCase } from "./create-${names.singularKebab}";
2478
- export { list${names.pluralPascal}UseCase } from "./list-${names.pluralKebab}";
2479
- export {
3458
+ ${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}";
3459
+ ${mode === "resource" ? `export { update${names.singularPascal}UseCase } from "./update-${names.singularKebab}";\n` : ""}export {
2480
3460
  Create${names.singularPascal}InputSchema,
2481
- List${names.pluralPascal}InputSchema,
3461
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n` : ""} List${names.pluralPascal}InputSchema,
2482
3462
  List${names.pluralPascal}OutputSchema,
2483
- ${names.singularPascal}Schema,
3463
+ ${mode === "resource" ? ` Update${names.singularPascal}BodySchema,\n Update${names.singularPascal}InputSchema,\n` : ""} ${names.singularPascal}Schema,
2484
3464
  type Create${names.singularPascal}Input,
2485
- type List${names.pluralPascal}Input,
3465
+ ${mode === "resource" ? ` type ${names.singularPascal}IdInput,\n type Update${names.singularPascal}Body,\n type Update${names.singularPascal}Input,\n` : ""} type List${names.pluralPascal}Input,
2486
3466
  type ${names.singularPascal},
2487
- } from "./schemas";
3467
+ } from "../schemas";
2488
3468
  `;
2489
3469
  }
2490
- function repositoryPortFile(names, config) {
3470
+ function repositoryPortFile(names, config, mode, options) {
2491
3471
  return `import type {
2492
- OffsetPage,
2493
- OffsetPageInfo,
3472
+ CursorPage,
3473
+ CursorPageInfo,
2494
3474
  PageResult,
3475
+ SortDirection,
2495
3476
  } from "@beignet/core/pagination";
2496
3477
  import type {
2497
3478
  Create${names.singularPascal}Input,
2498
- ${names.singularPascal},
2499
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3479
+ ${names.singularPascal}Cursor,
3480
+ ${names.singularPascal}SortBy,
3481
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3482
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2500
3483
 
2501
- export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, OffsetPageInfo>;
3484
+ export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, CursorPageInfo>;
3485
+ export type List${names.pluralPascal}Query = {
3486
+ page: CursorPage;
3487
+ cursor: ${names.singularPascal}Cursor | null;
3488
+ name?: string;
3489
+ sortBy: ${names.singularPascal}SortBy;
3490
+ sortDirection: SortDirection;
3491
+ ${options.tenant ? `\ttenantId: string;\n` : ""}};
3492
+ ${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` : ""}` : ""}
2502
3493
 
2503
3494
  export interface ${names.singularPascal}Repository {
2504
- list(page: OffsetPage): Promise<List${names.pluralPascal}Result>;
2505
- create(input: Create${names.singularPascal}Input): Promise<${names.singularPascal}>;
2506
- }
3495
+ list(query: List${names.pluralPascal}Query): Promise<List${names.pluralPascal}Result>;
3496
+ create(input: ${options.tenant ? `Create${names.singularPascal}RepositoryInput` : `Create${names.singularPascal}Input`}): Promise<${names.singularPascal}>;
3497
+ ${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` : ""}}
2507
3498
  `;
2508
3499
  }
2509
- function inMemoryRepositoryFile(names, config) {
3500
+ function inMemoryRepositoryFile(names, config, mode, options) {
2510
3501
  const repositoryPortPath = resourcePortFilePath(names, config);
2511
- return `import { offsetPageResult } from "@beignet/core/pagination";
3502
+ const recordType = options.softDelete
3503
+ ? `type ${names.singularPascal}Record = ${names.singularPascal} & {
3504
+ \tdeletedAt: string | null;
3505
+ };
3506
+
3507
+ `
3508
+ : "";
3509
+ const mapValueType = options.softDelete
3510
+ ? `${names.singularPascal}Record`
3511
+ : names.singularPascal;
3512
+ const activeFilter = options.softDelete
3513
+ ? `\t\t\t\t.filter((${names.singularCamel}) => ${names.singularCamel}.deletedAt === null)\n`
3514
+ : "";
3515
+ const newDeletedAtField = options.softDelete
3516
+ ? `\t\t\t\tdeletedAt: null,\n`
3517
+ : "";
3518
+ const findDeletedGuard = options.softDelete
3519
+ ? `\t\t\tif (${names.singularCamel}?.deletedAt !== null) return null;\n`
3520
+ : "";
3521
+ const updateDeletedGuard = options.softDelete
3522
+ ? " || existing.deletedAt !== null"
3523
+ : "";
3524
+ const deleteImplementation = options.softDelete
3525
+ ? `${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();
3526
+ ${names.pluralCamel}.set(id, { ...existing, deletedAt: now, updatedAt: now });
3527
+ return true;`
3528
+ : `${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);`;
3529
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3530
+ import { cursorPageResult } from "@beignet/core/pagination";
2512
3531
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3532
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2513
3533
  import type {
2514
3534
  Create${names.singularPascal}Input,
2515
- ${names.singularPascal},
2516
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3535
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3536
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
3537
+
3538
+ ${recordType}function to${names.singularPascal}(${names.singularCamel}: ${mapValueType}): ${names.singularPascal} {
3539
+ return {
3540
+ id: ${names.singularCamel}.id,
3541
+ ${options.tenant ? `\t\ttenantId: ${names.singularCamel}.tenantId,\n` : ""} name: ${names.singularCamel}.name,
3542
+ version: ${names.singularCamel}.version,
3543
+ createdAt: ${names.singularCamel}.createdAt,
3544
+ updatedAt: ${names.singularCamel}.updatedAt,
3545
+ };
3546
+ }
3547
+
3548
+ function compare${names.pluralPascal}(
3549
+ left: ${mapValueType},
3550
+ right: ${mapValueType},
3551
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3552
+ ): number {
3553
+ const leftValue = left[query.sortBy];
3554
+ const rightValue = right[query.sortBy];
3555
+ const fieldComparison =
3556
+ leftValue === rightValue ? left.id.localeCompare(right.id) : leftValue.localeCompare(rightValue);
3557
+
3558
+ return query.sortDirection === "asc" ? fieldComparison : -fieldComparison;
3559
+ }
3560
+
3561
+ function isAfter${names.singularPascal}Cursor(
3562
+ ${names.singularCamel}: ${mapValueType},
3563
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3564
+ ): boolean {
3565
+ if (!query.cursor) return true;
3566
+
3567
+ const sortValue = ${names.singularCamel}[query.sortBy];
3568
+ const fieldComparison =
3569
+ sortValue === query.cursor.sortValue
3570
+ ? ${names.singularCamel}.id.localeCompare(query.cursor.id)
3571
+ : sortValue.localeCompare(query.cursor.sortValue);
3572
+
3573
+ return query.sortDirection === "asc" ? fieldComparison > 0 : fieldComparison < 0;
3574
+ }
3575
+
3576
+ function cursorFor${names.singularPascal}(
3577
+ ${names.singularCamel}: ${mapValueType},
3578
+ query: Parameters<${names.singularPascal}Repository["list"]>[0],
3579
+ ): string {
3580
+ return encode${names.singularPascal}Cursor({
3581
+ sortBy: query.sortBy,
3582
+ sortDirection: query.sortDirection,
3583
+ sortValue: ${names.singularCamel}[query.sortBy],
3584
+ id: ${names.singularCamel}.id,
3585
+ });
3586
+ }
2517
3587
 
2518
3588
  export function createInMemory${names.singularPascal}Repository(
2519
3589
  seed: ${names.singularPascal}[] = [],
2520
3590
  ): ${names.singularPascal}Repository {
2521
- const ${names.pluralCamel} = new Map(seed.map((${names.singularCamel}) => [${names.singularCamel}.id, ${names.singularCamel}]));
3591
+ const ${names.pluralCamel} = new Map<string, ${mapValueType}>(
3592
+ seed.map((${names.singularCamel}) => [
3593
+ ${names.singularCamel}.id,
3594
+ { ...${names.singularCamel}${options.softDelete ? ", deletedAt: null" : ""} },
3595
+ ]),
3596
+ );
2522
3597
 
2523
3598
  return {
2524
- async list(page) {
2525
- const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values()).sort(
2526
- (left, right) => right.createdAt.localeCompare(left.createdAt),
2527
- );
3599
+ async list(query) {
3600
+ const name = query.name?.toLocaleLowerCase();
3601
+ const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values())
3602
+ ${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))
3603
+ .sort((left, right) => compare${names.pluralPascal}(left, right, query))
3604
+ .filter((${names.singularCamel}) => isAfter${names.singularPascal}Cursor(${names.singularCamel}, query));
3605
+ const pageItems = all${names.pluralPascal}.slice(0, query.page.limit);
3606
+ const nextCursor =
3607
+ all${names.pluralPascal}.length > query.page.limit && pageItems.length > 0
3608
+ ? cursorFor${names.singularPascal}(pageItems[pageItems.length - 1]!, query)
3609
+ : null;
2528
3610
 
2529
- return offsetPageResult(
2530
- all${names.pluralPascal}.slice(page.offset, page.offset + page.limit),
2531
- page,
2532
- all${names.pluralPascal}.length,
3611
+ return cursorPageResult(
3612
+ pageItems.map(to${names.singularPascal}),
3613
+ query.page,
3614
+ nextCursor,
2533
3615
  );
2534
3616
  },
2535
- async create(input: Create${names.singularPascal}Input) {
3617
+ async create(input) {
3618
+ const now = new Date().toISOString();
2536
3619
  const ${names.singularCamel}: ${names.singularPascal} = {
2537
3620
  id: crypto.randomUUID(),
2538
- name: input.name,
2539
- createdAt: new Date().toISOString(),
3621
+ ${options.tenant ? `\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
3622
+ version: 1,
3623
+ createdAt: now,
3624
+ updatedAt: now,
2540
3625
  };
2541
- ${names.pluralCamel}.set(${names.singularCamel}.id, ${names.singularCamel});
3626
+ ${names.pluralCamel}.set(${names.singularCamel}.id, {
3627
+ ...${names.singularCamel},
3628
+ ${newDeletedAtField} });
2542
3629
  return ${names.singularCamel};
2543
3630
  },
3631
+ ${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` : ""}
2544
3632
  };
2545
3633
  }
2546
3634
  `;
2547
3635
  }
2548
- function drizzleSchemaFile(names) {
2549
- return `import { sqliteTable, text } from "drizzle-orm/sqlite-core";
3636
+ function drizzleSchemaFile(names, options) {
3637
+ return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2550
3638
 
2551
3639
  export const ${names.pluralCamel} = sqliteTable("${names.pluralKebab.replaceAll("-", "_")}", {
2552
3640
  id: text("id").primaryKey(),
2553
- name: text("name").notNull(),
3641
+ ${options.tenant ? `\ttenantId: text("tenant_id").notNull(),\n` : ""} name: text("name").notNull(),
3642
+ version: integer("version").notNull(),
2554
3643
  createdAt: text("created_at").notNull(),
2555
- });
3644
+ updatedAt: text("updated_at").notNull(),
3645
+ ${options.softDelete ? `\tdeletedAt: text("deleted_at"),\n` : ""}});
2556
3646
  `;
2557
3647
  }
2558
- function drizzleRepositoryFile(names, config) {
3648
+ function drizzleResourceWhere(names, options, predicates) {
3649
+ const allPredicates = [
3650
+ ...predicates,
3651
+ ...(options.softDelete
3652
+ ? [`isNull(schema.${names.pluralCamel}.deletedAt)`]
3653
+ : []),
3654
+ ];
3655
+ if (allPredicates.length === 0)
3656
+ return "";
3657
+ if (allPredicates.length === 1)
3658
+ return allPredicates[0] ?? "";
3659
+ return `and(${allPredicates.join(", ")})`;
3660
+ }
3661
+ function drizzleRepositoryFile(names, config, mode, options) {
2559
3662
  const repositoryPortPath = resourcePortFilePath(names, config);
2560
3663
  const schemaPath = drizzleSchemaIndexPath(config);
2561
- return `import { offsetPageResult } from "@beignet/core/pagination";
3664
+ const findWhere = drizzleResourceWhere(names, options, [
3665
+ `eq(schema.${names.pluralCamel}.id, id)`,
3666
+ ...(options.tenant
3667
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
3668
+ : []),
3669
+ ]);
3670
+ const updateWhere = drizzleResourceWhere(names, options, [
3671
+ `eq(schema.${names.pluralCamel}.id, input.id)`,
3672
+ `eq(schema.${names.pluralCamel}.version, input.version)`,
3673
+ ...(options.tenant
3674
+ ? [`eq(schema.${names.pluralCamel}.tenantId, input.tenantId)`]
3675
+ : []),
3676
+ ]);
3677
+ const deleteWhere = drizzleResourceWhere(names, options, [
3678
+ `eq(schema.${names.pluralCamel}.id, id)`,
3679
+ ...(options.tenant
3680
+ ? [`eq(schema.${names.pluralCamel}.tenantId, filter.tenantId)`]
3681
+ : []),
3682
+ ]);
3683
+ const drizzleImports = [
3684
+ "and",
3685
+ "asc",
3686
+ "desc",
3687
+ "eq",
3688
+ "gt",
3689
+ options.softDelete ? "isNull" : undefined,
3690
+ "lt",
3691
+ "or",
3692
+ "sql",
3693
+ "type SQL",
3694
+ ].filter((name) => Boolean(name));
3695
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3696
+ import { cursorPageResult } from "@beignet/core/pagination";
2562
3697
  import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
2563
- import { count, desc } from "drizzle-orm";
3698
+ import { ${drizzleImports.join(", ")} } from "drizzle-orm";
2564
3699
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
3700
+ import { encode${names.singularPascal}Cursor } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2565
3701
  import type {
2566
3702
  Create${names.singularPascal}Input,
2567
- ${names.singularPascal},
2568
- } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
3703
+ ${mode === "resource" ? ` Update${names.singularPascal}Input,\n` : ""} ${names.singularPascal},
3704
+ } from "${aliasModule(resourceSchemaFilePath(names, config))}";
2569
3705
  import * as schema from "${aliasModule(schemaPath)}";
2570
3706
 
2571
3707
  type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelect;
@@ -2573,35 +3709,104 @@ type ${names.singularPascal}Row = typeof schema.${names.pluralCamel}.$inferSelec
2573
3709
  function to${names.singularPascal}(row: ${names.singularPascal}Row): ${names.singularPascal} {
2574
3710
  return {
2575
3711
  id: row.id,
2576
- name: row.name,
3712
+ ${options.tenant ? `\t\ttenantId: row.tenantId,\n` : ""} name: row.name,
3713
+ version: row.version,
2577
3714
  createdAt: row.createdAt,
3715
+ updatedAt: row.updatedAt,
2578
3716
  };
2579
3717
  }
2580
3718
 
3719
+ type List${names.pluralPascal}Query = Parameters<${names.singularPascal}Repository["list"]>[0];
3720
+
3721
+ function ${names.singularCamel}SortColumn(query: List${names.pluralPascal}Query) {
3722
+ return query.sortBy === "name" ? schema.${names.pluralCamel}.name : schema.${names.pluralCamel}.createdAt;
3723
+ }
3724
+
3725
+ function ${names.singularCamel}CursorFilter(
3726
+ query: List${names.pluralPascal}Query,
3727
+ ): SQL<unknown> | undefined {
3728
+ if (!query.cursor) return undefined;
3729
+
3730
+ const column = ${names.singularCamel}SortColumn(query);
3731
+ const compare = query.sortDirection === "asc" ? gt : lt;
3732
+
3733
+ return or(
3734
+ compare(column, query.cursor.sortValue),
3735
+ and(
3736
+ eq(column, query.cursor.sortValue),
3737
+ compare(schema.${names.pluralCamel}.id, query.cursor.id),
3738
+ ),
3739
+ ) as SQL<unknown>;
3740
+ }
3741
+
3742
+ function ${names.singularCamel}ListWhere(
3743
+ query: List${names.pluralPascal}Query,
3744
+ ): SQL<unknown> | undefined {
3745
+ const filters: SQL<unknown>[] = [
3746
+ ${options.tenant ? `\t\teq(schema.${names.pluralCamel}.tenantId, query.tenantId),\n` : ""}${options.softDelete ? `\t\tisNull(schema.${names.pluralCamel}.deletedAt),\n` : ""} ];
3747
+ const cursor = ${names.singularCamel}CursorFilter(query);
3748
+
3749
+ if (query.name) {
3750
+ const pattern = \`%\${query.name.toLocaleLowerCase()}%\`;
3751
+ filters.push(sql\`lower(\${schema.${names.pluralCamel}.name}) like \${pattern}\`);
3752
+ }
3753
+ if (cursor) filters.push(cursor);
3754
+
3755
+ return filters.length > 0 ? and(...filters) : undefined;
3756
+ }
3757
+
3758
+ function ${names.singularCamel}OrderBy(query: List${names.pluralPascal}Query) {
3759
+ const order = query.sortDirection === "asc" ? asc : desc;
3760
+
3761
+ return [order(${names.singularCamel}SortColumn(query)), order(schema.${names.pluralCamel}.id)] as const;
3762
+ }
3763
+
3764
+ function cursorFor${names.singularPascal}(
3765
+ row: ${names.singularPascal}Row,
3766
+ query: List${names.pluralPascal}Query,
3767
+ ): string {
3768
+ return encode${names.singularPascal}Cursor({
3769
+ sortBy: query.sortBy,
3770
+ sortDirection: query.sortDirection,
3771
+ sortValue: row[query.sortBy],
3772
+ id: row.id,
3773
+ });
3774
+ }
3775
+
2581
3776
  export function createDrizzle${names.singularPascal}Repository(
2582
3777
  db: DrizzleTursoDatabase<typeof schema>,
2583
3778
  ): ${names.singularPascal}Repository {
2584
3779
  return {
2585
- async list(page) {
3780
+ async list(query) {
3781
+ const where = ${names.singularCamel}ListWhere(query);
2586
3782
  const rows = await db
2587
3783
  .select()
2588
3784
  .from(schema.${names.pluralCamel})
2589
- .orderBy(desc(schema.${names.pluralCamel}.createdAt))
2590
- .limit(page.limit)
2591
- .offset(page.offset);
2592
- const [{ total }] = await db
2593
- .select({ total: count() })
2594
- .from(schema.${names.pluralCamel});
2595
-
2596
- return offsetPageResult(rows.map(to${names.singularPascal}), page, total);
3785
+ .where(where)
3786
+ .orderBy(...${names.singularCamel}OrderBy(query))
3787
+ .limit(query.page.limit + 1);
3788
+ const pageRows = rows.slice(0, query.page.limit);
3789
+ const nextCursor =
3790
+ rows.length > query.page.limit && pageRows.length > 0
3791
+ ? cursorFor${names.singularPascal}(pageRows[pageRows.length - 1]!, query)
3792
+ : null;
3793
+
3794
+ return cursorPageResult(
3795
+ pageRows.map(to${names.singularPascal}),
3796
+ query.page,
3797
+ nextCursor,
3798
+ );
2597
3799
  },
2598
- async create(input: Create${names.singularPascal}Input) {
3800
+ async create(input) {
3801
+ const now = new Date().toISOString();
2599
3802
  const [row] = await db
2600
3803
  .insert(schema.${names.pluralCamel})
2601
3804
  .values({
2602
3805
  id: crypto.randomUUID(),
2603
- name: input.name,
2604
- createdAt: new Date().toISOString(),
3806
+ ${options.tenant ? `\t\t\t\t\ttenantId: input.tenantId,\n` : ""} name: input.name,
3807
+ version: 1,
3808
+ createdAt: now,
3809
+ updatedAt: now,
2605
3810
  })
2606
3811
  .returning();
2607
3812
 
@@ -2611,17 +3816,20 @@ export function createDrizzle${names.singularPascal}Repository(
2611
3816
 
2612
3817
  return to${names.singularPascal}(row);
2613
3818
  },
3819
+ ${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` : ""}
2614
3820
  };
2615
3821
  }
2616
3822
  `;
2617
3823
  }
2618
- function contractFile(names, config) {
2619
- return `import { createContractGroup } from "@beignet/core/contracts";
3824
+ function contractFile(names, config, mode, options) {
3825
+ return `import { defineContractGroup } from "@beignet/core/contracts";
2620
3826
  import { z } from "zod";
2621
- import {
2622
- create${names.singularPascal}UseCase,
2623
- list${names.pluralPascal}UseCase,
2624
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
3827
+ ${mode === "resource" ? `import { errors } from "${aliasModule(resourceSharedErrorsPath(config))}";\n` : ""}import {
3828
+ Create${names.singularPascal}InputSchema,
3829
+ List${names.pluralPascal}InputSchema,
3830
+ List${names.pluralPascal}OutputSchema,
3831
+ ${names.singularPascal}Schema,
3832
+ ${mode === "resource" ? ` ${names.singularPascal}IdInputSchema,\n Update${names.singularPascal}BodySchema,\n` : ""}} from "${aliasModule(resourceSchemaFilePath(names, config))}";
2625
3833
 
2626
3834
  const ErrorResponseSchema = z.object({
2627
3835
  code: z.string(),
@@ -2629,7 +3837,7 @@ const ErrorResponseSchema = z.object({
2629
3837
  requestId: z.string().optional(),
2630
3838
  });
2631
3839
 
2632
- const ${names.pluralCamel} = createContractGroup()
3840
+ const ${names.pluralCamel} = defineContractGroup()
2633
3841
  .namespace("${names.pluralCamel}")
2634
3842
  .responses({
2635
3843
  500: ErrorResponseSchema,
@@ -2637,21 +3845,49 @@ const ${names.pluralCamel} = createContractGroup()
2637
3845
 
2638
3846
  export const list${names.pluralPascal} = ${names.pluralCamel}
2639
3847
  .get("/api/${names.pluralKebab}")
2640
- .query(list${names.pluralPascal}UseCase.inputSchema)
3848
+ .query(List${names.pluralPascal}InputSchema)
2641
3849
  .responses({
2642
- 200: list${names.pluralPascal}UseCase.outputSchema,
3850
+ 200: List${names.pluralPascal}OutputSchema,
2643
3851
  });
2644
3852
 
2645
3853
  export const create${names.singularPascal} = ${names.pluralCamel}
2646
3854
  .post("/api/${names.pluralKebab}")
2647
- .body(create${names.singularPascal}UseCase.inputSchema)
3855
+ .body(Create${names.singularPascal}InputSchema)
3856
+ ${options.auth ? ` .meta({\n auth: "required",\n authorization: { ability: "${names.pluralCamel}.create" },\n })\n .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden })\n` : ""} .responses({
3857
+ 201: ${names.singularPascal}Schema,
3858
+ });
3859
+ ${mode === "resource"
3860
+ ? `
3861
+ export const get${names.singularPascal} = ${names.pluralCamel}
3862
+ .get("/api/${names.pluralKebab}/:id")
3863
+ .pathParams(${names.singularPascal}IdInputSchema)
3864
+ .errors({ ${names.singularPascal}NotFound: errors.${names.singularPascal}NotFound })
3865
+ .responses({
3866
+ 200: ${names.singularPascal}Schema,
3867
+ });
3868
+
3869
+ export const update${names.singularPascal} = ${names.pluralCamel}
3870
+ .patch("/api/${names.pluralKebab}/:id")
3871
+ .pathParams(${names.singularPascal}IdInputSchema)
3872
+ .body(Update${names.singularPascal}BodySchema)
3873
+ ${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 })
3874
+ .responses({
3875
+ 200: ${names.singularPascal}Schema,
3876
+ });
3877
+
3878
+ export const delete${names.singularPascal} = ${names.pluralCamel}
3879
+ .delete("/api/${names.pluralKebab}/:id")
3880
+ .pathParams(${names.singularPascal}IdInputSchema)
3881
+ ${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 })
2648
3882
  .responses({
2649
- 201: create${names.singularPascal}UseCase.outputSchema,
3883
+ 204: null,
2650
3884
  });
3885
+ `
3886
+ : ""}
2651
3887
  `;
2652
3888
  }
2653
3889
  function standaloneContractFile(names) {
2654
- return `import { createContractGroup } from "@beignet/core/contracts";
3890
+ return `import { defineContractGroup } from "@beignet/core/contracts";
2655
3891
  import { z } from "zod";
2656
3892
 
2657
3893
  const ErrorResponseSchema = z.object({
@@ -2660,7 +3896,7 @@ const ErrorResponseSchema = z.object({
2660
3896
  requestId: z.string().optional(),
2661
3897
  });
2662
3898
 
2663
- const ${names.pluralCamel} = createContractGroup()
3899
+ const ${names.pluralCamel} = defineContractGroup()
2664
3900
  .namespace("${names.pluralCamel}")
2665
3901
  .responses({
2666
3902
  500: ErrorResponseSchema,
@@ -2696,7 +3932,8 @@ export const ${names.pluralCamel}Contracts = [list${names.pluralPascal}];
2696
3932
  `;
2697
3933
  }
2698
3934
  function standaloneUseCaseFile(names, config, filePath) {
2699
- return `import { z } from "zod";
3935
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
3936
+ import { z } from "zod";
2700
3937
  import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2701
3938
 
2702
3939
  export const ${names.action.pascal}InputSchema = z.object({});
@@ -2724,29 +3961,27 @@ function useCaseTestFile(names, config) {
2724
3961
  return `import { describe, expect, it } from "bun:test";
2725
3962
  import { createUseCaseTester } from "@beignet/core/application";
2726
3963
  import { createInMemoryDevtools } from "@beignet/devtools";
2727
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
3964
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
3965
+ import { createTestAnonymousActor } from "@beignet/core/ports/testing";
2728
3966
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2729
3967
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
2730
3968
  import { ${names.exportName} } from "${relativeModule(filePath, useCaseFilePath(names, config))}";
2731
3969
 
2732
3970
  describe("${names.exportName}", () => {
2733
3971
  it("runs ${names.action.camel}", async () => {
2734
- const testPorts = {
2735
- ...appPorts,
2736
- uow: createNoopUnitOfWork(
2737
- () => appPorts as unknown as AppContext["ports"],
2738
- ) as unknown as AppContext["ports"]["uow"],
2739
- devtools: createInMemoryDevtools(),
2740
- storage: createMemoryStorage(),
2741
- } as AppContext["ports"];
2742
- const actor = createAnonymousActor();
2743
- const tester = createUseCaseTester<AppContext>(() => ({
2744
- requestId: "test-request",
2745
- actor,
2746
- auth: null,
2747
- gate: testPorts.gate.bind({ actor, auth: null }),
2748
- ports: testPorts,
2749
- }));
3972
+ const testFixture = createTestPorts<AppContext["ports"]>({
3973
+ base: appPorts,
3974
+ overrides: {
3975
+ gate: appPorts.gate,
3976
+ devtools: createInMemoryDevtools(),
3977
+ },
3978
+ });
3979
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
3980
+ ports: testFixture.ports,
3981
+ actor: createTestAnonymousActor(),
3982
+ tenant: null,
3983
+ });
3984
+ const tester = createUseCaseTester<AppContext>(createTestContext);
2750
3985
 
2751
3986
  const result = await tester.run(${names.exportName}, {});
2752
3987
 
@@ -2807,6 +4042,134 @@ export const ${names.policyName} = definePolicy({
2807
4042
  });
2808
4043
  `;
2809
4044
  }
4045
+ function resourcePolicyFile(names, _config, options) {
4046
+ return `import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
4047
+ import { definePolicy, deny } from "@beignet/core/ports";
4048
+
4049
+ export type AuthorizationContext = {
4050
+ actor: ActivityActor;
4051
+ tenant?: ActivityTenant;
4052
+ };
4053
+
4054
+ type ${names.singularPascal}PolicyResource = {
4055
+ id: string;
4056
+ tenantId?: string;
4057
+ };
4058
+
4059
+ function isAuthenticated(ctx: AuthorizationContext) {
4060
+ return ctx.actor.type === "user" && Boolean(ctx.actor.id);
4061
+ }
4062
+
4063
+ function canWrite(ctx: AuthorizationContext) {
4064
+ if (!isAuthenticated(ctx)) return deny("You must be signed in.");
4065
+ ${options.tenant ? `\tif (!ctx.tenant?.id) return deny("A tenant is required.");\n` : ""} return true;
4066
+ }
4067
+
4068
+ export const ${names.singularCamel}Policy = definePolicy({
4069
+ "${names.pluralCamel}.view": (_ctx: AuthorizationContext) => true,
4070
+ "${names.pluralCamel}.create": (ctx: AuthorizationContext) =>
4071
+ canWrite(ctx),
4072
+ "${names.pluralCamel}.update": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
4073
+ const decision = canWrite(ctx);
4074
+ if (decision !== true) return decision;
4075
+ ${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` : ""}
4076
+ return true;
4077
+ },
4078
+ "${names.pluralCamel}.delete": (ctx: AuthorizationContext, ${names.singularCamel}: ${names.singularPascal}PolicyResource) => {
4079
+ const decision = canWrite(ctx);
4080
+ if (decision !== true) return decision;
4081
+ ${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` : ""}
4082
+ return true;
4083
+ },
4084
+ "${names.pluralCamel}.manage": (_ctx: AuthorizationContext) =>
4085
+ deny("You are not allowed to manage this ${names.singularKebab}."),
4086
+ });
4087
+ `;
4088
+ }
4089
+ function resourceEventsFile(names) {
4090
+ return `import { defineEvent } from "@beignet/core/events";
4091
+ import { z } from "zod";
4092
+
4093
+ export const ${names.singularPascal}Created = defineEvent("${names.pluralCamel}.created", {
4094
+ payload: z.object({
4095
+ id: z.string().uuid(),
4096
+ }),
4097
+ });
4098
+
4099
+ export const ${names.singularPascal}Updated = defineEvent("${names.pluralCamel}.updated", {
4100
+ payload: z.object({
4101
+ id: z.string().uuid(),
4102
+ }),
4103
+ });
4104
+
4105
+ export const ${names.singularPascal}Deleted = defineEvent("${names.pluralCamel}.deleted", {
4106
+ payload: z.object({
4107
+ id: z.string().uuid(),
4108
+ }),
4109
+ });
4110
+
4111
+ export const ${names.singularCamel}Events = [
4112
+ ${names.singularPascal}Created,
4113
+ ${names.singularPascal}Updated,
4114
+ ${names.singularPascal}Deleted,
4115
+ ] as const;
4116
+ `;
4117
+ }
4118
+ function resourcePolicyTestFile(names, options) {
4119
+ return `import { describe, expect, it } from "bun:test";
4120
+ import { createPolicyTester } from "@beignet/core/ports/testing";
4121
+ import { ${names.singularCamel}Policy } from "../policy";
4122
+ import type { ${names.singularPascal} } from "../schemas";
4123
+
4124
+ function create${names.singularPascal}(): ${names.singularPascal} {
4125
+ const now = new Date().toISOString();
4126
+ return {
4127
+ id: "00000000-0000-4000-8000-000000000001",
4128
+ ${options.tenant ? `\t\ttenantId: "tenant_test",\n` : ""} name: "Test ${names.singularPascal}",
4129
+ version: 1,
4130
+ createdAt: now,
4131
+ updatedAt: now,
4132
+ };
4133
+ }
4134
+
4135
+ describe("${names.singularCamel}Policy", () => {
4136
+ it("documents generated ${names.pluralKebab} authorization rules", async () => {
4137
+ const tester = createPolicyTester({
4138
+ policies: [${names.singularCamel}Policy],
4139
+ });
4140
+ const ${names.singularCamel} = create${names.singularPascal}();
4141
+ const ctx = {
4142
+ actor: { type: "user" as const, id: "user_test" },
4143
+ ${options.tenant ? `\t\t\ttenant: { id: "tenant_test" },\n` : ""} };
4144
+
4145
+ await expect(
4146
+ tester.assertMatrix([
4147
+ {
4148
+ name: "authenticated actor can create ${names.singularKebab}",
4149
+ ctx,
4150
+ ability: "${names.pluralCamel}.create",
4151
+ expected: "allow",
4152
+ },
4153
+ {
4154
+ name: "authenticated actor can update ${names.singularKebab}",
4155
+ ctx,
4156
+ ability: "${names.pluralCamel}.update",
4157
+ subject: ${names.singularCamel},
4158
+ expected: "allow",
4159
+ },
4160
+ {
4161
+ name: "authenticated actor can delete ${names.singularKebab}",
4162
+ ctx,
4163
+ ability: "${names.pluralCamel}.delete",
4164
+ subject: ${names.singularCamel},
4165
+ expected: "allow",
4166
+ },
4167
+ ]),
4168
+ ).resolves.toBeUndefined();
4169
+ });
4170
+ });
4171
+ `;
4172
+ }
2810
4173
  function eventFile(names) {
2811
4174
  return `import { defineEvent } from "@beignet/core/events";
2812
4175
  import { z } from "zod";
@@ -2823,11 +4186,9 @@ export const ${names.eventExportName} = defineEvent("${names.eventName}", {
2823
4186
  `;
2824
4187
  }
2825
4188
  function jobFile(names, config) {
2826
- return `import { createJobHandlers, retry } from "@beignet/core/jobs";
4189
+ return `import { retry } from "@beignet/core/jobs";
2827
4190
  import { z } from "zod";
2828
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2829
-
2830
- const jobs = createJobHandlers<AppContext>();
4191
+ import { defineJob } from "${aliasModule(config.paths.jobsBuilder)}";
2831
4192
 
2832
4193
  export const ${names.payloadSchemaName} = z.object({
2833
4194
  \tid: z.string().uuid(),
@@ -2835,7 +4196,7 @@ export const ${names.payloadSchemaName} = z.object({
2835
4196
 
2836
4197
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2837
4198
 
2838
- export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
4199
+ export const ${names.jobExportName} = defineJob("${names.jobName}", {
2839
4200
  \tpayload: ${names.payloadSchemaName},
2840
4201
  \tretry: retry.exponential({
2841
4202
  \t\tattempts: 3,
@@ -2851,12 +4212,38 @@ export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
2851
4212
  });
2852
4213
  `;
2853
4214
  }
4215
+ function taskFile(names, config) {
4216
+ return `import { z } from "zod";
4217
+ import { defineTask } from "${aliasModule(config.paths.tasksBuilder)}";
4218
+
4219
+ export const ${names.inputSchemaName} = z.object({
4220
+ \tdryRun: z.boolean().default(true),
4221
+ });
4222
+
4223
+ export type ${names.inputTypeName} = z.infer<typeof ${names.inputSchemaName}>;
4224
+
4225
+ export const ${names.taskExportName} = defineTask("${names.taskName}", {
4226
+ \tinput: ${names.inputSchemaName},
4227
+ \tdescription: "Operational ${names.feature.kebab} ${names.artifact.kebab} task.",
4228
+ \tasync handle({ input, ctx }) {
4229
+ \t\tctx.ports.logger.info("Task handled", {
4230
+ \t\t\ttaskName: "${names.taskName}",
4231
+ \t\t\tdryRun: input.dryRun,
4232
+ \t\t});
4233
+
4234
+ \t\treturn {
4235
+ \t\t\tdryRun: input.dryRun,
4236
+ \t\t};
4237
+ \t},
4238
+ });
4239
+ `;
4240
+ }
2854
4241
  function factoryFile(names, config) {
2855
4242
  const resource = featureResourceNames(names);
2856
- return `import { defineFactory } from "@beignet/core/testing";
4243
+ return `import { createFactory } from "@beignet/core/testing";
2857
4244
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2858
4245
 
2859
- export const ${names.factoryExportName} = defineFactory("${names.factoryName}", {
4246
+ export const ${names.factoryExportName} = createFactory("${names.factoryName}", {
2860
4247
  \tdefaults: ({ sequence }) => ({
2861
4248
  \t\tname: "${resource.singularPascal} " + sequence,
2862
4249
  \t}),
@@ -2880,14 +4267,9 @@ export const ${names.seedExportName} = defineSeed("${names.seedName}", {
2880
4267
  `;
2881
4268
  }
2882
4269
  function notificationFile(names, config) {
2883
- return `import {
2884
- \tcreateNotificationHandlers,
2885
- \tdefineMailNotificationChannel,
2886
- } from "@beignet/core/notifications";
4270
+ return `import { defineMailNotificationChannel } from "@beignet/core/notifications";
2887
4271
  import { z } from "zod";
2888
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2889
-
2890
- const notifications = createNotificationHandlers<AppContext>();
4272
+ import { defineNotification } from "${aliasModule(config.paths.notificationsBuilder)}";
2891
4273
 
2892
4274
  export const ${names.payloadSchemaName} = z.object({
2893
4275
  \tid: z.string().uuid(),
@@ -2897,7 +4279,7 @@ export const ${names.payloadSchemaName} = z.object({
2897
4279
 
2898
4280
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2899
4281
 
2900
- export const ${names.notificationExportName} = notifications.defineNotification(
4282
+ export const ${names.notificationExportName} = defineNotification(
2901
4283
  \t"${names.notificationName}",
2902
4284
  \t{
2903
4285
  \t\tpayload: ${names.payloadSchemaName},
@@ -2914,13 +4296,10 @@ export const ${names.notificationExportName} = notifications.defineNotification(
2914
4296
  }
2915
4297
  function listenerFile(names, config) {
2916
4298
  const filePath = listenerFilePath(names, config);
2917
- return `import { createEventHandlers } from "@beignet/core/events";
2918
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
4299
+ return `import { defineListener } from "${aliasModule(config.paths.listenersBuilder)}";
2919
4300
  import { ${names.event.eventExportName} } from "${relativeModule(filePath, eventFilePath(names.event, config))}";
2920
4301
 
2921
- const events = createEventHandlers<AppContext>();
2922
-
2923
- export const ${names.listenerExportName} = events.defineListener(${names.event.eventExportName}, {
4302
+ export const ${names.listenerExportName} = defineListener(${names.event.eventExportName}, {
2924
4303
  \tname: "${names.listenerName}",
2925
4304
  \tasync handle({ payload, ctx }) {
2926
4305
  \t\tctx.ports.logger.info("Listener handled", {
@@ -2932,11 +4311,8 @@ export const ${names.listenerExportName} = events.defineListener(${names.event.e
2932
4311
  `;
2933
4312
  }
2934
4313
  function scheduleFile(names, config) {
2935
- return `import { createScheduleHandlers } from "@beignet/core/schedules";
2936
- import { z } from "zod";
2937
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2938
-
2939
- const schedules = createScheduleHandlers<AppContext>();
4314
+ return `import { z } from "zod";
4315
+ import { defineSchedule } from "${aliasModule(config.paths.schedulesBuilder)}";
2940
4316
 
2941
4317
  export const ${names.payloadSchemaName} = z.object({
2942
4318
  \tdate: z.string(),
@@ -2944,7 +4320,7 @@ export const ${names.payloadSchemaName} = z.object({
2944
4320
 
2945
4321
  export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2946
4322
 
2947
- export const ${names.scheduleExportName} = schedules.defineSchedule(
4323
+ export const ${names.scheduleExportName} = defineSchedule(
2948
4324
  \t"${names.scheduleName}",
2949
4325
  \t{
2950
4326
  \t\tcron: "${names.cron}",
@@ -3020,108 +4396,110 @@ export const ${names.uploadExportName} = defineUpload<
3020
4396
  });
3021
4397
  `;
3022
4398
  }
3023
- function scheduleRouteFile(names, config) {
3024
- return `import { createInlineScheduleRunner } from "@beignet/core/schedules";
3025
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3026
- import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
3027
- import { env } from "@/lib/env";
3028
- import { server } from "${aliasModule(config.paths.server)}";
4399
+ function featureUiComponentFile(names, config) {
4400
+ return `"use client";
3029
4401
 
3030
- async function run${names.artifact.pascal}(request: Request) {
3031
- \tconst cronSecret = env.CRON_SECRET;
4402
+ import { contractErrorMessage } from "@beignet/core/client";
4403
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4404
+ import { useState } from "react";
4405
+ import { rq } from "${aliasModule(clientIndexPath(config))}";
4406
+ import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
3032
4407
 
3033
- \tif (!cronSecret) {
3034
- \t\treturn Response.json(
3035
- \t\t\t{
3036
- \t\t\t\tok: false,
3037
- \t\t\t\terror: "CRON_SECRET is not configured.",
3038
- \t\t\t\tscheduleName: ${names.scheduleExportName}.name,
4408
+ export function ${names.componentExportName}() {
4409
+ \tconst [name, setName] = useState("");
4410
+ \tconst queryClient = useQueryClient();
4411
+ \tconst ${names.pluralCamel}Query = useQuery(
4412
+ \t\trq(list${names.pluralPascal}).queryOptions({ query: {} }),
4413
+ \t);
4414
+ \tconst create${names.singularPascal}Mutation = useMutation(
4415
+ \t\trq(create${names.singularPascal}).mutationOptions({
4416
+ \t\t\tonSuccess: async () => {
4417
+ \t\t\t\tsetName("");
4418
+ \t\t\t\tawait rq(list${names.pluralPascal}).invalidate(queryClient);
3039
4419
  \t\t\t},
3040
- \t\t\t{ status: 500 },
3041
- \t\t);
3042
- \t}
3043
-
3044
- \tif (
3045
- \t\trequest.headers.get("authorization") !== \`Bearer \${cronSecret}\`
3046
- \t) {
3047
- \t\treturn Response.json({ error: "Unauthorized" }, { status: 401 });
3048
- \t}
3049
-
3050
- \tconst ctx = await server.createContextFromNext();
3051
- \tconst runner = createInlineScheduleRunner<AppContext>({
3052
- \t\tctx,
3053
- \t\tonStart({ run, schedule }) {
3054
- \t\t\tctx.ports.devtools.record({
3055
- \t\t\t\ttype: "schedule",
3056
- \t\t\t\twatcher: "schedules",
3057
- \t\t\t\trequestId: ctx.requestId,
3058
- \t\t\t\tscheduleName: schedule.name,
3059
- \t\t\t\tstatus: "started",
3060
- \t\t\t\tcron: schedule.cron,
3061
- \t\t\t\ttimezone: schedule.timezone,
3062
- \t\t\t\tdetails: {
3063
- \t\t\t\t\tsource: run.source,
3064
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3065
- \t\t\t\t},
3066
- \t\t\t});
3067
- \t\t},
3068
- \t\tonSuccess({ run, schedule }) {
3069
- \t\t\tctx.ports.devtools.record({
3070
- \t\t\t\ttype: "schedule",
3071
- \t\t\t\twatcher: "schedules",
3072
- \t\t\t\trequestId: ctx.requestId,
3073
- \t\t\t\tscheduleName: schedule.name,
3074
- \t\t\t\tstatus: "completed",
3075
- \t\t\t\tcron: schedule.cron,
3076
- \t\t\t\ttimezone: schedule.timezone,
3077
- \t\t\t\tdetails: {
3078
- \t\t\t\t\tsource: run.source,
3079
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3080
- \t\t\t\t},
3081
- \t\t\t});
3082
- \t\t},
3083
- \t\tonError({ error, run, schedule }) {
3084
- \t\t\tctx.ports.devtools.record({
3085
- \t\t\t\ttype: "schedule",
3086
- \t\t\t\twatcher: "schedules",
3087
- \t\t\t\trequestId: ctx.requestId,
3088
- \t\t\t\tscheduleName: schedule.name,
3089
- \t\t\t\tstatus: "failed",
3090
- \t\t\t\tcron: schedule.cron,
3091
- \t\t\t\ttimezone: schedule.timezone,
3092
- \t\t\t\tdetails: {
3093
- \t\t\t\t\terror,
3094
- \t\t\t\t\tsource: run.source,
3095
- \t\t\t\t\tscheduledAt: run.scheduledAt?.toISOString(),
3096
- \t\t\t\t},
3097
- \t\t\t});
3098
- \t\t\tctx.ports.logger.error("Schedule failed", {
3099
- \t\t\t\terror,
3100
- \t\t\t\tscheduleName: schedule.name,
3101
- \t\t\t});
3102
- \t\t},
3103
- \t\tonHookError({ error, hook, schedule }) {
3104
- \t\t\tctx.ports.logger.warn("Schedule lifecycle hook failed", {
3105
- \t\t\t\terror,
3106
- \t\t\t\thook,
3107
- \t\t\t\tscheduleName: schedule.name,
3108
- \t\t\t});
3109
- \t\t},
3110
- \t});
4420
+ \t\t}),
4421
+ \t);
3111
4422
 
3112
- \ttry {
3113
- \t\tawait runner.run(${names.scheduleExportName}, {
3114
- \t\t\tsource: "cron-route",
3115
- \t\t});
3116
- \t} catch {
3117
- \t\treturn Response.json({ error: "Schedule failed" }, { status: 500 });
3118
- \t}
4423
+ \treturn (
4424
+ \t\t<section className="${names.pluralKebab}-panel">
4425
+ \t\t\t<form
4426
+ \t\t\t\tclassName="${names.pluralKebab}-composer"
4427
+ \t\t\t\tonSubmit={(event) => {
4428
+ \t\t\t\t\tevent.preventDefault();
4429
+ \t\t\t\t\tconst trimmedName = name.trim();
4430
+ \t\t\t\t\tif (!trimmedName) return;
4431
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.reset();
4432
+ \t\t\t\t\tcreate${names.singularPascal}Mutation.mutate({
4433
+ \t\t\t\t\t\tbody: { name: trimmedName },
4434
+ \t\t\t\t\t});
4435
+ \t\t\t\t}}
4436
+ \t\t\t>
4437
+ \t\t\t\t<label htmlFor="${names.singularKebab}-name">New ${names.singularKebab}</label>
4438
+ \t\t\t\t<div className="${names.pluralKebab}-composer-row">
4439
+ \t\t\t\t\t<input
4440
+ \t\t\t\t\t\tid="${names.singularKebab}-name"
4441
+ \t\t\t\t\t\tvalue={name}
4442
+ \t\t\t\t\t\tonChange={(event) => setName(event.currentTarget.value)}
4443
+ \t\t\t\t\t\tplaceholder="Name"
4444
+ \t\t\t\t\t/>
4445
+ \t\t\t\t\t<button
4446
+ \t\t\t\t\t\ttype="submit"
4447
+ \t\t\t\t\t\tdisabled={!name.trim() || create${names.singularPascal}Mutation.isPending}
4448
+ \t\t\t\t\t>
4449
+ \t\t\t\t\t\t{create${names.singularPascal}Mutation.isPending ? "Creating" : "Create"}
4450
+ \t\t\t\t\t</button>
4451
+ \t\t\t\t</div>
4452
+ \t\t\t\t{create${names.singularPascal}Mutation.isError ? (
4453
+ \t\t\t\t\t<p role="alert">
4454
+ \t\t\t\t\t\t{contractErrorMessage(
4455
+ \t\t\t\t\t\t\tcreate${names.singularPascal}Mutation.error,
4456
+ \t\t\t\t\t\t\t"Could not create ${names.singularKebab}.",
4457
+ \t\t\t\t\t\t)}
4458
+ \t\t\t\t\t</p>
4459
+ \t\t\t\t) : null}
4460
+ \t\t\t</form>
3119
4461
 
3120
- \treturn Response.json({ ok: true });
4462
+ \t\t\t<div className="${names.pluralKebab}-list">
4463
+ \t\t\t\t<div className="${names.pluralKebab}-list-heading">
4464
+ \t\t\t\t\t<h2>${names.pluralPascal}</h2>
4465
+ \t\t\t\t\t<span>{${names.pluralCamel}Query.data?.items.length ?? 0} shown</span>
4466
+ \t\t\t\t</div>
4467
+ \t\t\t\t{${names.pluralCamel}Query.isLoading ? <p>Loading...</p> : null}
4468
+ \t\t\t\t{${names.pluralCamel}Query.isError ? (
4469
+ \t\t\t\t\t<p role="alert">Could not load ${names.pluralKebab}.</p>
4470
+ \t\t\t\t) : null}
4471
+ \t\t\t\t{${names.pluralCamel}Query.data?.items.length === 0 ? (
4472
+ \t\t\t\t\t<p>No ${names.pluralKebab} yet.</p>
4473
+ \t\t\t\t) : null}
4474
+ \t\t\t\t<ul>
4475
+ \t\t\t\t\t{${names.pluralCamel}Query.data?.items.map((${names.singularCamel}) => (
4476
+ \t\t\t\t\t\t<li key={${names.singularCamel}.id}>
4477
+ \t\t\t\t\t\t\t<strong>{${names.singularCamel}.name}</strong>
4478
+ \t\t\t\t\t\t\t<small>{new Date(${names.singularCamel}.createdAt).toLocaleDateString()}</small>
4479
+ \t\t\t\t\t\t</li>
4480
+ \t\t\t\t\t))}
4481
+ \t\t\t\t</ul>
4482
+ \t\t\t</div>
4483
+ \t\t</section>
4484
+ \t);
4485
+ }
4486
+ `;
3121
4487
  }
4488
+ function scheduleRouteFile(names, config) {
4489
+ return `import { createScheduleRoute } from "@beignet/next";
4490
+ import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
4491
+ import { env } from "@/lib/env";
4492
+ import { server } from "${aliasModule(config.paths.server)}";
3122
4493
 
3123
- export const GET = run${names.artifact.pascal};
3124
- export const POST = run${names.artifact.pascal};
4494
+ export const runtime = "nodejs";
4495
+
4496
+ export const { GET, POST } = createScheduleRoute({
4497
+ \tserver,
4498
+ \tschedules: [${names.scheduleExportName}],
4499
+ \tschedule: ${names.scheduleExportName}.name,
4500
+ \tsecret: env.CRON_SECRET,
4501
+ \tsource: "cron-route",
4502
+ });
3125
4503
  `;
3126
4504
  }
3127
4505
  function useCaseIndexExport(names) {
@@ -3134,78 +4512,138 @@ function useCaseIndexExport(names) {
3134
4512
  } from "./${names.action.kebab}";
3135
4513
  `;
3136
4514
  }
3137
- function routeGroupFile(names, config) {
3138
- return `import { defineRouteGroup } from "@beignet/next";
3139
- import type { AppContext } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), config.paths.appContext)}";
3140
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceContractFilePath(names, config))}";
4515
+ function routeGroupFile(names, config, mode, _options) {
4516
+ const routeFilePath = path.join(config.paths.features, names.pluralKebab, "routes.ts");
4517
+ const contractsPath = resourceContractFilePath(names, config);
4518
+ const useCasesPath = resourceUseCaseIndexPath(names, config);
4519
+ return `import type { beignetServerOnly } from "@beignet/core/server-only";
4520
+ import { defineRouteGroup } from "@beignet/next";
4521
+ import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
4522
+ import {
4523
+ create${names.singularPascal},
4524
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
4525
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${relativeModule(routeFilePath, contractsPath)}";
3141
4526
  import {
3142
4527
  create${names.singularPascal}UseCase,
3143
- list${names.pluralPascal}UseCase,
3144
- } from "${relativeModule(path.join(config.paths.features, names.pluralKebab, "routes.ts"), resourceUseCaseIndexPath(names, config))}";
4528
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
4529
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${relativeModule(routeFilePath, useCasesPath)}";
3145
4530
 
3146
4531
  export const ${names.singularCamel}Routes = defineRouteGroup<AppContext>({
3147
4532
  name: "${names.pluralKebab}",
3148
4533
  routes: [
3149
- {
3150
- contract: list${names.pluralPascal},
3151
- handle: async ({ ctx, query }) => ({
3152
- status: 200,
3153
- body: await list${names.pluralPascal}UseCase.run({ ctx, input: query }),
3154
- }),
3155
- },
3156
- {
3157
- contract: create${names.singularPascal},
3158
- handle: async ({ ctx, body }) => ({
3159
- status: 201,
3160
- body: await create${names.singularPascal}UseCase.run({ ctx, input: body }),
3161
- }),
3162
- },
3163
- ],
4534
+ { contract: list${names.pluralPascal}, useCase: list${names.pluralPascal}UseCase },
4535
+ { contract: create${names.singularPascal}, useCase: create${names.singularPascal}UseCase },
4536
+ ${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` : ""} ],
3164
4537
  });
3165
4538
  `;
3166
4539
  }
3167
- function testFile(names, config) {
4540
+ function testFile(names, config, mode, options) {
3168
4541
  const repositoryPath = path.join(path.dirname(config.paths.infrastructurePorts), names.pluralKebab, `in-memory-${names.singularKebab}-repository.ts`);
4542
+ const serverContextPath = path.join(path.dirname(config.paths.server), "context.ts");
4543
+ const requestTarget = options.tenant ? "requester" : "app";
3169
4544
  return `import { describe, expect, it } from "bun:test";
3170
4545
  import { createUseCaseTester } from "@beignet/core/application";
3171
- import { defineRoutes } from "@beignet/web";
3172
- import { createTestApp } from "@beignet/web/testing";
4546
+ ${mode === "resource" ? `import { isAppError } from "@beignet/core/errors";\n` : ""}import { defineRoutes } from "@beignet/web";
4547
+ import { createTestApp${options.tenant ? ", createTestRequester" : ""} } from "@beignet/web/testing";
3173
4548
  import { createInMemoryDevtools } from "@beignet/devtools";
3174
- import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
4549
+ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
4550
+ import { ${options.auth ? "createTestUserActor" : "createTestAnonymousActor"}${options.tenant ? ", createTestTenant" : ""} } from "@beignet/core/ports/testing";
3175
4551
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
3176
4552
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
3177
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
4553
+ import { appContext } from "${aliasModule(serverContextPath)}";
4554
+ import {
4555
+ create${names.singularPascal},
4556
+ ${mode === "resource" ? ` delete${names.singularPascal},\n get${names.singularPascal},\n` : ""} list${names.pluralPascal},
4557
+ ${mode === "resource" ? ` update${names.singularPascal},\n` : ""}} from "${aliasModule(resourceContractFilePath(names, config))}";
3178
4558
  import { createInMemory${names.singularPascal}Repository } from "${aliasModule(repositoryPath)}";
3179
4559
  import { ${names.singularCamel}Routes } from "${aliasModule(path.join(resourceFeatureDir(names, config), "routes.ts"))}";
3180
4560
  import {
3181
4561
  create${names.singularPascal}UseCase,
3182
- list${names.pluralPascal}UseCase,
3183
- } from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
4562
+ ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
4563
+ ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${aliasModule(resourceUseCaseIndexPath(names, config))}";
3184
4564
 
3185
4565
  describe("${names.pluralCamel} resource", () => {
3186
- it("creates and lists ${names.pluralCamel}", async () => {
3187
- const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();
3188
- const testPorts = {
3189
- ...appPorts,
3190
- ${names.pluralCamel},
3191
- uow: createNoopUnitOfWork(() => ({
3192
- ...(appPorts as unknown as AppContext["ports"]),
4566
+ it("${mode === "resource" ? `exercises ${names.pluralCamel} CRUD` : `creates and lists ${names.pluralCamel}`}", async () => {
4567
+ const seed${names.pluralPascal} = [
4568
+ {
4569
+ id: "00000000-0000-4000-8000-000000000101",
4570
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Alpha ${names.singularPascal}",
4571
+ version: 1,
4572
+ createdAt: "2024-01-01T00:00:00.000Z",
4573
+ updatedAt: "2024-01-01T00:00:00.000Z",
4574
+ },
4575
+ {
4576
+ id: "00000000-0000-4000-8000-000000000102",
4577
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Beta ${names.singularPascal}",
4578
+ version: 1,
4579
+ createdAt: "2024-01-02T00:00:00.000Z",
4580
+ updatedAt: "2024-01-02T00:00:00.000Z",
4581
+ },
4582
+ {
4583
+ id: "00000000-0000-4000-8000-000000000103",
4584
+ ${options.tenant ? `\t\t\t\ttenantId: "tenant_test",\n` : ""} name: "Gamma ${names.singularPascal}",
4585
+ version: 1,
4586
+ createdAt: "2024-01-03T00:00:00.000Z",
4587
+ updatedAt: "2024-01-03T00:00:00.000Z",
4588
+ },
4589
+ ${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` : ""} ];
4590
+ const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository(seed${names.pluralPascal});
4591
+ const testFixture = createTestPorts<AppContext["ports"]>({
4592
+ base: appPorts,
4593
+ overrides: {
3193
4594
  ${names.pluralCamel},
3194
- })) as unknown as AppContext["ports"]["uow"],
3195
- devtools: createInMemoryDevtools(),
3196
- storage: createMemoryStorage(),
3197
- } as AppContext["ports"];
3198
- const actor = createAnonymousActor();
3199
- const createTestContext = () => ({
3200
- requestId: "test-request",
3201
- actor,
3202
- auth: null,
3203
- gate: testPorts.gate.bind({ actor, auth: null }),
3204
- ports: testPorts,
4595
+ ${options.auth
4596
+ ? `\t\t\t\tauth: {
4597
+ getSession: async () => ({
4598
+ user: { id: "user_test", name: "Test User" },
4599
+ }),
4600
+ },
4601
+ gate: appPorts.gate,\n`
4602
+ : `\t\t\t\tauth: {
4603
+ getSession: async () => null,
4604
+ },\n`} devtools: createInMemoryDevtools(),
4605
+ },
4606
+ transaction: {
4607
+ ports: (ports) => ({
4608
+ ...ports,
4609
+ ${names.pluralCamel},
4610
+ }),
4611
+ },
4612
+ });
4613
+ const createTestContext = createTestContextFactory<AppContext, AppContext["ports"]>({
4614
+ ports: testFixture.ports,
4615
+ actor: ${options.auth ? `createTestUserActor("user_test")` : "createTestAnonymousActor()"},
4616
+ tenant: ${options.tenant ? `createTestTenant("tenant_test")` : "null"},
3205
4617
  });
3206
4618
  const tester = createUseCaseTester<AppContext>(createTestContext);
3207
4619
 
3208
4620
  const ctx = await tester.ctx();
4621
+ const defaultPage = await tester.run(list${names.pluralPascal}UseCase, {}, { ctx });
4622
+ const filteredPage = await tester.run(
4623
+ list${names.pluralPascal}UseCase,
4624
+ { name: "alp" },
4625
+ { ctx },
4626
+ );
4627
+ const firstPage = await tester.run(
4628
+ list${names.pluralPascal}UseCase,
4629
+ { limit: 2 },
4630
+ { ctx },
4631
+ );
4632
+ const secondPage = await tester.run(
4633
+ list${names.pluralPascal}UseCase,
4634
+ { limit: 2, cursor: firstPage.page.nextCursor },
4635
+ { ctx },
4636
+ );
4637
+ const nameAscendingPage = await tester.run(
4638
+ list${names.pluralPascal}UseCase,
4639
+ { sortBy: "name", sortDirection: "asc" },
4640
+ { ctx },
4641
+ );
4642
+ const nameDescendingPage = await tester.run(
4643
+ list${names.pluralPascal}UseCase,
4644
+ { sortBy: "name", sortDirection: "desc" },
4645
+ { ctx },
4646
+ );
3209
4647
  const created = await tester.run(
3210
4648
  create${names.singularPascal}UseCase,
3211
4649
  { name: "First ${names.singularPascal}" },
@@ -3213,35 +4651,111 @@ describe("${names.pluralCamel} resource", () => {
3213
4651
  );
3214
4652
  const result = await tester.run(
3215
4653
  list${names.pluralPascal}UseCase,
3216
- { limit: 20, offset: 0 },
4654
+ { name: "First" },
3217
4655
  { ctx },
3218
4656
  );
3219
4657
 
4658
+ expect(defaultPage.page).toMatchObject({
4659
+ kind: "cursor",
4660
+ limit: 20,
4661
+ cursor: null,
4662
+ nextCursor: null,
4663
+ hasMore: false,
4664
+ });
4665
+ expect(defaultPage.items.map((item) => item.name)).toEqual([
4666
+ "Gamma ${names.singularPascal}",
4667
+ "Beta ${names.singularPascal}",
4668
+ "Alpha ${names.singularPascal}",
4669
+ ]);
4670
+ expect(filteredPage.items.map((item) => item.name)).toEqual([
4671
+ "Alpha ${names.singularPascal}",
4672
+ ]);
4673
+ expect(firstPage.items.map((item) => item.name)).toEqual([
4674
+ "Gamma ${names.singularPascal}",
4675
+ "Beta ${names.singularPascal}",
4676
+ ]);
4677
+ expect(firstPage.page.nextCursor).toEqual(expect.any(String));
4678
+ expect(secondPage.items.map((item) => item.name)).toEqual([
4679
+ "Alpha ${names.singularPascal}",
4680
+ ]);
4681
+ expect(secondPage.page.nextCursor).toBeNull();
4682
+ expect(nameAscendingPage.items.map((item) => item.name)).toEqual([
4683
+ "Alpha ${names.singularPascal}",
4684
+ "Beta ${names.singularPascal}",
4685
+ "Gamma ${names.singularPascal}",
4686
+ ]);
4687
+ expect(nameDescendingPage.items.map((item) => item.name)).toEqual([
4688
+ "Gamma ${names.singularPascal}",
4689
+ "Beta ${names.singularPascal}",
4690
+ "Alpha ${names.singularPascal}",
4691
+ ]);
3220
4692
  expect(created.name).toBe("First ${names.singularPascal}");
3221
- expect(result.page.total).toBe(1);
3222
4693
  expect(result.items).toEqual([created]);
4694
+ ${mode === "resource"
4695
+ ? `
4696
+ const fetched = await tester.run(
4697
+ get${names.singularPascal}UseCase,
4698
+ { id: created.id },
4699
+ { ctx },
4700
+ );
4701
+ const updated = await tester.run(
4702
+ update${names.singularPascal}UseCase,
4703
+ { id: created.id, name: "Updated ${names.singularPascal}", version: fetched.version },
4704
+ { ctx },
4705
+ );
4706
+ try {
4707
+ await tester.run(
4708
+ update${names.singularPascal}UseCase,
4709
+ { id: created.id, name: "Stale ${names.singularPascal}", version: fetched.version },
4710
+ { ctx },
4711
+ );
4712
+ throw new Error("Expected ${names.singularPascal}Conflict");
4713
+ } catch (error) {
4714
+ expect(isAppError(error)).toBe(true);
4715
+ if (isAppError(error)) {
4716
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_CONFLICT");
4717
+ }
4718
+ }
4719
+ await tester.run(delete${names.singularPascal}UseCase, { id: created.id }, { ctx });
4720
+ const afterDelete = await tester.run(
4721
+ list${names.pluralPascal}UseCase,
4722
+ { name: "Updated" },
4723
+ { ctx },
4724
+ );
4725
+
4726
+ expect(fetched).toEqual(created);
4727
+ expect(updated.name).toBe("Updated ${names.singularPascal}");
4728
+ expect(updated.version).toBe(fetched.version + 1);
4729
+ expect(afterDelete.items).toEqual([]);
3223
4730
 
3224
- const app = await createTestApp<AppContext, AppContext["ports"]>({
3225
- ports: testPorts,
4731
+ try {
4732
+ await tester.run(get${names.singularPascal}UseCase, { id: created.id }, { ctx });
4733
+ throw new Error("Expected ${names.singularPascal}NotFound");
4734
+ } catch (error) {
4735
+ expect(isAppError(error)).toBe(true);
4736
+ if (isAppError(error)) {
4737
+ expect(error.code).toBe("${constantCase(names.singularKebab)}_NOT_FOUND");
4738
+ }
4739
+ }
4740
+ `
4741
+ : ""}
4742
+
4743
+ const app = await createTestApp({
4744
+ ports: testFixture.ports,
3226
4745
  routes: defineRoutes<AppContext>([${names.singularCamel}Routes]),
3227
- createContext: async () => createTestContext(),
3228
- mapUnhandledError: () => ({
3229
- status: 500,
3230
- body: { code: "INTERNAL_SERVER_ERROR", message: "Internal server error" },
3231
- }),
4746
+ context: appContext,
3232
4747
  });
3233
-
3234
- const createdViaRoute = await app.request(create${names.singularPascal}, {
4748
+ ${options.tenant ? `\t\tconst requester = createTestRequester(app, {\n\t\t\theaders: { "x-tenant-id": "tenant_test" },\n\t\t});\n` : ""}
4749
+ const createdViaRoute = await ${requestTarget}.request(create${names.singularPascal}, {
3235
4750
  body: { name: "Second ${names.singularPascal}" },
3236
4751
  });
3237
- const listedViaRoute = await app.request(list${names.pluralPascal}, {
3238
- query: { limit: 20, offset: 0 },
4752
+ ${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}, {
4753
+ query: { name: "${mode === "resource" ? "Updated via route" : "Second"}" },
3239
4754
  });
3240
- await app.stop();
4755
+ ${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();
3241
4756
 
3242
4757
  expect(createdViaRoute.name).toBe("Second ${names.singularPascal}");
3243
- expect(listedViaRoute.page.total).toBe(2);
3244
- expect(listedViaRoute.items).toContainEqual(createdViaRoute);
4758
+ ${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`}
3245
4759
  });
3246
4760
  });
3247
4761
  `;