@beignet/cli 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/make.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
4
+ export const makeFeatureAddonChoices = [
5
+ "policy",
6
+ "event",
7
+ "events",
8
+ "job",
9
+ "jobs",
10
+ "notification",
11
+ "notifications",
12
+ "upload",
13
+ "uploads",
14
+ ];
4
15
  export async function makeResource(options) {
5
16
  const targetDir = path.resolve(options.cwd ?? process.cwd());
6
17
  const names = resourceNames(options.name);
@@ -47,6 +58,91 @@ export async function makeResource(options) {
47
58
  skippedFiles,
48
59
  };
49
60
  }
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;
81
+ }
82
+ }
83
+ async function makeFeatureSlice(options, addons) {
84
+ let result = await makeFeatureResource(options);
85
+ for (const addon of addons) {
86
+ result = mergeMakeResults(result, await makeFeatureAddon(addon, result.name, options));
87
+ }
88
+ return result;
89
+ }
90
+ async function makeFeatureAddon(addon, feature, options) {
91
+ const shared = {
92
+ cwd: options.cwd,
93
+ force: options.force,
94
+ dryRun: options.dryRun,
95
+ config: options.config,
96
+ };
97
+ if (addon === "policy") {
98
+ return makePolicy({ ...shared, name: feature });
99
+ }
100
+ if (addon === "event") {
101
+ return makeEvent({ ...shared, name: `${feature}/created` });
102
+ }
103
+ if (addon === "job") {
104
+ return makeJob({ ...shared, name: `${feature}/process` });
105
+ }
106
+ if (addon === "notification") {
107
+ return makeNotification({ ...shared, name: `${feature}/created` });
108
+ }
109
+ return makeUpload({ ...shared, name: `${feature}/attachment` });
110
+ }
111
+ function normalizeMakeFeatureAddons(addons) {
112
+ const normalized = new Set();
113
+ for (const addon of addons) {
114
+ if (addon === "events")
115
+ normalized.add("event");
116
+ else if (addon === "jobs")
117
+ normalized.add("job");
118
+ else if (addon === "notifications")
119
+ normalized.add("notification");
120
+ else if (addon === "uploads")
121
+ normalized.add("upload");
122
+ else
123
+ normalized.add(addon);
124
+ }
125
+ return featureAddonOrder.filter((addon) => normalized.has(addon));
126
+ }
127
+ const featureAddonOrder = [
128
+ "policy",
129
+ "event",
130
+ "job",
131
+ "notification",
132
+ "upload",
133
+ ];
134
+ function mergeMakeResults(left, right) {
135
+ return {
136
+ ...left,
137
+ files: uniqueStrings([...left.files, ...right.files]),
138
+ createdFiles: uniqueStrings([...left.createdFiles, ...right.createdFiles]),
139
+ updatedFiles: uniqueStrings([...left.updatedFiles, ...right.updatedFiles]),
140
+ skippedFiles: uniqueStrings([...left.skippedFiles, ...right.skippedFiles]),
141
+ };
142
+ }
143
+ function uniqueStrings(values) {
144
+ return [...new Set(values)];
145
+ }
50
146
  export async function makeContract(options) {
51
147
  const targetDir = path.resolve(options.cwd ?? process.cwd());
52
148
  const names = resourceNames(options.name);
@@ -145,7 +241,9 @@ export async function makeTest(options) {
145
241
  if (result === "skipped")
146
242
  skippedFiles.push(file.path);
147
243
  }
148
- if (await updatePackageJson(targetDir, { dryRun: Boolean(options.dryRun) })) {
244
+ if (await updatePackageJson(targetDir, {
245
+ dryRun: Boolean(options.dryRun),
246
+ })) {
149
247
  updatedFiles.push("package.json");
150
248
  }
151
249
  return {
@@ -320,6 +418,66 @@ export async function makeJob(options) {
320
418
  },
321
419
  });
322
420
  }
421
+ export async function makeFactory(options) {
422
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
423
+ const names = factoryNames(options.name);
424
+ const config = options.config
425
+ ? resolveConfig(options.config)
426
+ : await loadBeignetConfig(targetDir);
427
+ await assertPersistenceArtifactApp(targetDir, names, config, "factory");
428
+ return makeFeatureArtifact({
429
+ targetDir,
430
+ config,
431
+ force: Boolean(options.force),
432
+ dryRun: Boolean(options.dryRun),
433
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
434
+ files: factoryFiles(names, config),
435
+ index: featureArtifactIndexFile("factory", names, config),
436
+ dependencies: {
437
+ "@beignet/core": beignetDependencyVersion,
438
+ },
439
+ });
440
+ }
441
+ export async function makeSeed(options) {
442
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
443
+ const names = seedNames(options.name);
444
+ const config = options.config
445
+ ? resolveConfig(options.config)
446
+ : await loadBeignetConfig(targetDir);
447
+ await assertPersistenceArtifactApp(targetDir, names, config, "seed");
448
+ return makeFeatureArtifact({
449
+ targetDir,
450
+ config,
451
+ force: Boolean(options.force),
452
+ dryRun: Boolean(options.dryRun),
453
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
454
+ files: seedFiles(names, config),
455
+ index: featureArtifactIndexFile("seed", names, config),
456
+ dependencies: {
457
+ "@beignet/core": beignetDependencyVersion,
458
+ },
459
+ });
460
+ }
461
+ export async function makeNotification(options) {
462
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
463
+ const names = notificationNames(options.name);
464
+ const config = options.config
465
+ ? resolveConfig(options.config)
466
+ : await loadBeignetConfig(targetDir);
467
+ await assertFeatureArtifactApp(targetDir, config, "notification");
468
+ return makeFeatureArtifact({
469
+ targetDir,
470
+ config,
471
+ force: Boolean(options.force),
472
+ dryRun: Boolean(options.dryRun),
473
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
474
+ files: notificationFiles(names, config),
475
+ index: featureArtifactIndexFile("notification", names, config),
476
+ dependencies: {
477
+ "@beignet/core": beignetDependencyVersion,
478
+ },
479
+ });
480
+ }
323
481
  export async function makeListener(options) {
324
482
  const targetDir = path.resolve(options.cwd ?? process.cwd());
325
483
  const names = listenerNames(options.name, options.event);
@@ -376,6 +534,26 @@ export async function makeSchedule(options) {
376
534
  },
377
535
  });
378
536
  }
537
+ export async function makeUpload(options) {
538
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
539
+ const names = uploadNames(options.name);
540
+ const config = options.config
541
+ ? resolveConfig(options.config)
542
+ : await loadBeignetConfig(targetDir);
543
+ await assertFeatureArtifactApp(targetDir, config, "upload");
544
+ return makeFeatureArtifact({
545
+ targetDir,
546
+ config,
547
+ force: Boolean(options.force),
548
+ dryRun: Boolean(options.dryRun),
549
+ name: `${names.feature.kebab}/${names.artifact.kebab}`,
550
+ files: uploadFiles(names, config),
551
+ index: featureArtifactIndexFile("upload", names, config),
552
+ dependencies: {
553
+ "@beignet/core": beignetDependencyVersion,
554
+ },
555
+ });
556
+ }
379
557
  async function assertStandardApp(targetDir, config) {
380
558
  const requiredFiles = [
381
559
  config.paths.appContext,
@@ -468,6 +646,21 @@ async function assertFeatureArtifactApp(targetDir, config, artifact, options = {
468
646
  }
469
647
  }
470
648
  }
649
+ async function assertPersistenceArtifactApp(targetDir, names, config, artifact) {
650
+ const requiredFiles = [
651
+ config.paths.appContext,
652
+ config.paths.ports,
653
+ resourcePortFilePath(featureResourceNames(names), config),
654
+ ];
655
+ for (const file of requiredFiles) {
656
+ try {
657
+ await stat(path.join(targetDir, file));
658
+ }
659
+ catch {
660
+ throw new Error(`beignet make ${artifact} expects a feature with a repository port. Missing ${file}. Run beignet make feature ${names.feature.kebab} first, or add the repository port before generating ${artifact}s.`);
661
+ }
662
+ }
663
+ }
471
664
  async function writeGeneratedFile(targetDir, file, options) {
472
665
  const plannedFile = await planGeneratedFile(targetDir, file, {
473
666
  force: options.force,
@@ -587,12 +780,17 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
587
780
  ? maybeOptions
588
781
  : persistenceOrOptions;
589
782
  const updated = new Set();
590
- if (await updatePackageJson(targetDir, options))
783
+ if (await updatePackageJson(targetDir, {
784
+ ...options,
785
+ webTestingDependency: true,
786
+ })) {
591
787
  updated.add("package.json");
788
+ }
592
789
  if (await updatePortsIndex(targetDir, names, config, options)) {
593
790
  updated.add(config.paths.ports);
594
791
  }
595
- if (await updateInfrastructurePorts(targetDir, names, config, options)) {
792
+ if (persistence === "memory" &&
793
+ (await updateInfrastructurePorts(targetDir, names, config, options))) {
596
794
  updated.add(config.paths.infrastructurePorts);
597
795
  }
598
796
  if (persistence === "drizzle" &&
@@ -620,9 +818,14 @@ async function updatePackageJson(targetDir, options) {
620
818
  const original = await readFile(filePath, "utf8");
621
819
  const packageJson = JSON.parse(original);
622
820
  packageJson.scripts = packageJson.scripts ?? {};
623
- if (packageJson.scripts.test)
624
- return false;
625
- packageJson.scripts.test = "bun test";
821
+ packageJson.scripts.test ??= "bun test";
822
+ if (options.webTestingDependency) {
823
+ packageJson.devDependencies = packageJson.devDependencies ?? {};
824
+ packageJson.devDependencies["@beignet/web"] ??=
825
+ packageJson.dependencies?.["@beignet/core"] ??
826
+ packageJson.dependencies?.["@beignet/next"] ??
827
+ "*";
828
+ }
626
829
  const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
627
830
  if (!options.dryRun)
628
831
  await writeFile(filePath, next);
@@ -1383,6 +1586,9 @@ function resourceNames(input) {
1383
1586
  ]);
1384
1587
  return names;
1385
1588
  }
1589
+ function featureResourceNames(names) {
1590
+ return resourceNames(names.feature.kebab);
1591
+ }
1386
1592
  function useCaseNames(input) {
1387
1593
  const parts = input
1388
1594
  .trim()
@@ -1497,6 +1703,40 @@ function jobNames(input) {
1497
1703
  payloadTypeName: `${jobExportName}Payload`,
1498
1704
  };
1499
1705
  }
1706
+ function factoryNames(input) {
1707
+ const names = featureArtifactNames(input, "Factory");
1708
+ const factoryExportName = `${names.artifact.camel}Factory`;
1709
+ assertGeneratedIdentifiers(input, "Factory name", [factoryExportName]);
1710
+ return {
1711
+ ...names,
1712
+ factoryName: names.stableName,
1713
+ factoryExportName,
1714
+ };
1715
+ }
1716
+ function seedNames(input) {
1717
+ const names = featureArtifactNames(input, "Seed");
1718
+ const seedExportName = `${names.artifact.camel}Seed`;
1719
+ assertGeneratedIdentifiers(input, "Seed name", [seedExportName]);
1720
+ return {
1721
+ ...names,
1722
+ seedName: names.stableName,
1723
+ seedExportName,
1724
+ };
1725
+ }
1726
+ function notificationNames(input) {
1727
+ const names = featureArtifactNames(input, "Notification");
1728
+ const notificationExportName = `${names.featureSingularPascal}${names.artifact.pascal}Notification`;
1729
+ assertGeneratedIdentifiers(input, "Notification name", [
1730
+ notificationExportName,
1731
+ ]);
1732
+ return {
1733
+ ...names,
1734
+ notificationName: names.stableName,
1735
+ notificationExportName,
1736
+ payloadSchemaName: `${notificationExportName}PayloadSchema`,
1737
+ payloadTypeName: `${notificationExportName}Payload`,
1738
+ };
1739
+ }
1500
1740
  function listenerNames(input, eventInput) {
1501
1741
  const names = featureArtifactNames(input, "Listener");
1502
1742
  const listenerExportName = names.artifact.camel;
@@ -1522,6 +1762,18 @@ function scheduleNames(input, options) {
1522
1762
  timezone: options.timezone?.trim() || undefined,
1523
1763
  };
1524
1764
  }
1765
+ function uploadNames(input) {
1766
+ const names = featureArtifactNames(input, "Upload");
1767
+ const uploadExportName = `${names.artifact.pascal}Upload`;
1768
+ assertGeneratedIdentifiers(input, "Upload name", [uploadExportName]);
1769
+ return {
1770
+ ...names,
1771
+ uploadName: names.stableName,
1772
+ uploadExportName,
1773
+ metadataSchemaName: `${uploadExportName}MetadataSchema`,
1774
+ metadataTypeName: `${uploadExportName}Metadata`,
1775
+ };
1776
+ }
1525
1777
  function normalizeEventReference(input) {
1526
1778
  const trimmed = input.trim();
1527
1779
  if (trimmed.includes("/"))
@@ -1767,6 +2019,30 @@ function jobFiles(names, config) {
1767
2019
  },
1768
2020
  ];
1769
2021
  }
2022
+ function notificationFiles(names, config) {
2023
+ return [
2024
+ {
2025
+ path: notificationFilePath(names, config),
2026
+ content: notificationFile(names, config),
2027
+ },
2028
+ ];
2029
+ }
2030
+ function factoryFiles(names, config) {
2031
+ return [
2032
+ {
2033
+ path: factoryFilePath(names, config),
2034
+ content: factoryFile(names, config),
2035
+ },
2036
+ ];
2037
+ }
2038
+ function seedFiles(names, config) {
2039
+ return [
2040
+ {
2041
+ path: seedFilePath(names, config),
2042
+ content: seedFile(names, config),
2043
+ },
2044
+ ];
2045
+ }
1770
2046
  function listenerFiles(names, config) {
1771
2047
  return [
1772
2048
  {
@@ -1790,6 +2066,14 @@ function scheduleFiles(names, config, options) {
1790
2066
  }
1791
2067
  return files;
1792
2068
  }
2069
+ function uploadFiles(names, config) {
2070
+ return [
2071
+ {
2072
+ path: uploadFilePath(names, config),
2073
+ content: uploadFile(names, config),
2074
+ },
2075
+ ];
2076
+ }
1793
2077
  function portFilePath(names, config) {
1794
2078
  return path.join(path.dirname(config.paths.ports), `${names.kebab}.ts`);
1795
2079
  }
@@ -1882,6 +2166,9 @@ function featureArtifactDir(names, kind, config) {
1882
2166
  if (kind === "events") {
1883
2167
  return path.join(featureDir, "domain", "events");
1884
2168
  }
2169
+ if (kind === "factories") {
2170
+ return path.join(featureDir, "tests", "factories");
2171
+ }
1885
2172
  return path.join(featureDir, kind);
1886
2173
  }
1887
2174
  function eventFilePath(names, config) {
@@ -1890,12 +2177,24 @@ function eventFilePath(names, config) {
1890
2177
  function jobFilePath(names, config) {
1891
2178
  return path.join(featureArtifactDir(names, "jobs", config), `${names.artifact.kebab}.ts`);
1892
2179
  }
2180
+ function factoryFilePath(names, config) {
2181
+ return path.join(featureArtifactDir(names, "factories", config), `${names.artifact.kebab}.ts`);
2182
+ }
2183
+ function seedFilePath(names, config) {
2184
+ return path.join(featureArtifactDir(names, "seeds", config), `${names.artifact.kebab}.ts`);
2185
+ }
2186
+ function notificationFilePath(names, config) {
2187
+ return path.join(featureArtifactDir(names, "notifications", config), `${names.artifact.kebab}.ts`);
2188
+ }
1893
2189
  function listenerFilePath(names, config) {
1894
2190
  return path.join(featureArtifactDir(names, "listeners", config), `${names.artifact.kebab}.ts`);
1895
2191
  }
1896
2192
  function scheduleFilePath(names, config) {
1897
2193
  return path.join(featureArtifactDir(names, "schedules", config), `${names.artifact.kebab}.ts`);
1898
2194
  }
2195
+ function uploadFilePath(names, config) {
2196
+ return path.join(featureArtifactDir(names, "uploads", config), `${names.artifact.kebab}.ts`);
2197
+ }
1899
2198
  function scheduleRouteFilePath(names, config) {
1900
2199
  return path.join(config.paths.routes, "cron", names.feature.kebab, names.artifact.kebab, "route.ts");
1901
2200
  }
@@ -1932,28 +2231,54 @@ function featureArtifactIndexFile(kind, names, config) {
1932
2231
  ? eventFilePath(names, config)
1933
2232
  : kind === "job"
1934
2233
  ? jobFilePath(names, config)
1935
- : kind === "listener"
1936
- ? listenerFilePath(names, config)
1937
- : scheduleFilePath(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);
1938
2245
  const registrySuffix = kind === "event"
1939
2246
  ? "Events"
1940
2247
  : kind === "job"
1941
2248
  ? "Jobs"
1942
- : kind === "listener"
1943
- ? "Listeners"
1944
- : "Schedules";
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";
1945
2260
  const importName = kind === "event"
1946
2261
  ? names.eventExportName
1947
2262
  : kind === "job"
1948
2263
  ? names.jobExportName
1949
- : kind === "listener"
1950
- ? names.listenerExportName
1951
- : names.scheduleExportName;
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;
1952
2275
  return {
1953
2276
  path: path.join(path.dirname(artifactPath), "index.ts"),
2277
+ kind,
1954
2278
  importName,
1955
2279
  artifactPath,
1956
2280
  registryName: `${names.featureSingularCamel}${registrySuffix}`,
2281
+ registryEntryName: names.artifact.camel,
1957
2282
  };
1958
2283
  }
1959
2284
  async function updateFeatureArtifactIndex(targetDir, file, options) {
@@ -1961,9 +2286,20 @@ async function updateFeatureArtifactIndex(targetDir, file, options) {
1961
2286
  const existing = await readOptionalFile(destination);
1962
2287
  const specifier = relativeModule(file.path, file.artifactPath);
1963
2288
  const importLine = `import { ${file.importName} } from "${specifier}";`;
2289
+ const defineUploadsImport = `import { defineUploads } from "@beignet/core/uploads";`;
1964
2290
  const exportLine = `export { ${file.importName} } from "${specifier}";`;
1965
2291
  if (existing === undefined) {
1966
- const content = `${importLine}
2292
+ const content = file.kind === "upload"
2293
+ ? `${defineUploadsImport}
2294
+ ${importLine}
2295
+
2296
+ ${exportLine}
2297
+
2298
+ export const ${file.registryName} = defineUploads({
2299
+ \t${file.registryEntryName}: ${file.importName},
2300
+ });
2301
+ `
2302
+ : `${importLine}
1967
2303
 
1968
2304
  ${exportLine}
1969
2305
 
@@ -1985,13 +2321,43 @@ export const ${file.registryName} = [${file.importName}] as const;
1985
2321
  ? `${next.slice(0, registryExport.index).trimEnd()}\n${exportLine}\n\n${next.slice(registryExport.index)}`
1986
2322
  : `${next.trimEnd()}\n${exportLine}\n`;
1987
2323
  }
1988
- const registry = arrayInitializerInfo(next, file.registryName);
1989
- if (!registry) {
1990
- next = `${next.trimEnd()}\n\nexport const ${file.registryName} = [${file.importName}] as const;\n`;
2324
+ if (file.kind === "upload") {
2325
+ const registry = defineUploadsInitializerInfo(next, file.registryName);
2326
+ const arrayRegistry = arrayInitializerInfo(next, file.registryName);
2327
+ if (registry) {
2328
+ if (!next.includes(defineUploadsImport)) {
2329
+ next = insertAfterImports(next, defineUploadsImport);
2330
+ }
2331
+ if (!registry.text.includes(`${file.registryEntryName}:`)) {
2332
+ next = `${next.slice(0, registry.end)}\t${file.registryEntryName}: ${file.importName},\n${next.slice(registry.end)}`;
2333
+ }
2334
+ }
2335
+ else if (arrayRegistry) {
2336
+ if (!identifiersFromArrayExpression(arrayRegistry.text).has(file.importName)) {
2337
+ const nextArray = appendToArrayExpression(arrayRegistry.text, [
2338
+ file.importName,
2339
+ ]);
2340
+ next = `${next.slice(0, arrayRegistry.start)}${nextArray}${next.slice(arrayRegistry.end)}`;
2341
+ }
2342
+ }
2343
+ else {
2344
+ if (!next.includes(defineUploadsImport)) {
2345
+ next = insertAfterImports(next, defineUploadsImport);
2346
+ }
2347
+ next = `${next.trimEnd()}\n\nexport const ${file.registryName} = defineUploads({\n\t${file.registryEntryName}: ${file.importName},\n});\n`;
2348
+ }
1991
2349
  }
1992
- else if (!identifiersFromArrayExpression(registry.text).has(file.importName)) {
1993
- const nextArray = appendToArrayExpression(registry.text, [file.importName]);
1994
- next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
2350
+ else {
2351
+ const registry = arrayInitializerInfo(next, file.registryName);
2352
+ if (!registry) {
2353
+ next = `${next.trimEnd()}\n\nexport const ${file.registryName} = [${file.importName}] as const;\n`;
2354
+ }
2355
+ else if (!identifiersFromArrayExpression(registry.text).has(file.importName)) {
2356
+ const nextArray = appendToArrayExpression(registry.text, [
2357
+ file.importName,
2358
+ ]);
2359
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
2360
+ }
1995
2361
  }
1996
2362
  if (next === existing)
1997
2363
  return "skipped";
@@ -2015,6 +2381,22 @@ function arrayInitializerInfo(source, constName) {
2015
2381
  end: closeBracket + 1,
2016
2382
  };
2017
2383
  }
2384
+ function defineUploadsInitializerInfo(source, constName) {
2385
+ const match = new RegExp(`\\bconst\\s+${constName}\\s*=\\s*defineUploads\\s*\\(`).exec(source);
2386
+ if (!match)
2387
+ return undefined;
2388
+ const openBrace = source.indexOf("{", match.index);
2389
+ if (openBrace === -1)
2390
+ return undefined;
2391
+ const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
2392
+ if (closeBrace === -1)
2393
+ return undefined;
2394
+ return {
2395
+ text: source.slice(openBrace, closeBrace + 1),
2396
+ start: openBrace,
2397
+ end: closeBrace,
2398
+ };
2399
+ }
2018
2400
  function schemasFile(names) {
2019
2401
  return `import { z } from "zod";
2020
2402
 
@@ -2030,10 +2412,14 @@ export const List${names.pluralPascal}InputSchema = z.object({
2030
2412
  });
2031
2413
 
2032
2414
  export const List${names.pluralPascal}OutputSchema = z.object({
2033
- ${names.pluralCamel}: z.array(${names.singularPascal}Schema),
2034
- total: z.number().int().min(0),
2035
- limit: z.number().int().min(1),
2036
- offset: z.number().int().min(0),
2415
+ items: z.array(${names.singularPascal}Schema),
2416
+ page: z.object({
2417
+ kind: z.literal("offset"),
2418
+ limit: z.number().int().min(1),
2419
+ offset: z.number().int().min(0),
2420
+ total: z.number().int().min(0),
2421
+ hasMore: z.boolean(),
2422
+ }),
2037
2423
  });
2038
2424
 
2039
2425
  export const Create${names.singularPascal}InputSchema = z.object({
@@ -2051,7 +2437,8 @@ export type List${names.pluralPascal}Input = z.infer<
2051
2437
  }
2052
2438
  function listUseCaseFile(names, config) {
2053
2439
  const filePath = path.join(resourceUseCaseDir(names, config), `list-${names.pluralKebab}.ts`);
2054
- return `import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2440
+ return `import { normalizeOffsetPage } from "@beignet/core/pagination";
2441
+ import { useCase } from "${relativeModule(filePath, config.paths.useCaseBuilder)}";
2055
2442
  import {
2056
2443
  List${names.pluralPascal}InputSchema,
2057
2444
  List${names.pluralPascal}OutputSchema,
@@ -2062,13 +2449,12 @@ export const list${names.pluralPascal}UseCase = useCase
2062
2449
  .input(List${names.pluralPascal}InputSchema)
2063
2450
  .output(List${names.pluralPascal}OutputSchema)
2064
2451
  .run(async ({ ctx, input }) => {
2065
- const result = await ctx.ports.${names.pluralCamel}.list(input);
2066
- return {
2067
- ${names.pluralCamel}: result.${names.pluralCamel},
2068
- total: result.total,
2069
- limit: input.limit,
2070
- offset: input.offset,
2071
- };
2452
+ const page = normalizeOffsetPage(input, {
2453
+ defaultLimit: 20,
2454
+ maxLimit: 100,
2455
+ });
2456
+
2457
+ return ctx.ports.${names.pluralCamel}.list(page);
2072
2458
  });
2073
2459
  `;
2074
2460
  }
@@ -2103,28 +2489,29 @@ export {
2103
2489
  }
2104
2490
  function repositoryPortFile(names, config) {
2105
2491
  return `import type {
2492
+ OffsetPage,
2493
+ OffsetPageInfo,
2494
+ PageResult,
2495
+ } from "@beignet/core/pagination";
2496
+ import type {
2106
2497
  Create${names.singularPascal}Input,
2107
- List${names.pluralPascal}Input,
2108
2498
  ${names.singularPascal},
2109
2499
  } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
2110
2500
 
2111
- export type List${names.pluralPascal}Result = {
2112
- ${names.pluralCamel}: ${names.singularPascal}[];
2113
- total: number;
2114
- };
2501
+ export type List${names.pluralPascal}Result = PageResult<${names.singularPascal}, OffsetPageInfo>;
2115
2502
 
2116
2503
  export interface ${names.singularPascal}Repository {
2117
- list(input: List${names.pluralPascal}Input): Promise<List${names.pluralPascal}Result>;
2504
+ list(page: OffsetPage): Promise<List${names.pluralPascal}Result>;
2118
2505
  create(input: Create${names.singularPascal}Input): Promise<${names.singularPascal}>;
2119
2506
  }
2120
2507
  `;
2121
2508
  }
2122
2509
  function inMemoryRepositoryFile(names, config) {
2123
2510
  const repositoryPortPath = resourcePortFilePath(names, config);
2124
- return `import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
2511
+ return `import { offsetPageResult } from "@beignet/core/pagination";
2512
+ import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
2125
2513
  import type {
2126
2514
  Create${names.singularPascal}Input,
2127
- List${names.pluralPascal}Input,
2128
2515
  ${names.singularPascal},
2129
2516
  } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
2130
2517
 
@@ -2134,18 +2521,16 @@ export function createInMemory${names.singularPascal}Repository(
2134
2521
  const ${names.pluralCamel} = new Map(seed.map((${names.singularCamel}) => [${names.singularCamel}.id, ${names.singularCamel}]));
2135
2522
 
2136
2523
  return {
2137
- async list(input: List${names.pluralPascal}Input) {
2524
+ async list(page) {
2138
2525
  const all${names.pluralPascal} = Array.from(${names.pluralCamel}.values()).sort(
2139
2526
  (left, right) => right.createdAt.localeCompare(left.createdAt),
2140
2527
  );
2141
2528
 
2142
- return {
2143
- ${names.pluralCamel}: all${names.pluralPascal}.slice(
2144
- input.offset,
2145
- input.offset + input.limit,
2146
- ),
2147
- total: all${names.pluralPascal}.length,
2148
- };
2529
+ return offsetPageResult(
2530
+ all${names.pluralPascal}.slice(page.offset, page.offset + page.limit),
2531
+ page,
2532
+ all${names.pluralPascal}.length,
2533
+ );
2149
2534
  },
2150
2535
  async create(input: Create${names.singularPascal}Input) {
2151
2536
  const ${names.singularCamel}: ${names.singularPascal} = {
@@ -2173,12 +2558,12 @@ export const ${names.pluralCamel} = sqliteTable("${names.pluralKebab.replaceAll(
2173
2558
  function drizzleRepositoryFile(names, config) {
2174
2559
  const repositoryPortPath = resourcePortFilePath(names, config);
2175
2560
  const schemaPath = drizzleSchemaIndexPath(config);
2176
- return `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
2561
+ return `import { offsetPageResult } from "@beignet/core/pagination";
2562
+ import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
2177
2563
  import { count, desc } from "drizzle-orm";
2178
2564
  import type { ${names.singularPascal}Repository } from "${aliasModule(repositoryPortPath)}";
2179
2565
  import type {
2180
2566
  Create${names.singularPascal}Input,
2181
- List${names.pluralPascal}Input,
2182
2567
  ${names.singularPascal},
2183
2568
  } from "${aliasModule(path.join(resourceUseCaseDir(names, config), "schemas.ts"))}";
2184
2569
  import * as schema from "${aliasModule(schemaPath)}";
@@ -2197,21 +2582,18 @@ export function createDrizzle${names.singularPascal}Repository(
2197
2582
  db: DrizzleTursoDatabase<typeof schema>,
2198
2583
  ): ${names.singularPascal}Repository {
2199
2584
  return {
2200
- async list(input: List${names.pluralPascal}Input) {
2585
+ async list(page) {
2201
2586
  const rows = await db
2202
2587
  .select()
2203
2588
  .from(schema.${names.pluralCamel})
2204
2589
  .orderBy(desc(schema.${names.pluralCamel}.createdAt))
2205
- .limit(input.limit)
2206
- .offset(input.offset);
2590
+ .limit(page.limit)
2591
+ .offset(page.offset);
2207
2592
  const [{ total }] = await db
2208
2593
  .select({ total: count() })
2209
2594
  .from(schema.${names.pluralCamel});
2210
2595
 
2211
- return {
2212
- ${names.pluralCamel}: rows.map(to${names.singularPascal}),
2213
- total,
2214
- };
2596
+ return offsetPageResult(rows.map(to${names.singularPascal}), page, total);
2215
2597
  },
2216
2598
  async create(input: Create${names.singularPascal}Input) {
2217
2599
  const [row] = await db
@@ -2299,10 +2681,14 @@ export const list${names.pluralPascal} = ${names.pluralCamel}
2299
2681
  )
2300
2682
  .responses({
2301
2683
  200: z.object({
2302
- ${names.pluralCamel}: z.array(${names.singularCamel}Schema),
2303
- total: z.number().int().min(0),
2304
- limit: z.number().int().min(1),
2305
- offset: z.number().int().min(0),
2684
+ items: z.array(${names.singularCamel}Schema),
2685
+ page: z.object({
2686
+ kind: z.literal("offset"),
2687
+ limit: z.number().int().min(1),
2688
+ offset: z.number().int().min(0),
2689
+ total: z.number().int().min(0),
2690
+ hasMore: z.boolean(),
2691
+ }),
2306
2692
  }),
2307
2693
  });
2308
2694
 
@@ -2349,7 +2735,7 @@ describe("${names.exportName}", () => {
2349
2735
  ...appPorts,
2350
2736
  uow: createNoopUnitOfWork(
2351
2737
  () => appPorts as unknown as AppContext["ports"],
2352
- ) as AppContext["ports"]["uow"],
2738
+ ) as unknown as AppContext["ports"]["uow"],
2353
2739
  devtools: createInMemoryDevtools(),
2354
2740
  storage: createMemoryStorage(),
2355
2741
  } as AppContext["ports"];
@@ -2437,7 +2823,7 @@ export const ${names.eventExportName} = defineEvent("${names.eventName}", {
2437
2823
  `;
2438
2824
  }
2439
2825
  function jobFile(names, config) {
2440
- return `import { createJobHandlers } from "@beignet/core/jobs";
2826
+ return `import { createJobHandlers, retry } from "@beignet/core/jobs";
2441
2827
  import { z } from "zod";
2442
2828
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2443
2829
 
@@ -2451,9 +2837,11 @@ export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}
2451
2837
 
2452
2838
  export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
2453
2839
  \tpayload: ${names.payloadSchemaName},
2454
- \tretry: {
2840
+ \tretry: retry.exponential({
2455
2841
  \t\tattempts: 3,
2456
- \t},
2842
+ \t\tinitialDelay: "1s",
2843
+ \t\tmaxDelay: "1m",
2844
+ \t}),
2457
2845
  \tasync handle({ payload, ctx }) {
2458
2846
  \t\tctx.ports.logger.info("Job handled", {
2459
2847
  \t\t\tjobName: "${names.jobName}",
@@ -2463,6 +2851,67 @@ export const ${names.jobExportName} = jobs.defineJob("${names.jobName}", {
2463
2851
  });
2464
2852
  `;
2465
2853
  }
2854
+ function factoryFile(names, config) {
2855
+ const resource = featureResourceNames(names);
2856
+ return `import { defineFactory } from "@beignet/core/testing";
2857
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2858
+
2859
+ export const ${names.factoryExportName} = defineFactory("${names.factoryName}", {
2860
+ \tdefaults: ({ sequence }) => ({
2861
+ \t\tname: "${resource.singularPascal} " + sequence,
2862
+ \t}),
2863
+ \tpersist: (ctx: AppContext, input) => ctx.ports.${resource.pluralCamel}.create(input),
2864
+ });
2865
+ `;
2866
+ }
2867
+ function seedFile(names, config) {
2868
+ const resource = featureResourceNames(names);
2869
+ return `import { defineSeed } from "@beignet/core/testing";
2870
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2871
+
2872
+ export const ${names.seedExportName} = defineSeed("${names.seedName}", {
2873
+ \tdescription: "Creates starter ${names.feature.kebab} data.",
2874
+ \trun: async (ctx: AppContext) => {
2875
+ \t\tawait ctx.ports.${resource.pluralCamel}.create({
2876
+ \t\t\tname: "Demo ${resource.singularPascal}",
2877
+ \t\t});
2878
+ \t},
2879
+ });
2880
+ `;
2881
+ }
2882
+ function notificationFile(names, config) {
2883
+ return `import {
2884
+ \tcreateNotificationHandlers,
2885
+ \tdefineMailNotificationChannel,
2886
+ } from "@beignet/core/notifications";
2887
+ import { z } from "zod";
2888
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2889
+
2890
+ const notifications = createNotificationHandlers<AppContext>();
2891
+
2892
+ export const ${names.payloadSchemaName} = z.object({
2893
+ \tid: z.string().uuid(),
2894
+ \tsubject: z.string(),
2895
+ \trecipientEmail: z.string().email(),
2896
+ });
2897
+
2898
+ export type ${names.payloadTypeName} = z.infer<typeof ${names.payloadSchemaName}>;
2899
+
2900
+ export const ${names.notificationExportName} = notifications.defineNotification(
2901
+ \t"${names.notificationName}",
2902
+ \t{
2903
+ \t\tpayload: ${names.payloadSchemaName},
2904
+ \t\tchannels: {
2905
+ \t\t\temail: defineMailNotificationChannel(({ payload }) => ({
2906
+ \t\t\t\tto: payload.recipientEmail,
2907
+ \t\t\t\tsubject: payload.subject,
2908
+ \t\t\t\ttext: \`${names.notificationName} notification for \${payload.id}.\`,
2909
+ \t\t\t})),
2910
+ \t\t},
2911
+ \t},
2912
+ );
2913
+ `;
2914
+ }
2466
2915
  function listenerFile(names, config) {
2467
2916
  const filePath = listenerFilePath(names, config);
2468
2917
  return `import { createEventHandlers } from "@beignet/core/events";
@@ -2517,6 +2966,60 @@ ${names.timezone ? `\t\ttimezone: "${names.timezone}",\n` : ""}\t\tpayload: ${na
2517
2966
  );
2518
2967
  `;
2519
2968
  }
2969
+ function uploadFile(names, config) {
2970
+ return `import { defineUpload } from "@beignet/core/uploads";
2971
+ import { z } from "zod";
2972
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2973
+
2974
+ export const ${names.metadataSchemaName} = z.object({
2975
+ \tresourceId: z.string().min(1),
2976
+ });
2977
+
2978
+ export type ${names.metadataTypeName} = z.infer<typeof ${names.metadataSchemaName}>;
2979
+
2980
+ export const ${names.uploadExportName} = defineUpload<
2981
+ \t"${names.uploadName}",
2982
+ \ttypeof ${names.metadataSchemaName},
2983
+ \tAppContext,
2984
+ \t{ objectKeys: string[] }
2985
+ >("${names.uploadName}", {
2986
+ \tmetadata: ${names.metadataSchemaName},
2987
+ \tfile: {
2988
+ \t\tcontentTypes: ["application/pdf", "text/plain"],
2989
+ \t\tmaxSizeBytes: 5 * 1024 * 1024,
2990
+ \t\tmaxFiles: 1,
2991
+ \t\tvisibility: "private",
2992
+ \t\tcacheControl: "private, max-age=0",
2993
+ \t},
2994
+ \tauthorize({ ctx }) {
2995
+ \t\treturn ctx.actor.type === "user";
2996
+ \t},
2997
+ \tkey({ ctx, metadata, uploadId, file }) {
2998
+ \t\tconst tenantId = ctx.tenant?.id ?? "default";
2999
+ \t\tconst extension = file.name.includes(".") ? file.name.split(".").pop() : undefined;
3000
+ \t\tconst suffix = extension ? \`.\${extension}\` : "";
3001
+ \t\treturn "${names.feature.kebab}/" + tenantId + "/" + metadata.resourceId + "/" + uploadId + suffix;
3002
+ \t},
3003
+ \tstorageMetadata({ ctx, metadata }) {
3004
+ \t\treturn {
3005
+ \t\t\ttenantId: ctx.tenant?.id ?? "default",
3006
+ \t\t\tresourceId: metadata.resourceId,
3007
+ \t\t};
3008
+ \t},
3009
+ \tasync onComplete({ ctx, metadata, files }) {
3010
+ \t\tctx.ports.logger.info("Upload completed", {
3011
+ \t\t\tuploadName: "${names.uploadName}",
3012
+ \t\t\tresourceId: metadata.resourceId,
3013
+ \t\t\tobjectKeys: files.map((file) => file.key),
3014
+ \t\t});
3015
+
3016
+ \t\treturn {
3017
+ \t\t\tobjectKeys: files.map((file) => file.key),
3018
+ \t\t};
3019
+ \t},
3020
+ });
3021
+ `;
3022
+ }
2520
3023
  function scheduleRouteFile(names, config) {
2521
3024
  return `import { createInlineScheduleRunner } from "@beignet/core/schedules";
2522
3025
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
@@ -2665,11 +3168,15 @@ function testFile(names, config) {
2665
3168
  const repositoryPath = path.join(path.dirname(config.paths.infrastructurePorts), names.pluralKebab, `in-memory-${names.singularKebab}-repository.ts`);
2666
3169
  return `import { describe, expect, it } from "bun:test";
2667
3170
  import { createUseCaseTester } from "@beignet/core/application";
3171
+ import { defineRoutes } from "@beignet/web";
3172
+ import { createTestApp } from "@beignet/web/testing";
2668
3173
  import { createInMemoryDevtools } from "@beignet/devtools";
2669
3174
  import { createAnonymousActor, createMemoryStorage, createNoopUnitOfWork } from "@beignet/core/ports";
2670
3175
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
2671
3176
  import { appPorts } from "${aliasModule(config.paths.infrastructurePorts)}";
3177
+ import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(resourceContractFilePath(names, config))}";
2672
3178
  import { createInMemory${names.singularPascal}Repository } from "${aliasModule(repositoryPath)}";
3179
+ import { ${names.singularCamel}Routes } from "${aliasModule(path.join(resourceFeatureDir(names, config), "routes.ts"))}";
2673
3180
  import {
2674
3181
  create${names.singularPascal}UseCase,
2675
3182
  list${names.pluralPascal}UseCase,
@@ -2684,18 +3191,19 @@ describe("${names.pluralCamel} resource", () => {
2684
3191
  uow: createNoopUnitOfWork(() => ({
2685
3192
  ...(appPorts as unknown as AppContext["ports"]),
2686
3193
  ${names.pluralCamel},
2687
- })) as AppContext["ports"]["uow"],
3194
+ })) as unknown as AppContext["ports"]["uow"],
2688
3195
  devtools: createInMemoryDevtools(),
2689
3196
  storage: createMemoryStorage(),
2690
3197
  } as AppContext["ports"];
2691
3198
  const actor = createAnonymousActor();
2692
- const tester = createUseCaseTester<AppContext>(() => ({
3199
+ const createTestContext = () => ({
2693
3200
  requestId: "test-request",
2694
3201
  actor,
2695
3202
  auth: null,
2696
3203
  gate: testPorts.gate.bind({ actor, auth: null }),
2697
3204
  ports: testPorts,
2698
- }));
3205
+ });
3206
+ const tester = createUseCaseTester<AppContext>(createTestContext);
2699
3207
 
2700
3208
  const ctx = await tester.ctx();
2701
3209
  const created = await tester.run(
@@ -2710,8 +3218,30 @@ describe("${names.pluralCamel} resource", () => {
2710
3218
  );
2711
3219
 
2712
3220
  expect(created.name).toBe("First ${names.singularPascal}");
2713
- expect(result.total).toBe(1);
2714
- expect(result.${names.pluralCamel}).toEqual([created]);
3221
+ expect(result.page.total).toBe(1);
3222
+ expect(result.items).toEqual([created]);
3223
+
3224
+ const app = await createTestApp<AppContext, AppContext["ports"]>({
3225
+ ports: testPorts,
3226
+ 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
+ }),
3232
+ });
3233
+
3234
+ const createdViaRoute = await app.request(create${names.singularPascal}, {
3235
+ body: { name: "Second ${names.singularPascal}" },
3236
+ });
3237
+ const listedViaRoute = await app.request(list${names.pluralPascal}, {
3238
+ query: { limit: 20, offset: 0 },
3239
+ });
3240
+ await app.stop();
3241
+
3242
+ expect(createdViaRoute.name).toBe("Second ${names.singularPascal}");
3243
+ expect(listedViaRoute.page.total).toBe(2);
3244
+ expect(listedViaRoute.items).toContainEqual(createdViaRoute);
2715
3245
  });
2716
3246
  });
2717
3247
  `;