@beignet/cli 0.0.15 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/make.ts CHANGED
@@ -37,10 +37,14 @@ export { makeFeatureAddonChoices } from "./choices.js";
37
37
 
38
38
  type NormalizedMakeFeatureAddon =
39
39
  | "policy"
40
+ | "factory"
40
41
  | "task"
41
42
  | "event"
42
43
  | "job"
44
+ | "listener"
43
45
  | "notification"
46
+ | "schedule"
47
+ | "seed"
44
48
  | "ui"
45
49
  | "upload";
46
50
 
@@ -126,6 +130,7 @@ type MakeFactoryOptions = {
126
130
  force?: boolean;
127
131
  dryRun?: boolean;
128
132
  config?: BeignetConfig;
133
+ skipPersistenceAssert?: boolean;
129
134
  };
130
135
 
131
136
  type MakeSeedOptions = {
@@ -134,6 +139,7 @@ type MakeSeedOptions = {
134
139
  force?: boolean;
135
140
  dryRun?: boolean;
136
141
  config?: BeignetConfig;
142
+ skipPersistenceAssert?: boolean;
137
143
  };
138
144
 
139
145
  type MakeNotificationOptions = {
@@ -151,6 +157,7 @@ type MakeListenerOptions = {
151
157
  force?: boolean;
152
158
  dryRun?: boolean;
153
159
  config?: BeignetConfig;
160
+ skipEventAssert?: boolean;
154
161
  };
155
162
 
156
163
  type MakeScheduleOptions = {
@@ -172,6 +179,13 @@ type MakeUploadOptions = {
172
179
  config?: BeignetConfig;
173
180
  };
174
181
 
182
+ type MakeOutboxOptions = {
183
+ cwd?: string;
184
+ force?: boolean;
185
+ dryRun?: boolean;
186
+ config?: BeignetConfig;
187
+ };
188
+
175
189
  type MakePaymentsOptions = {
176
190
  cwd?: string;
177
191
  force?: boolean;
@@ -258,6 +272,10 @@ export type MakeScheduleResult = MakeResult;
258
272
  * Result returned by `beignet make upload`.
259
273
  */
260
274
  export type MakeUploadResult = MakeResult;
275
+ /**
276
+ * Result returned by `beignet make outbox`.
277
+ */
278
+ export type MakeOutboxResult = MakeResult;
261
279
  /**
262
280
  * Result returned by `beignet make payments`.
263
281
  */
@@ -742,6 +760,13 @@ async function makeFeatureAddon(
742
760
  if (addon === "policy") {
743
761
  return makePolicy({ ...shared, name: feature });
744
762
  }
763
+ if (addon === "factory") {
764
+ return makeFactory({
765
+ ...shared,
766
+ name: `${feature}/${singularize(feature)}`,
767
+ skipPersistenceAssert: true,
768
+ });
769
+ }
745
770
  if (addon === "task") {
746
771
  return makeTask({ ...shared, name: `${feature}/backfill` });
747
772
  }
@@ -751,9 +776,27 @@ async function makeFeatureAddon(
751
776
  if (addon === "job") {
752
777
  return makeJob({ ...shared, name: `${feature}/process` });
753
778
  }
779
+ if (addon === "listener") {
780
+ return makeListener({
781
+ ...shared,
782
+ name: `${feature}/handle-created`,
783
+ event: `${feature}/created`,
784
+ skipEventAssert: true,
785
+ });
786
+ }
754
787
  if (addon === "notification") {
755
788
  return makeNotification({ ...shared, name: `${feature}/created` });
756
789
  }
790
+ if (addon === "schedule") {
791
+ return makeSchedule({ ...shared, name: `${feature}/daily-summary` });
792
+ }
793
+ if (addon === "seed") {
794
+ return makeSeed({
795
+ ...shared,
796
+ name: `${feature}/demo-${feature}`,
797
+ skipPersistenceAssert: true,
798
+ });
799
+ }
757
800
  if (addon === "ui") {
758
801
  return makeFeatureUi({ ...shared, name: feature });
759
802
  }
@@ -767,12 +810,21 @@ function normalizeMakeFeatureAddons(
767
810
  const normalized = new Set<NormalizedMakeFeatureAddon>();
768
811
 
769
812
  for (const addon of addons) {
770
- if (addon === "events") normalized.add("event");
771
- else if (addon === "tasks") normalized.add("task");
813
+ if (addon === "factories") normalized.add("factory");
814
+ else if (addon === "events") normalized.add("event");
815
+ else if (addon === "listeners") {
816
+ normalized.add("event");
817
+ normalized.add("listener");
818
+ } else if (addon === "tasks") normalized.add("task");
772
819
  else if (addon === "jobs") normalized.add("job");
773
820
  else if (addon === "notifications") normalized.add("notification");
821
+ else if (addon === "schedules") normalized.add("schedule");
822
+ else if (addon === "seeds") normalized.add("seed");
774
823
  else if (addon === "uploads") normalized.add("upload");
775
- else normalized.add(addon);
824
+ else if (addon === "listener") {
825
+ normalized.add("event");
826
+ normalized.add("listener");
827
+ } else normalized.add(addon);
776
828
  }
777
829
 
778
830
  return featureAddonOrder.filter((addon) => normalized.has(addon));
@@ -780,10 +832,14 @@ function normalizeMakeFeatureAddons(
780
832
 
781
833
  const featureAddonOrder: readonly NormalizedMakeFeatureAddon[] = [
782
834
  "policy",
835
+ "factory",
783
836
  "task",
784
837
  "event",
785
838
  "job",
839
+ "listener",
786
840
  "notification",
841
+ "schedule",
842
+ "seed",
787
843
  "ui",
788
844
  "upload",
789
845
  ];
@@ -798,6 +854,22 @@ function mergeMakeResults(left: MakeResult, right: MakeResult): MakeResult {
798
854
  };
799
855
  }
800
856
 
857
+ function mergeMakeResultInto(target: MakeResult, source: MakeResult): void {
858
+ target.files = uniqueStrings([...target.files, ...source.files]);
859
+ target.createdFiles = uniqueStrings([
860
+ ...target.createdFiles,
861
+ ...source.createdFiles,
862
+ ]);
863
+ target.updatedFiles = uniqueStrings([
864
+ ...target.updatedFiles,
865
+ ...source.updatedFiles,
866
+ ]);
867
+ target.skippedFiles = uniqueStrings([
868
+ ...target.skippedFiles,
869
+ ...source.skippedFiles,
870
+ ]);
871
+ }
872
+
801
873
  function uniqueStrings(values: readonly string[]): string[] {
802
874
  return [...new Set(values)];
803
875
  }
@@ -1086,6 +1158,8 @@ export async function makeEvent(
1086
1158
  : await loadBeignetConfig(targetDir);
1087
1159
 
1088
1160
  await assertFeatureArtifactApp(targetDir, config, "event");
1161
+ await assertServerRuntimeApp(targetDir, config, "event");
1162
+ await updateOutboxPortWiring(targetDir, config, { dryRun: true });
1089
1163
 
1090
1164
  // Events are published through ctx.ports.eventBus, so the generator wires
1091
1165
  // the port and an in-memory dev provider when the app has no bus yet. Plan
@@ -1095,6 +1169,9 @@ export async function makeEvent(
1095
1169
  await wirePortProviders(targetDir, config, eventBusWirings, {
1096
1170
  dryRun: true,
1097
1171
  });
1172
+ const outboxDrainPlan = await planOutboxDrainWiring(targetDir, config, {
1173
+ force: Boolean(options.force),
1174
+ });
1098
1175
 
1099
1176
  const result = await makeFeatureArtifact({
1100
1177
  targetDir,
@@ -1112,6 +1189,12 @@ export async function makeEvent(
1112
1189
  await applyOutboxRegistryUpdate(result, targetDir, names, "events", config, {
1113
1190
  dryRun: Boolean(options.dryRun),
1114
1191
  });
1192
+ await applyOutboxDrainWiring(result, targetDir, outboxDrainPlan, {
1193
+ dryRun: Boolean(options.dryRun),
1194
+ });
1195
+ await applyOutboxPortWiring(result, targetDir, config, {
1196
+ dryRun: Boolean(options.dryRun),
1197
+ });
1115
1198
 
1116
1199
  await applyPortProviderWiring(result, targetDir, config, eventBusWirings, {
1117
1200
  dryRun: Boolean(options.dryRun),
@@ -1128,6 +1211,11 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
1128
1211
  : await loadBeignetConfig(targetDir);
1129
1212
 
1130
1213
  await assertFeatureArtifactApp(targetDir, config, "job");
1214
+ await assertServerRuntimeApp(targetDir, config, "job");
1215
+ await updateOutboxPortWiring(targetDir, config, { dryRun: true });
1216
+ const outboxDrainPlan = await planOutboxDrainWiring(targetDir, config, {
1217
+ force: Boolean(options.force),
1218
+ });
1131
1219
 
1132
1220
  const result = await makeFeatureArtifact({
1133
1221
  targetDir,
@@ -1148,6 +1236,12 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
1148
1236
  await applyOutboxRegistryUpdate(result, targetDir, names, "jobs", config, {
1149
1237
  dryRun: Boolean(options.dryRun),
1150
1238
  });
1239
+ await applyOutboxDrainWiring(result, targetDir, outboxDrainPlan, {
1240
+ dryRun: Boolean(options.dryRun),
1241
+ });
1242
+ await applyOutboxPortWiring(result, targetDir, config, {
1243
+ dryRun: Boolean(options.dryRun),
1244
+ });
1151
1245
 
1152
1246
  return result;
1153
1247
  }
@@ -1204,7 +1298,9 @@ export async function makeFactory(
1204
1298
  ? resolveConfig(options.config)
1205
1299
  : await loadBeignetConfig(targetDir);
1206
1300
 
1207
- await assertPersistenceArtifactApp(targetDir, names, config, "factory");
1301
+ if (!options.skipPersistenceAssert) {
1302
+ await assertPersistenceArtifactApp(targetDir, names, config, "factory");
1303
+ }
1208
1304
 
1209
1305
  return makeFeatureArtifact({
1210
1306
  targetDir,
@@ -1229,9 +1325,11 @@ export async function makeSeed(
1229
1325
  ? resolveConfig(options.config)
1230
1326
  : await loadBeignetConfig(targetDir);
1231
1327
 
1232
- await assertPersistenceArtifactApp(targetDir, names, config, "seed");
1328
+ if (!options.skipPersistenceAssert) {
1329
+ await assertPersistenceArtifactApp(targetDir, names, config, "seed");
1330
+ }
1233
1331
 
1234
- return makeFeatureArtifact({
1332
+ const result = await makeFeatureArtifact({
1235
1333
  targetDir,
1236
1334
  config,
1237
1335
  force: Boolean(options.force),
@@ -1243,6 +1341,57 @@ export async function makeSeed(
1243
1341
  "@beignet/core": beignetDependencyVersion,
1244
1342
  },
1245
1343
  });
1344
+
1345
+ const factoryNamesForSeed = defaultFactoryNamesForSeed(names);
1346
+ if (
1347
+ !(await fileExists(
1348
+ path.join(targetDir, factoryFilePath(factoryNamesForSeed, config)),
1349
+ ))
1350
+ ) {
1351
+ const factoryResult = await makeFeatureArtifact({
1352
+ targetDir,
1353
+ config,
1354
+ force: Boolean(options.force),
1355
+ dryRun: Boolean(options.dryRun),
1356
+ name: `${factoryNamesForSeed.feature.kebab}/${factoryNamesForSeed.artifact.kebab}`,
1357
+ files: factoryFiles(factoryNamesForSeed, config),
1358
+ index: featureArtifactIndexFile("factory", factoryNamesForSeed, config),
1359
+ dependencies: {
1360
+ "@beignet/core": beignetDependencyVersion,
1361
+ },
1362
+ });
1363
+ mergeMakeResultInto(result, factoryResult);
1364
+ }
1365
+
1366
+ const seedEntrypointResult = await updateSeedEntrypoint(
1367
+ targetDir,
1368
+ names,
1369
+ config,
1370
+ { dryRun: Boolean(options.dryRun) },
1371
+ );
1372
+ if (seedEntrypointResult === "created")
1373
+ result.createdFiles.push(seedEntrypointPath(config));
1374
+ if (seedEntrypointResult === "updated")
1375
+ result.updatedFiles.push(seedEntrypointPath(config));
1376
+ if (seedEntrypointResult === "skipped")
1377
+ result.skippedFiles.push(seedEntrypointPath(config));
1378
+ if (!result.files.includes(seedEntrypointPath(config))) {
1379
+ result.files.push(seedEntrypointPath(config));
1380
+ }
1381
+
1382
+ if (
1383
+ await updatePackageScripts(
1384
+ targetDir,
1385
+ { "db:seed": `bun ${seedEntrypointPath(config)}` },
1386
+ { dryRun: Boolean(options.dryRun) },
1387
+ )
1388
+ ) {
1389
+ result.updatedFiles.push("package.json");
1390
+ if (!result.files.includes("package.json"))
1391
+ result.files.push("package.json");
1392
+ }
1393
+
1394
+ return result;
1246
1395
  }
1247
1396
 
1248
1397
  export async function makeNotification(
@@ -1309,7 +1458,9 @@ export async function makeListener(
1309
1458
  : await loadBeignetConfig(targetDir);
1310
1459
 
1311
1460
  await assertFeatureArtifactApp(targetDir, config, "listener");
1312
- await assertListenerEventExists(targetDir, names.event, config);
1461
+ if (!options.skipEventAssert) {
1462
+ await assertListenerEventExists(targetDir, names.event, config);
1463
+ }
1313
1464
 
1314
1465
  return makeFeatureArtifact({
1315
1466
  targetDir,
@@ -1518,6 +1669,253 @@ async function applyCronSecretWiring(
1518
1669
  };
1519
1670
  }
1520
1671
 
1672
+ type OutboxDrainWiringPlan = {
1673
+ route: PlannedGeneratedFile;
1674
+ cronSecret: CronSecretWiringPlan;
1675
+ };
1676
+
1677
+ async function planOutboxDrainWiring(
1678
+ targetDir: string,
1679
+ config: ResolvedBeignetConfig,
1680
+ options: { force: boolean },
1681
+ ): Promise<OutboxDrainWiringPlan | undefined> {
1682
+ if (await fileExists(path.join(targetDir, outboxDrainRoutePath(config)))) {
1683
+ return undefined;
1684
+ }
1685
+
1686
+ const route = await planGeneratedFile(
1687
+ targetDir,
1688
+ {
1689
+ path: outboxDrainRoutePath(config),
1690
+ content: outboxDrainRouteFile(config),
1691
+ },
1692
+ { force: options.force },
1693
+ );
1694
+
1695
+ return {
1696
+ route,
1697
+ cronSecret: await planCronSecretWiring(targetDir),
1698
+ };
1699
+ }
1700
+
1701
+ async function updateOutboxPortWiring(
1702
+ targetDir: string,
1703
+ config: ResolvedBeignetConfig,
1704
+ options: { dryRun: boolean },
1705
+ ): Promise<string[]> {
1706
+ const updated = new Set<string>();
1707
+
1708
+ if (await updateOutboxPorts(targetDir, config, options)) {
1709
+ updated.add(config.paths.ports);
1710
+ }
1711
+ if (await updateOutboxInfrastructurePorts(targetDir, config, options)) {
1712
+ updated.add(config.paths.infrastructurePorts);
1713
+ }
1714
+ if (await updateOutboxDatabaseProvider(targetDir, config, options)) {
1715
+ updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
1716
+ }
1717
+ if (await updateOutboxRepositoriesReturnType(targetDir, config, options)) {
1718
+ updated.add(drizzleRepositoriesPath(config));
1719
+ }
1720
+
1721
+ return [...updated];
1722
+ }
1723
+
1724
+ async function applyOutboxPortWiring(
1725
+ result: MakeResult,
1726
+ targetDir: string,
1727
+ config: ResolvedBeignetConfig,
1728
+ options: { dryRun: boolean },
1729
+ ): Promise<void> {
1730
+ const updatedFiles = await updateOutboxPortWiring(targetDir, config, options);
1731
+ for (const file of updatedFiles) {
1732
+ if (!result.updatedFiles.includes(file)) result.updatedFiles.push(file);
1733
+ if (!result.files.includes(file)) result.files.push(file);
1734
+ }
1735
+ }
1736
+
1737
+ async function updateOutboxPorts(
1738
+ targetDir: string,
1739
+ config: ResolvedBeignetConfig,
1740
+ options: { dryRun: boolean },
1741
+ ): Promise<boolean> {
1742
+ const filePath = path.join(targetDir, config.paths.ports);
1743
+ const original = await readFile(filePath, "utf8");
1744
+ let next = addNamedTypeImport(original, "OutboxPort", "@beignet/core/outbox");
1745
+ if (!typeHasTopLevelProperty(next, "AppPorts", "outbox")) {
1746
+ next = insertTypeProperty(
1747
+ next,
1748
+ "AppPorts",
1749
+ "outbox: OutboxPort;",
1750
+ `Could not find AppPorts in ${config.paths.ports}. Add outbox: OutboxPort; manually, or restore the generated ports file before running make outbox.`,
1751
+ );
1752
+ }
1753
+ if (!typeHasTopLevelProperty(next, "AppTransactionPorts", "outbox")) {
1754
+ next = insertTypeProperty(
1755
+ next,
1756
+ "AppTransactionPorts",
1757
+ "outbox: OutboxPort;",
1758
+ `Could not find AppTransactionPorts in ${config.paths.ports}. Add outbox: OutboxPort; manually, or restore the generated ports file before running make outbox.`,
1759
+ );
1760
+ }
1761
+
1762
+ if (next === original) return false;
1763
+ if (!options.dryRun) await writeFile(filePath, next);
1764
+ return true;
1765
+ }
1766
+
1767
+ async function updateOutboxInfrastructurePorts(
1768
+ targetDir: string,
1769
+ config: ResolvedBeignetConfig,
1770
+ options: { dryRun: boolean },
1771
+ ): Promise<boolean> {
1772
+ const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1773
+ const original = await readFile(filePath, "utf8");
1774
+ const next = appendDeferredPortKey(original, "outbox");
1775
+
1776
+ if (next === original) return false;
1777
+ if (!options.dryRun) await writeFile(filePath, next);
1778
+ return true;
1779
+ }
1780
+
1781
+ async function updateOutboxDatabaseProvider(
1782
+ targetDir: string,
1783
+ config: ResolvedBeignetConfig,
1784
+ options: { dryRun: boolean },
1785
+ ): Promise<boolean> {
1786
+ const file = path.join(infrastructureDir(config), "db/provider.ts");
1787
+ const filePath = path.join(targetDir, file);
1788
+ if (!(await fileExists(filePath))) return false;
1789
+
1790
+ const database = await detectResourceDatabase(targetDir, config);
1791
+ const factoryName = drizzleOutboxPortFactoryName(database);
1792
+ const original = await readFile(filePath, "utf8");
1793
+ let next = addNamedImport(
1794
+ original,
1795
+ factoryName,
1796
+ `@beignet/provider-db-drizzle/${database}`,
1797
+ );
1798
+ next = appendPickStringLiteralMember(next, "AppPorts", "outbox");
1799
+
1800
+ if (!/\bconst\s+outbox\s*=/.test(next)) {
1801
+ const beforeOutboxConst = next;
1802
+ next = beforeOutboxConst.replace(
1803
+ /(\n\s*const idempotency = createDrizzle[A-Za-z]+IdempotencyPort\(dbPort\.db\);)/,
1804
+ `$1\n\t\tconst outbox = ${factoryName}(dbPort.db);`,
1805
+ );
1806
+ if (next === beforeOutboxConst) {
1807
+ throw new Error(
1808
+ `Could not find the idempotency port in ${file}. Add const outbox = ${factoryName}(dbPort.db) manually, or restore the generated database provider before running make outbox.`,
1809
+ );
1810
+ }
1811
+ }
1812
+
1813
+ if (!/\boutbox\s*,/.test(next)) {
1814
+ const withOutboxPort = next.replace(
1815
+ /(\n\s*idempotency,\n)/,
1816
+ `$1\t\t\toutbox,\n`,
1817
+ );
1818
+ if (withOutboxPort === next) {
1819
+ throw new Error(
1820
+ `Could not find idempotency in providedPorts in ${file}. Add outbox to providedPorts manually, or restore the generated database provider before running make outbox.`,
1821
+ );
1822
+ }
1823
+ next = withOutboxPort;
1824
+ }
1825
+
1826
+ if (!new RegExp(`outbox:\\s*${factoryName}\\(tx\\)`).test(next)) {
1827
+ const withTransactionOutbox = next.replace(
1828
+ /(\n\s*idempotency: createDrizzle[A-Za-z]+IdempotencyPort\(tx\),\n)/,
1829
+ `$1\t\t\t\t\toutbox: ${factoryName}(tx),\n`,
1830
+ );
1831
+ if (withTransactionOutbox === next) {
1832
+ throw new Error(
1833
+ `Could not find transaction idempotency wiring in ${file}. Add outbox: ${factoryName}(tx) manually, or restore the generated database provider before running make outbox.`,
1834
+ );
1835
+ }
1836
+ next = withTransactionOutbox;
1837
+ }
1838
+
1839
+ if (next === original) return false;
1840
+ if (!options.dryRun) await writeFile(filePath, next);
1841
+ return true;
1842
+ }
1843
+
1844
+ async function updateOutboxRepositoriesReturnType(
1845
+ targetDir: string,
1846
+ config: ResolvedBeignetConfig,
1847
+ options: { dryRun: boolean },
1848
+ ): Promise<boolean> {
1849
+ const file = drizzleRepositoriesPath(config);
1850
+ const filePath = path.join(targetDir, file);
1851
+ if (!(await fileExists(filePath))) return false;
1852
+
1853
+ const original = await readFile(filePath, "utf8");
1854
+ const next = original.replace(
1855
+ /Omit<AppTransactionPorts,\s*"idempotency"\s*>/g,
1856
+ 'Omit<AppTransactionPorts, "idempotency" | "outbox">',
1857
+ );
1858
+
1859
+ if (next === original) return false;
1860
+ if (!options.dryRun) await writeFile(filePath, next);
1861
+ return true;
1862
+ }
1863
+
1864
+ function drizzleOutboxPortFactoryName(database: DatabaseName): string {
1865
+ if (database === "postgres") return "createDrizzlePostgresOutboxPort";
1866
+ if (database === "mysql") return "createDrizzleMysqlOutboxPort";
1867
+ return "createDrizzleSqliteOutboxPort";
1868
+ }
1869
+
1870
+ async function applyOutboxDrainWiring(
1871
+ result: MakeResult,
1872
+ targetDir: string,
1873
+ plan: OutboxDrainWiringPlan | undefined,
1874
+ options: { dryRun: boolean },
1875
+ ): Promise<void> {
1876
+ if (!plan) return;
1877
+
1878
+ if (!options.dryRun) {
1879
+ await writePlannedGeneratedFile(targetDir, plan.route);
1880
+ }
1881
+
1882
+ if (plan.route.result === "created") {
1883
+ result.createdFiles.push(plan.route.path);
1884
+ }
1885
+ if (plan.route.result === "updated") {
1886
+ result.updatedFiles.push(plan.route.path);
1887
+ }
1888
+ if (plan.route.result === "skipped") {
1889
+ result.skippedFiles.push(plan.route.path);
1890
+ }
1891
+ if (!result.files.includes(plan.route.path)) {
1892
+ result.files.push(plan.route.path);
1893
+ }
1894
+
1895
+ const cronSecretResult = await applyCronSecretWiring(
1896
+ targetDir,
1897
+ plan.cronSecret,
1898
+ options,
1899
+ );
1900
+ result.createdFiles = uniqueStrings([
1901
+ ...result.createdFiles,
1902
+ ...cronSecretResult.createdFiles,
1903
+ ]);
1904
+ result.updatedFiles = uniqueStrings([
1905
+ ...result.updatedFiles,
1906
+ ...cronSecretResult.updatedFiles,
1907
+ ]);
1908
+ result.skippedFiles = uniqueStrings([
1909
+ ...result.skippedFiles,
1910
+ ...cronSecretResult.skippedFiles,
1911
+ ]);
1912
+ result.files = uniqueStrings([
1913
+ ...result.files,
1914
+ ...plan.cronSecret.files.map((file) => file.path),
1915
+ ...plan.cronSecret.skippedFiles,
1916
+ ]);
1917
+ }
1918
+
1521
1919
  /**
1522
1920
  * A provider-backed app port a generator depends on.
1523
1921
  *
@@ -1588,6 +1986,18 @@ function paymentsPortWiring(command: string): PortProviderWiring {
1588
1986
  };
1589
1987
  }
1590
1988
 
1989
+ function storagePortWiring(command: string): PortProviderWiring {
1990
+ return {
1991
+ command,
1992
+ portKey: "storage",
1993
+ portType: "StoragePort",
1994
+ portTypeModule: "@beignet/core/ports",
1995
+ providerFactory: "createLocalStorageProvider",
1996
+ providerModule: "@beignet/provider-storage-local",
1997
+ dependency: "@beignet/provider-storage-local",
1998
+ };
1999
+ }
2000
+
1591
2001
  function providersFilePath(config: ResolvedBeignetConfig): string {
1592
2002
  return `${directoryPath(path.dirname(config.paths.server))}/providers.ts`;
1593
2003
  }
@@ -1770,7 +2180,16 @@ export async function makeUpload(
1770
2180
 
1771
2181
  await assertFeatureArtifactApp(targetDir, config, "upload");
1772
2182
 
1773
- return makeFeatureArtifact({
2183
+ await wirePortProviders(
2184
+ targetDir,
2185
+ config,
2186
+ [storagePortWiring("make upload")],
2187
+ {
2188
+ dryRun: true,
2189
+ },
2190
+ );
2191
+
2192
+ const result = await makeFeatureArtifact({
1774
2193
  targetDir,
1775
2194
  config,
1776
2195
  force: Boolean(options.force),
@@ -1782,6 +2201,95 @@ export async function makeUpload(
1782
2201
  "@beignet/core": beignetDependencyVersion,
1783
2202
  },
1784
2203
  });
2204
+
2205
+ await applyPortProviderWiring(
2206
+ result,
2207
+ targetDir,
2208
+ config,
2209
+ [storagePortWiring("make upload")],
2210
+ { dryRun: Boolean(options.dryRun) },
2211
+ );
2212
+
2213
+ const uploadRouteResult = await updateUploadRoute(targetDir, names, config, {
2214
+ dryRun: Boolean(options.dryRun),
2215
+ });
2216
+ if (uploadRouteResult === "created")
2217
+ result.createdFiles.push(uploadRoutePath(config));
2218
+ if (uploadRouteResult === "updated")
2219
+ result.updatedFiles.push(uploadRoutePath(config));
2220
+ if (uploadRouteResult === "skipped")
2221
+ result.skippedFiles.push(uploadRoutePath(config));
2222
+ if (!result.files.includes(uploadRoutePath(config))) {
2223
+ result.files.push(uploadRoutePath(config));
2224
+ }
2225
+
2226
+ return result;
2227
+ }
2228
+
2229
+ export async function makeOutbox(
2230
+ options: MakeOutboxOptions = {},
2231
+ ): Promise<MakeOutboxResult> {
2232
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
2233
+ const config = options.config
2234
+ ? resolveConfig(options.config)
2235
+ : await loadBeignetConfig(targetDir);
2236
+
2237
+ await assertServerRuntimeApp(targetDir, config, "outbox");
2238
+ await updateOutboxPortWiring(targetDir, config, { dryRun: true });
2239
+
2240
+ const cronSecretPlan = await planCronSecretWiring(targetDir);
2241
+ const files = outboxFiles(config);
2242
+ const plannedFiles = await planGeneratedFiles(targetDir, files, {
2243
+ force: Boolean(options.force),
2244
+ });
2245
+ const createdFiles = plannedFiles
2246
+ .filter((file) => file.result === "created")
2247
+ .map((file) => file.path);
2248
+ const updatedFiles = plannedFiles
2249
+ .filter((file) => file.result === "updated")
2250
+ .map((file) => file.path);
2251
+ const skippedFiles = plannedFiles
2252
+ .filter((file) => file.result === "skipped")
2253
+ .map((file) => file.path);
2254
+
2255
+ if (!options.dryRun) {
2256
+ for (const file of plannedFiles) {
2257
+ await writePlannedGeneratedFile(targetDir, file);
2258
+ }
2259
+ }
2260
+
2261
+ const cronSecretResult = await applyCronSecretWiring(
2262
+ targetDir,
2263
+ cronSecretPlan,
2264
+ { dryRun: Boolean(options.dryRun) },
2265
+ );
2266
+ createdFiles.push(...cronSecretResult.createdFiles);
2267
+ updatedFiles.push(...cronSecretResult.updatedFiles);
2268
+ skippedFiles.push(...cronSecretResult.skippedFiles);
2269
+ updatedFiles.push(
2270
+ ...(await updateOutboxPortWiring(targetDir, config, {
2271
+ dryRun: Boolean(options.dryRun),
2272
+ })),
2273
+ );
2274
+
2275
+ return {
2276
+ schemaVersion: 1,
2277
+ name: "outbox",
2278
+ targetDir,
2279
+ dryRun: Boolean(options.dryRun),
2280
+ files: [
2281
+ ...files.map((file) => file.path),
2282
+ ...cronSecretPlan.files.map((file) => file.path),
2283
+ ...cronSecretPlan.skippedFiles,
2284
+ config.paths.ports,
2285
+ config.paths.infrastructurePorts,
2286
+ path.join(infrastructureDir(config), "db/provider.ts"),
2287
+ drizzleRepositoriesPath(config),
2288
+ ],
2289
+ createdFiles: uniqueStrings(createdFiles),
2290
+ updatedFiles: uniqueStrings(updatedFiles),
2291
+ skippedFiles: uniqueStrings(skippedFiles),
2292
+ };
1785
2293
  }
1786
2294
 
1787
2295
  async function makeFeatureUi(
@@ -2017,6 +2525,28 @@ async function assertFeatureUiApp(
2017
2525
  }
2018
2526
  }
2019
2527
 
2528
+ async function assertServerRuntimeApp(
2529
+ targetDir: string,
2530
+ config: ResolvedBeignetConfig,
2531
+ artifact: string,
2532
+ ): Promise<void> {
2533
+ const requiredFiles = [
2534
+ config.paths.appContext,
2535
+ config.paths.server,
2536
+ config.paths.infrastructurePorts,
2537
+ ];
2538
+
2539
+ for (const file of requiredFiles) {
2540
+ try {
2541
+ await stat(path.join(targetDir, file));
2542
+ } catch {
2543
+ throw new Error(
2544
+ `beignet make ${artifact} expects a standard Beignet app with a server runtime. Missing ${file}.`,
2545
+ );
2546
+ }
2547
+ }
2548
+ }
2549
+
2020
2550
  async function assertPersistenceArtifactApp(
2021
2551
  targetDir: string,
2022
2552
  names: FactoryNames | SeedNames,
@@ -2268,6 +2798,7 @@ async function makeFeatureArtifact(options: {
2268
2798
  }
2269
2799
 
2270
2800
  type PackageJsonLike = {
2801
+ scripts?: Record<string, string>;
2271
2802
  dependencies?: Record<string, string>;
2272
2803
  devDependencies?: Record<string, string>;
2273
2804
  };
@@ -2292,6 +2823,26 @@ async function updatePackageDependencies(
2292
2823
  return true;
2293
2824
  }
2294
2825
 
2826
+ async function updatePackageScripts(
2827
+ targetDir: string,
2828
+ scripts: Record<string, string>,
2829
+ options: { dryRun: boolean },
2830
+ ): Promise<boolean> {
2831
+ const filePath = path.join(targetDir, "package.json");
2832
+ const original = await readFile(filePath, "utf8");
2833
+ const packageJson = JSON.parse(original) as PackageJsonLike;
2834
+ packageJson.scripts = packageJson.scripts ?? {};
2835
+
2836
+ for (const [name, command] of Object.entries(scripts)) {
2837
+ packageJson.scripts[name] ??= command;
2838
+ }
2839
+
2840
+ const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
2841
+ if (next === original) return false;
2842
+ if (!options.dryRun) await writeFile(filePath, next);
2843
+ return true;
2844
+ }
2845
+
2295
2846
  function beignetDependencyVersion(packageJson: PackageJsonLike): string {
2296
2847
  return (
2297
2848
  packageJson.dependencies?.["@beignet/core"] ??
@@ -3670,6 +4221,15 @@ function typeBodySource(source: string, typeName: string): string | undefined {
3670
4221
  return source.slice(openBrace + 1, closeBrace);
3671
4222
  }
3672
4223
 
4224
+ function typeHasTopLevelProperty(
4225
+ source: string,
4226
+ typeName: string,
4227
+ propertyName: string,
4228
+ ): boolean {
4229
+ const body = typeBodySource(source, typeName);
4230
+ return body !== undefined && hasTopLevelObjectProperty(body, propertyName);
4231
+ }
4232
+
3673
4233
  function appendUnionTypeMember(
3674
4234
  source: string,
3675
4235
  typeName: string,
@@ -3976,6 +4536,45 @@ function addNamedTypeImport(
3976
4536
  )}`;
3977
4537
  }
3978
4538
 
4539
+ function addNamedImport(
4540
+ source: string,
4541
+ name: string,
4542
+ moduleName: string,
4543
+ ): string {
4544
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4545
+ const importPattern = new RegExp(
4546
+ `import \\{([^}]*)\\} from "${escapedModule}";`,
4547
+ );
4548
+ const match = importPattern.exec(source);
4549
+ if (!match) {
4550
+ return insertAfterImports(
4551
+ source,
4552
+ `import { ${name} } from "${moduleName}";`,
4553
+ );
4554
+ }
4555
+
4556
+ const names = match[1]
4557
+ .split(",")
4558
+ .map((entry) => entry.trim())
4559
+ .filter(Boolean);
4560
+ if (names.includes(name)) return source;
4561
+
4562
+ const insertIndex = names.findIndex(
4563
+ (existing) => existing.localeCompare(name) > 0,
4564
+ );
4565
+ if (insertIndex === -1) names.push(name);
4566
+ else names.splice(insertIndex, 0, name);
4567
+
4568
+ const indent = match[1].match(/\n([\t ]+)/)?.[1] ?? "\t";
4569
+ const replacement = match[1].includes("\n")
4570
+ ? `import {\n${indent}${names.join(`,\n${indent}`)},\n} from "${moduleName}";`
4571
+ : `import { ${names.join(", ")} } from "${moduleName}";`;
4572
+
4573
+ return `${source.slice(0, match.index)}${replacement}${source.slice(
4574
+ match.index + match[0].length,
4575
+ )}`;
4576
+ }
4577
+
3979
4578
  function topLevelBoundObjectRange(
3980
4579
  source: string,
3981
4580
  openBrace: number,
@@ -4454,6 +5053,10 @@ function seedNames(input: string): SeedNames {
4454
5053
  };
4455
5054
  }
4456
5055
 
5056
+ function defaultFactoryNamesForSeed(names: SeedNames): FactoryNames {
5057
+ return factoryNames(`${names.feature.kebab}/${names.featureSingularKebab}`);
5058
+ }
5059
+
4457
5060
  function notificationNames(input: string): NotificationNames {
4458
5061
  const names = featureArtifactNames(input, "Notification");
4459
5062
  const notificationExportName = `${names.featureSingularPascal}${names.artifact.pascal}Notification`;
@@ -5065,6 +5668,19 @@ function uploadFiles(
5065
5668
  ];
5066
5669
  }
5067
5670
 
5671
+ function outboxFiles(config: ResolvedBeignetConfig): GeneratedFile[] {
5672
+ return [
5673
+ {
5674
+ path: config.paths.outbox,
5675
+ content: outboxRegistryFile(config),
5676
+ },
5677
+ {
5678
+ path: outboxDrainRoutePath(config),
5679
+ content: outboxDrainRouteFile(config),
5680
+ },
5681
+ ];
5682
+ }
5683
+
5068
5684
  function portFilePath(names: PortNames, config: ResolvedBeignetConfig): string {
5069
5685
  return path.join(path.dirname(config.paths.ports), `${names.kebab}.ts`);
5070
5686
  }
@@ -5470,6 +6086,24 @@ function uploadFilePath(
5470
6086
  );
5471
6087
  }
5472
6088
 
6089
+ function seedEntrypointPath(config: ResolvedBeignetConfig): string {
6090
+ return path.join(infrastructureDir(config), "db", "seed.ts");
6091
+ }
6092
+
6093
+ function uploadRoutePath(config: ResolvedBeignetConfig): string {
6094
+ return path.join(
6095
+ config.paths.routes,
6096
+ "uploads",
6097
+ "[uploadName]",
6098
+ "[action]",
6099
+ "route.ts",
6100
+ );
6101
+ }
6102
+
6103
+ function outboxDrainRoutePath(config: ResolvedBeignetConfig): string {
6104
+ return path.join(config.paths.routes, "cron", "outbox", "drain", "route.ts");
6105
+ }
6106
+
5473
6107
  function scheduleRouteFilePath(
5474
6108
  names: ScheduleNames,
5475
6109
  config: ResolvedBeignetConfig,
@@ -5909,13 +6543,98 @@ export async function stopScheduleContext(): Promise<void> {
5909
6543
  return "updated";
5910
6544
  }
5911
6545
 
6546
+ async function updateSeedEntrypoint(
6547
+ targetDir: string,
6548
+ names: SeedNames,
6549
+ config: ResolvedBeignetConfig,
6550
+ options: { dryRun: boolean },
6551
+ ): Promise<WriteGeneratedFileResult> {
6552
+ const file = seedEntrypointPath(config);
6553
+ const destination = path.join(targetDir, file);
6554
+ const existing = await readOptionalFile(destination);
6555
+ const registryName = `${names.featureSingularCamel}Seeds`;
6556
+ const importLine = `import { ${registryName} } from "${aliasModule(
6557
+ path.join(featureArtifactDir(names, "seeds", config), "index.ts"),
6558
+ )}";`;
6559
+ const registryEntry = `...${registryName}`;
6560
+
6561
+ if (existing === undefined) {
6562
+ const content = seedEntrypointFile(names, config);
6563
+ if (!options.dryRun) {
6564
+ await mkdir(path.dirname(destination), { recursive: true });
6565
+ await writeFile(destination, content);
6566
+ }
6567
+ return "created";
6568
+ }
6569
+
6570
+ let next = existing;
6571
+ if (!next.includes(importLine)) {
6572
+ next = insertAfterImports(next, importLine);
6573
+ }
6574
+
6575
+ const registry = arrayInitializerInfo(next, "seeds");
6576
+ if (!registry) {
6577
+ next = `${next.trimEnd()}\n\nconst seeds = [${registryEntry}] as const;\n`;
6578
+ } else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
6579
+ const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
6580
+ next = `${next.slice(0, registry.start)}${nextArray}${next.slice(
6581
+ registry.end,
6582
+ )}`;
6583
+ }
6584
+
6585
+ if (next === existing) return "skipped";
6586
+ if (!options.dryRun) await writeFile(destination, next);
6587
+ return "updated";
6588
+ }
6589
+
6590
+ async function updateUploadRoute(
6591
+ targetDir: string,
6592
+ names: UploadNames,
6593
+ config: ResolvedBeignetConfig,
6594
+ options: { dryRun: boolean },
6595
+ ): Promise<WriteGeneratedFileResult> {
6596
+ const file = uploadRoutePath(config);
6597
+ const destination = path.join(targetDir, file);
6598
+ const existing = await readOptionalFile(destination);
6599
+ const registryName = `${names.featureSingularCamel}Uploads`;
6600
+ const registryKey = names.featureSingularCamel;
6601
+ const importLine = `import { ${registryName} } from "${aliasModule(
6602
+ path.join(featureArtifactDir(names, "uploads", config), "index.ts"),
6603
+ )}";`;
6604
+
6605
+ if (existing === undefined) {
6606
+ const content = uploadRouteFile(names, config);
6607
+ if (!options.dryRun) {
6608
+ await mkdir(path.dirname(destination), { recursive: true });
6609
+ await writeFile(destination, content);
6610
+ }
6611
+ return "created";
6612
+ }
6613
+
6614
+ let next = existing;
6615
+ if (!next.includes(importLine)) {
6616
+ next = insertAfterImports(next, importLine);
6617
+ }
6618
+
6619
+ const registry = defineUploadsInitializerInfo(next, "uploadRegistry");
6620
+ if (!registry) {
6621
+ return "skipped";
6622
+ }
6623
+ if (!new RegExp(`\\b${registryKey}\\s*:`).test(registry.text)) {
6624
+ next = `${next.slice(0, registry.end)}\t${registryKey}: ${registryName},\n${next.slice(
6625
+ registry.end,
6626
+ )}`;
6627
+ }
6628
+
6629
+ if (next === existing) return "skipped";
6630
+ if (!options.dryRun) await writeFile(destination, next);
6631
+ return "updated";
6632
+ }
6633
+
5912
6634
  /**
5913
6635
  * Append a feature event or job registry to `server/outbox.ts`.
5914
- *
5915
- * The outbox registry is opt-in, so this never creates the file; it returns
5916
- * `undefined` when the app has no outbox module. It also bails to "skipped"
5917
- * instead of writing when the `defineOutboxRegistry({...})` anchor is missing
5918
- * or the matching array is not editable.
6636
+ * Creates the registry when missing so event/job generators produce a complete
6637
+ * drainable path instead of leaving durable delivery as manual wiring.
5919
6638
  */
5920
6639
  async function updateOutboxRegistry(
5921
6640
  targetDir: string,
@@ -5923,10 +6642,17 @@ async function updateOutboxRegistry(
5923
6642
  kind: "events" | "jobs",
5924
6643
  config: ResolvedBeignetConfig,
5925
6644
  options: { dryRun: boolean },
5926
- ): Promise<WriteGeneratedFileResult | undefined> {
6645
+ ): Promise<WriteGeneratedFileResult> {
5927
6646
  const destination = path.join(targetDir, config.paths.outbox);
5928
6647
  const existing = await readOptionalFile(destination);
5929
- if (existing === undefined) return undefined;
6648
+ if (existing === undefined) {
6649
+ const content = outboxRegistryFile(config, { names, kind });
6650
+ if (!options.dryRun) {
6651
+ await mkdir(path.dirname(destination), { recursive: true });
6652
+ await writeFile(destination, content);
6653
+ }
6654
+ return "created";
6655
+ }
5930
6656
 
5931
6657
  const featureIndexPath = path.join(
5932
6658
  featureArtifactDir(names, kind, config),
@@ -5976,8 +6702,9 @@ async function applyOutboxRegistryUpdate(
5976
6702
  config,
5977
6703
  options,
5978
6704
  );
5979
- if (registryResult === undefined) return;
5980
6705
 
6706
+ if (registryResult === "created")
6707
+ result.createdFiles.push(config.paths.outbox);
5981
6708
  if (registryResult === "updated")
5982
6709
  result.updatedFiles.push(config.paths.outbox);
5983
6710
  if (registryResult === "skipped")
@@ -7488,6 +8215,137 @@ export const ${names.uploadExportName} = defineUpload<
7488
8215
  `;
7489
8216
  }
7490
8217
 
8218
+ function seedEntrypointFile(
8219
+ names: SeedNames,
8220
+ config: ResolvedBeignetConfig,
8221
+ ): string {
8222
+ const registryName = `${names.featureSingularCamel}Seeds`;
8223
+
8224
+ return `import { createServiceActor } from "@beignet/core/ports";
8225
+ import { runSeeds } from "@beignet/core/testing";
8226
+ import { ${registryName} } from "${aliasModule(path.join(featureArtifactDir(names, "seeds", config), "index.ts"))}";
8227
+ import { server } from "${aliasModule(config.paths.server)}";
8228
+
8229
+ const seeds = [...${registryName}] as const;
8230
+
8231
+ const ctx = await server.createServiceContext({
8232
+ \tactor: createServiceActor("beignet-seed"),
8233
+ });
8234
+
8235
+ try {
8236
+ \tawait runSeeds({ ctx, seeds });
8237
+ } finally {
8238
+ \tawait server.stop();
8239
+ }
8240
+ `;
8241
+ }
8242
+
8243
+ function uploadRouteFile(
8244
+ names: UploadNames,
8245
+ config: ResolvedBeignetConfig,
8246
+ ): string {
8247
+ const registryName = `${names.featureSingularCamel}Uploads`;
8248
+
8249
+ return `import {
8250
+ \tcreateUploadRouter,
8251
+ \tdefineUploads,
8252
+ \tuploadsFromRegistry,
8253
+ } from "@beignet/core/uploads";
8254
+ import { createUploadRoute } from "@beignet/next";
8255
+ import { ${registryName} } from "${aliasModule(path.join(featureArtifactDir(names, "uploads", config), "index.ts"))}";
8256
+ import { server } from "${aliasModule(config.paths.server)}";
8257
+
8258
+ const uploadRegistry = defineUploads({
8259
+ \t${names.featureSingularCamel}: ${registryName},
8260
+ });
8261
+
8262
+ const uploadRouter = createUploadRouter({
8263
+ \tuploads: uploadsFromRegistry(uploadRegistry),
8264
+ \tctx: () => server.createContextFromNext(),
8265
+ \tstorage: server.ports.storage,
8266
+ });
8267
+
8268
+ export const { POST } = createUploadRoute(uploadRouter);
8269
+ `;
8270
+ }
8271
+
8272
+ function outboxRegistryFile(
8273
+ config: ResolvedBeignetConfig,
8274
+ registration?: {
8275
+ names: EventNames | JobNames;
8276
+ kind: "events" | "jobs";
8277
+ },
8278
+ ): string {
8279
+ const featureRegistry = registration
8280
+ ? outboxFeatureRegistry(registration.names, registration.kind, config)
8281
+ : undefined;
8282
+ const eventEntry =
8283
+ featureRegistry?.kind === "events" ? `...${featureRegistry.name}` : "";
8284
+ const jobEntry =
8285
+ featureRegistry?.kind === "jobs" ? `...${featureRegistry.name}` : "";
8286
+ const featureImport = featureRegistry
8287
+ ? `${featureRegistry.importLine}\n`
8288
+ : "";
8289
+
8290
+ return `import { defineOutboxRegistry } from "@beignet/core/outbox";
8291
+ import { createServiceActor } from "@beignet/core/ports";
8292
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
8293
+ ${featureImport}import { server } from "${relativeModule(config.paths.outbox, config.paths.server)}";
8294
+
8295
+ export const outboxRegistry = defineOutboxRegistry({
8296
+ \tevents: [${eventEntry}],
8297
+ \tjobs: [${jobEntry}],
8298
+ });
8299
+
8300
+ export async function createOutboxDrainContext(): Promise<AppContext> {
8301
+ \treturn server.createServiceContext({
8302
+ \t\tactor: createServiceActor("beignet-outbox"),
8303
+ \t});
8304
+ }
8305
+
8306
+ export async function stopOutboxDrainContext(): Promise<void> {
8307
+ \tawait server.stop();
8308
+ }
8309
+ `;
8310
+ }
8311
+
8312
+ function outboxDrainRouteFile(config: ResolvedBeignetConfig): string {
8313
+ return `import { createOutboxDrainRoute } from "@beignet/next";
8314
+ import { env } from "@/lib/env";
8315
+ import { server } from "${aliasModule(config.paths.server)}";
8316
+ import { outboxRegistry } from "${aliasModule(config.paths.outbox)}";
8317
+
8318
+ export const runtime = "nodejs";
8319
+
8320
+ export const { GET, POST } = createOutboxDrainRoute({
8321
+ \tserver,
8322
+ \tregistry: outboxRegistry,
8323
+ \tsecret: env.CRON_SECRET,
8324
+ \tbatchSize: 100,
8325
+ });
8326
+ `;
8327
+ }
8328
+
8329
+ function outboxFeatureRegistry(
8330
+ names: EventNames | JobNames,
8331
+ kind: "events" | "jobs",
8332
+ config: ResolvedBeignetConfig,
8333
+ ): { kind: "events" | "jobs"; name: string; importLine: string } {
8334
+ const featureIndexPath = path.join(
8335
+ featureArtifactDir(names, kind, config),
8336
+ "index.ts",
8337
+ );
8338
+ const name = `${names.featureSingularCamel}${
8339
+ kind === "events" ? "Events" : "Jobs"
8340
+ }`;
8341
+
8342
+ return {
8343
+ kind,
8344
+ name,
8345
+ importLine: `import { ${name} } from "${aliasModule(featureIndexPath)}";`,
8346
+ };
8347
+ }
8348
+
7491
8349
  function billingSchemasFile(): string {
7492
8350
  return `import { z } from "zod";
7493
8351