@beignet/cli 0.0.26 → 0.0.27

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 (75) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/choices.d.ts +8 -0
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +14 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/db.js +2 -2
  8. package/dist/db.js.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +109 -13
  11. package/dist/index.js.map +1 -1
  12. package/dist/inspect.js +51 -3
  13. package/dist/inspect.js.map +1 -1
  14. package/dist/make/inbox.d.ts +63 -0
  15. package/dist/make/inbox.d.ts.map +1 -0
  16. package/dist/make/inbox.js +1747 -0
  17. package/dist/make/inbox.js.map +1 -0
  18. package/dist/make/payments.d.ts +61 -0
  19. package/dist/make/payments.d.ts.map +1 -0
  20. package/dist/make/payments.js +1900 -0
  21. package/dist/make/payments.js.map +1 -0
  22. package/dist/make/shared.d.ts +575 -0
  23. package/dist/make/shared.d.ts.map +1 -0
  24. package/dist/make/shared.js +2166 -0
  25. package/dist/make/shared.js.map +1 -0
  26. package/dist/make/tenancy.d.ts +101 -0
  27. package/dist/make/tenancy.d.ts.map +1 -0
  28. package/dist/make/tenancy.js +4409 -0
  29. package/dist/make/tenancy.js.map +1 -0
  30. package/dist/make.d.ts +8 -44
  31. package/dist/make.d.ts.map +1 -1
  32. package/dist/make.js +71 -3659
  33. package/dist/make.js.map +1 -1
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/mcp.js +44 -3
  36. package/dist/mcp.js.map +1 -1
  37. package/dist/provider-add.d.ts +19 -0
  38. package/dist/provider-add.d.ts.map +1 -0
  39. package/dist/provider-add.js +691 -0
  40. package/dist/provider-add.js.map +1 -0
  41. package/dist/registry-edits.d.ts +9 -0
  42. package/dist/registry-edits.d.ts.map +1 -1
  43. package/dist/registry-edits.js +32 -0
  44. package/dist/registry-edits.js.map +1 -1
  45. package/dist/task.d.ts +2 -0
  46. package/dist/task.d.ts.map +1 -1
  47. package/dist/task.js +2 -0
  48. package/dist/task.js.map +1 -1
  49. package/dist/templates/agents.js +2 -2
  50. package/dist/templates/server.d.ts.map +1 -1
  51. package/dist/templates/server.js +44 -37
  52. package/dist/templates/server.js.map +1 -1
  53. package/dist/templates/shared.d.ts +5 -0
  54. package/dist/templates/shared.d.ts.map +1 -1
  55. package/dist/templates/shared.js +5 -0
  56. package/dist/templates/shared.js.map +1 -1
  57. package/package.json +2 -2
  58. package/skills/app-structure/SKILL.md +1 -1
  59. package/src/choices.ts +28 -0
  60. package/src/db.ts +2 -2
  61. package/src/index.ts +170 -13
  62. package/src/inspect.ts +68 -2
  63. package/src/make/inbox.ts +2015 -0
  64. package/src/make/payments.ts +2182 -0
  65. package/src/make/shared.ts +3550 -0
  66. package/src/make/tenancy.ts +4809 -0
  67. package/src/make.ts +380 -5289
  68. package/src/mcp.ts +65 -2
  69. package/src/provider-add.ts +926 -0
  70. package/src/registry-edits.ts +47 -0
  71. package/src/task.ts +8 -10
  72. package/src/templates/agents.ts +2 -2
  73. package/src/templates/server.ts +44 -37
  74. package/src/templates/shared.ts +5 -0
  75. package/src/test-helpers/generated-app.ts +168 -0
package/dist/make.js CHANGED
@@ -1,44 +1,14 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { clientIndexPath, directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
5
- import { appendToArrayExpression, appendToNamedArray, appendToOutboxRegistryArray, arrayInitializerInfo, identifiersFromArrayExpression, insertAfterImports, matchingDelimiterIndex, } from "./registry-edits.js";
4
+ import { clientIndexPath, loadBeignetConfig, resolveConfig, } from "./config.js";
5
+ import { adapterFilePath, addNamedImport, addNamedTypeImport, aliasModule, appendDeferredPortKey, appendPickStringLiteralMember, applyPortProviderWiring, assertAdapterApp, assertFeatureArtifactApp, assertFeatureUiApp, assertGeneratedIdentifiers, assertPersistenceArtifactApp, assertPolicyApp, assertPortApp, assertServerRuntimeApp, assertStandardApp, assertTestApp, assertUseCaseApp, beignetDependencyVersion, constantCase, defineUploadsInitializerInfo, detectResourceDatabase, detectResourcePersistence, drizzleDialects, drizzleRepositoriesPath, drizzleResourceRepositoryFilePath, drizzleSchemaIndexPath, eventBusPortWiring, eventFilePath, factoryFilePath, featureArtifactDir, featureArtifactIndexFile, featureArtifactNames, featureResourceNames, featureUiClientQueriesFilePath, featureUiComponentFilePath, featureUiIndexFilePath, featureUiNames, fileExists, hasTopLevelObjectProperty, identifierNames, infrastructureDir, insertTypeProperty, jobFilePath, listenerFilePath, mergeMakeResultInto, mergeMakeResults, missingCapabilityBuilderFiles, notificationFilePath, notificationPortWirings, planGeneratedFile, planGeneratedFiles, portFilePath, portNames, providersFilePath, readOptionalFile, relativeModule, resourceContractFilePath, resourceFeatureDir, resourceNames, resourcePortFilePath, resourceSharedErrorsPath, scheduleFilePath, seedEntrypointPath, seedFilePath, seedNames, singularize, storagePortWiring, tanstackReactQueryDependencyVersion, taskFilePath, typeBodySource, uniqueStrings, updateDrizzleRepositories, updateDrizzleSchemaIndex, updateFeatureArtifactIndex, updateInfrastructureAdapterWiring, updateInfrastructureDeferredPorts, updateInfrastructureGatePolicies, updateInfrastructurePortStub, updateInfrastructurePorts, updateOpenApiRoute, updatePackageDependencies, updatePackageJson, updatePackageScripts, updatePortIndex, updatePortsIndex, updateSeedEntrypoint, updateServerRoutes, updateServerTransactionPorts, updateSharedErrors, uploadFilePath, useCaseFeatureDir, useCaseFilePath, usesFeatureOwnedPolicies, usesFeatureOwnedTests, wirePortProviders, writeGeneratedFile, writePlannedGeneratedFile, } from "./make/shared.js";
6
+ import { appendToArrayExpression, appendToNamedArray, appendToOutboxRegistryArray, arrayInitializerInfo, identifiersFromArrayExpression, insertAfterImports, } from "./registry-edits.js";
6
7
  export { makeFeatureAddonChoices, makeFeatureRecipeChoices, } from "./choices.js";
7
- const drizzleDialects = {
8
- sqlite: {
9
- subpath: "sqlite",
10
- dbTypeName: "DrizzleSqliteDatabase",
11
- columnModule: "drizzle-orm/sqlite-core",
12
- tableFunction: "sqliteTable",
13
- schemaImports: ["integer", "sqliteTable", "text"],
14
- supportsReturning: true,
15
- idColumn: (columnName) => `text("${columnName}")`,
16
- timestampColumn: (columnName) => `text("${columnName}")`,
17
- integerColumn: (columnName) => `integer("${columnName}")`,
18
- },
19
- postgres: {
20
- subpath: "postgres",
21
- dbTypeName: "DrizzlePostgresDatabase",
22
- columnModule: "drizzle-orm/pg-core",
23
- tableFunction: "pgTable",
24
- schemaImports: ["integer", "pgTable", "text"],
25
- supportsReturning: true,
26
- idColumn: (columnName) => `text("${columnName}")`,
27
- timestampColumn: (columnName) => `text("${columnName}")`,
28
- integerColumn: (columnName) => `integer("${columnName}")`,
29
- },
30
- mysql: {
31
- subpath: "mysql",
32
- dbTypeName: "DrizzleMysqlDatabase",
33
- columnModule: "drizzle-orm/mysql-core",
34
- tableFunction: "mysqlTable",
35
- schemaImports: ["int", "mysqlTable", "text", "varchar"],
36
- supportsReturning: false,
37
- idColumn: (columnName) => `varchar("${columnName}", { length: 36 })`,
38
- timestampColumn: (columnName) => `varchar("${columnName}", { length: 32 })`,
39
- integerColumn: (columnName) => `int("${columnName}")`,
40
- },
41
- };
8
+ export * from "./make/inbox.js";
9
+ export * from "./make/payments.js";
10
+ export * from "./make/shared.js";
11
+ export * from "./make/tenancy.js";
42
12
  export async function makeResource(options) {
43
13
  return makeResourceSlice(options, "resource");
44
14
  }
@@ -61,56 +31,6 @@ export async function makeFeature(options) {
61
31
  await makeFeatureSlice({ ...featureOptions, dryRun: true }, addons);
62
32
  return makeFeatureSlice(featureOptions, addons);
63
33
  }
64
- export async function makePayments(options) {
65
- const targetDir = path.resolve(options.cwd ?? process.cwd());
66
- const config = options.config
67
- ? resolveConfig(options.config)
68
- : await loadBeignetConfig(targetDir);
69
- await assertStandardApp(targetDir, config);
70
- const persistence = await detectResourcePersistence(targetDir, config);
71
- if (persistence !== "drizzle") {
72
- throw new Error("beignet make payments expects a Drizzle-backed Beignet app because billing state must be durable. Add a database provider first, then run make payments again.");
73
- }
74
- const database = await detectResourceDatabase(targetDir, config);
75
- const generatedFiles = paymentsFiles(config, database);
76
- const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
77
- force: Boolean(options.force),
78
- });
79
- const billingNames = resourceNames("billing");
80
- const plannedBillingUpdates = await updatePaymentsWiring(targetDir, billingNames, config, { dryRun: true });
81
- const plannedPaymentsUpdates = (await wirePortProviders(targetDir, config, [paymentsPortWiring("make payments")], { dryRun: true })).updatedFiles;
82
- const createdFiles = plannedFiles
83
- .filter((file) => file.result === "created")
84
- .map((file) => file.path);
85
- const updatedFiles = plannedFiles
86
- .filter((file) => file.result === "updated")
87
- .map((file) => file.path);
88
- const skippedFiles = plannedFiles
89
- .filter((file) => file.result === "skipped")
90
- .map((file) => file.path);
91
- if (options.dryRun) {
92
- updatedFiles.push(...plannedBillingUpdates, ...plannedPaymentsUpdates);
93
- }
94
- else {
95
- for (const file of plannedFiles) {
96
- await writePlannedGeneratedFile(targetDir, file);
97
- }
98
- updatedFiles.push(...(await updatePaymentsWiring(targetDir, billingNames, config, {
99
- dryRun: false,
100
- })));
101
- updatedFiles.push(...(await wirePortProviders(targetDir, config, [paymentsPortWiring("make payments")], { dryRun: false })).updatedFiles);
102
- }
103
- return {
104
- schemaVersion: 1,
105
- name: "payments",
106
- targetDir,
107
- dryRun: Boolean(options.dryRun),
108
- files: generatedFiles.map((file) => file.path),
109
- createdFiles,
110
- updatedFiles: uniqueStrings(updatedFiles),
111
- skippedFiles,
112
- };
113
- }
114
34
  async function makeFeatureResource(options) {
115
35
  try {
116
36
  return await makeResourceSlice(options, "feature");
@@ -339,33 +259,6 @@ const featureAddonOrder = [
339
259
  "ui",
340
260
  "upload",
341
261
  ];
342
- function mergeMakeResults(left, right) {
343
- return {
344
- ...left,
345
- files: uniqueStrings([...left.files, ...right.files]),
346
- createdFiles: uniqueStrings([...left.createdFiles, ...right.createdFiles]),
347
- updatedFiles: uniqueStrings([...left.updatedFiles, ...right.updatedFiles]),
348
- skippedFiles: uniqueStrings([...left.skippedFiles, ...right.skippedFiles]),
349
- };
350
- }
351
- function mergeMakeResultInto(target, source) {
352
- target.files = uniqueStrings([...target.files, ...source.files]);
353
- target.createdFiles = uniqueStrings([
354
- ...target.createdFiles,
355
- ...source.createdFiles,
356
- ]);
357
- target.updatedFiles = uniqueStrings([
358
- ...target.updatedFiles,
359
- ...source.updatedFiles,
360
- ]);
361
- target.skippedFiles = uniqueStrings([
362
- ...target.skippedFiles,
363
- ...source.skippedFiles,
364
- ]);
365
- }
366
- function uniqueStrings(values) {
367
- return [...new Set(values)];
368
- }
369
262
  export async function makeContract(options) {
370
263
  const targetDir = path.resolve(options.cwd ?? process.cwd());
371
264
  const names = resourceNames(options.name);
@@ -1315,171 +1208,6 @@ async function applyOutboxDrainWiring(result, targetDir, plan, options) {
1315
1208
  ...plan.cronSecret.skippedFiles,
1316
1209
  ]);
1317
1210
  }
1318
- function eventBusPortWiring(command) {
1319
- return {
1320
- command,
1321
- portKey: "eventBus",
1322
- portType: "EventBusPort",
1323
- portTypeModule: "@beignet/core/ports",
1324
- providerFactory: "createInMemoryEventBusProvider",
1325
- providerModule: "@beignet/provider-event-bus-memory",
1326
- dependency: "@beignet/provider-event-bus-memory",
1327
- };
1328
- }
1329
- function notificationPortWirings(command) {
1330
- return [
1331
- {
1332
- command,
1333
- portKey: "mailer",
1334
- portType: "MailerPort",
1335
- portTypeModule: "@beignet/core/mail",
1336
- providerFactory: "createMemoryMailerProvider",
1337
- providerModule: "@beignet/core/mail",
1338
- },
1339
- {
1340
- command,
1341
- portKey: "notifications",
1342
- portType: "NotificationPort",
1343
- portTypeModule: "@beignet/core/notifications",
1344
- providerFactory: "createInlineNotificationsProvider",
1345
- providerModule: "@beignet/core/notifications",
1346
- },
1347
- ];
1348
- }
1349
- function paymentsPortWiring(command) {
1350
- return {
1351
- command,
1352
- portKey: "payments",
1353
- portType: "PaymentsPort",
1354
- portTypeModule: "@beignet/core/payments",
1355
- providerFactory: "createMemoryPaymentsProvider",
1356
- providerModule: "@beignet/core/payments",
1357
- };
1358
- }
1359
- function storagePortWiring(command) {
1360
- return {
1361
- command,
1362
- portKey: "storage",
1363
- portType: "StoragePort",
1364
- portTypeModule: "@beignet/core/ports",
1365
- providerFactory: "createLocalStorageProvider",
1366
- providerModule: "@beignet/provider-storage-local",
1367
- dependency: "@beignet/provider-storage-local",
1368
- };
1369
- }
1370
- function providersFilePath(config) {
1371
- return `${directoryPath(path.dirname(config.paths.server))}/providers.ts`;
1372
- }
1373
- /**
1374
- * Wire provider-backed app ports a generator depends on.
1375
- *
1376
- * Each wiring is independent and skipped silently when its port key already
1377
- * appears in the ports file, so apps that wired their own adapter (or scaffolds
1378
- * whose integrations already contribute the port) are left untouched. All
1379
- * anchors are computed before any file is written; an anchor miss aborts with a
1380
- * copy-pasteable manual instruction so the generator never leaves partial
1381
- * wiring behind. Callers validate anchors with `dryRun: true` before writing
1382
- * artifact files.
1383
- */
1384
- async function wirePortProviders(targetDir, config, wirings, options) {
1385
- const portsFile = config.paths.ports;
1386
- const infrastructureFile = config.paths.infrastructurePorts;
1387
- const providersFile = providersFilePath(config);
1388
- const manualFor = (wiring) => `Wire the ${wiring.portKey} port manually: add ${wiring.portKey}: ${wiring.portType}; to AppPorts in ${portsFile}, defer "${wiring.portKey}" in ${infrastructureFile}, and register ${wiring.providerFactory}() from ${wiring.providerModule} in ${providersFile}.`;
1389
- const portsOriginal = await readOptionalFile(path.join(targetDir, portsFile));
1390
- if (portsOriginal === undefined) {
1391
- if (wirings.length === 0)
1392
- return { updatedFiles: [] };
1393
- throw new Error(`Could not find the generated ports file ${portsFile}. ${manualFor(wirings[0])}`);
1394
- }
1395
- // A port key that already appears in the ports file means the app wired its
1396
- // own adapter or an integration already contributes the port.
1397
- const active = wirings.filter((wiring) => !new RegExp(`\\b${wiring.portKey}\\b`).test(portsOriginal));
1398
- if (active.length === 0)
1399
- return { updatedFiles: [] };
1400
- let portsNext = portsOriginal;
1401
- for (const wiring of active) {
1402
- portsNext = addNamedTypeImport(portsNext, wiring.portType, wiring.portTypeModule);
1403
- portsNext = insertTypeProperty(portsNext, "AppPorts", `${wiring.portKey}: ${wiring.portType};`, `Could not find AppPorts in ${portsFile}. ${manualFor(wiring)} Or restore the generated ports file before running beignet ${wiring.command}.`);
1404
- }
1405
- const infrastructureOriginal = await readOptionalFile(path.join(targetDir, infrastructureFile));
1406
- if (infrastructureOriginal === undefined) {
1407
- throw new Error(`Could not find the generated infrastructure ports file ${infrastructureFile}. ${manualFor(active[0])}`);
1408
- }
1409
- let infrastructureNext = infrastructureOriginal;
1410
- for (const wiring of active) {
1411
- const appended = appendDeferredPortKey(infrastructureNext, wiring.portKey);
1412
- if (appended === infrastructureNext &&
1413
- !new RegExp(`["']${wiring.portKey}["']`).test(infrastructureNext)) {
1414
- throw new Error(`Could not find the deferred port list in ${infrastructureFile}. ${manualFor(wiring)} Or restore the generated infrastructure ports file before running beignet ${wiring.command}.`);
1415
- }
1416
- infrastructureNext = appended;
1417
- }
1418
- const providersOriginal = await readOptionalFile(path.join(targetDir, providersFile));
1419
- if (providersOriginal === undefined) {
1420
- throw new Error(`Could not find the generated providers file ${providersFile}. ${manualFor(active[0])}`);
1421
- }
1422
- let providersNext = providersOriginal;
1423
- for (const wiring of active) {
1424
- if (new RegExp(`\\b${wiring.providerFactory}\\b`).test(providersNext)) {
1425
- continue;
1426
- }
1427
- const appended = appendToNamedArray(providersNext, "providers", `${wiring.providerFactory}()`, `import { ${wiring.providerFactory} } from "${wiring.providerModule}";`);
1428
- if (appended.kind === "missing") {
1429
- throw new Error(`Could not find the exported providers array in ${providersFile}. ${manualFor(wiring)} Or restore the generated providers file before running beignet ${wiring.command}.`);
1430
- }
1431
- if (appended.kind === "updated")
1432
- providersNext = appended.source;
1433
- }
1434
- const dependencies = active.filter((wiring) => wiring.dependency !== undefined);
1435
- let packageJsonNext;
1436
- if (dependencies.length > 0) {
1437
- const packageJsonOriginal = await readFile(path.join(targetDir, "package.json"), "utf8");
1438
- const packageJson = JSON.parse(packageJsonOriginal);
1439
- packageJson.dependencies = packageJson.dependencies ?? {};
1440
- for (const wiring of dependencies) {
1441
- packageJson.dependencies[wiring.dependency] ??=
1442
- beignetDependencyVersion(packageJson);
1443
- }
1444
- const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
1445
- if (next !== packageJsonOriginal)
1446
- packageJsonNext = next;
1447
- }
1448
- const plannedFiles = [];
1449
- if (portsNext !== portsOriginal) {
1450
- plannedFiles.push({ path: portsFile, content: portsNext });
1451
- }
1452
- if (infrastructureNext !== infrastructureOriginal) {
1453
- plannedFiles.push({
1454
- path: infrastructureFile,
1455
- content: infrastructureNext,
1456
- });
1457
- }
1458
- if (providersNext !== providersOriginal) {
1459
- plannedFiles.push({ path: providersFile, content: providersNext });
1460
- }
1461
- if (packageJsonNext !== undefined) {
1462
- plannedFiles.push({ path: "package.json", content: packageJsonNext });
1463
- }
1464
- if (!options.dryRun) {
1465
- for (const file of plannedFiles) {
1466
- await writeFile(path.join(targetDir, file.path), file.content);
1467
- }
1468
- }
1469
- return { updatedFiles: plannedFiles.map((file) => file.path) };
1470
- }
1471
- /**
1472
- * Record port provider wiring updates on a make result.
1473
- */
1474
- async function applyPortProviderWiring(result, targetDir, config, wirings, options) {
1475
- const wired = await wirePortProviders(targetDir, config, wirings, options);
1476
- for (const file of wired.updatedFiles) {
1477
- if (!result.updatedFiles.includes(file))
1478
- result.updatedFiles.push(file);
1479
- if (!result.files.includes(file))
1480
- result.files.push(file);
1481
- }
1482
- }
1483
1211
  export async function makeUpload(options) {
1484
1212
  const targetDir = path.resolve(options.cwd ?? process.cwd());
1485
1213
  const names = uploadNames(options.name);
@@ -1626,247 +1354,6 @@ async function makeFeatureUi(options) {
1626
1354
  skippedFiles,
1627
1355
  };
1628
1356
  }
1629
- async function assertStandardApp(targetDir, config) {
1630
- const requiredFiles = [
1631
- config.paths.appContext,
1632
- config.paths.infrastructurePorts,
1633
- config.paths.ports,
1634
- config.paths.server,
1635
- config.paths.useCaseBuilder,
1636
- ];
1637
- for (const file of requiredFiles) {
1638
- try {
1639
- await stat(path.join(targetDir, file));
1640
- }
1641
- catch {
1642
- throw new Error(`beignet make resource expects a standard Beignet app. Missing ${file}.`);
1643
- }
1644
- }
1645
- }
1646
- async function assertUseCaseApp(targetDir, config) {
1647
- try {
1648
- await stat(path.join(targetDir, config.paths.useCaseBuilder));
1649
- }
1650
- catch {
1651
- throw new Error(`beignet make use-case expects a standard Beignet app. Missing ${config.paths.useCaseBuilder}.`);
1652
- }
1653
- }
1654
- async function assertTestApp(targetDir, names, config) {
1655
- const requiredFiles = [
1656
- config.paths.appContext,
1657
- config.paths.infrastructurePorts,
1658
- useCaseFilePath(names, config),
1659
- ];
1660
- for (const file of requiredFiles) {
1661
- try {
1662
- await stat(path.join(targetDir, file));
1663
- }
1664
- catch {
1665
- throw new Error(`beignet make test expects a standard Beignet app with the target use case. Missing ${file}.`);
1666
- }
1667
- }
1668
- }
1669
- async function assertPortApp(targetDir, config) {
1670
- const requiredFiles = [config.paths.ports, config.paths.infrastructurePorts];
1671
- for (const file of requiredFiles) {
1672
- try {
1673
- await stat(path.join(targetDir, file));
1674
- }
1675
- catch {
1676
- throw new Error(`beignet make port expects a standard Beignet app. Missing ${file}.`);
1677
- }
1678
- }
1679
- }
1680
- async function assertAdapterApp(targetDir, names, config) {
1681
- const requiredFiles = [
1682
- config.paths.infrastructurePorts,
1683
- portFilePath(names, config),
1684
- ];
1685
- for (const file of requiredFiles) {
1686
- try {
1687
- await stat(path.join(targetDir, file));
1688
- }
1689
- catch {
1690
- throw new Error(`beignet make adapter expects an existing port in a standard Beignet app. Missing ${file}.`);
1691
- }
1692
- }
1693
- const infrastructurePorts = await readFile(path.join(targetDir, config.paths.infrastructurePorts), "utf8");
1694
- if (!infrastructurePorts.includes(portStubEntry(names)) &&
1695
- !infrastructurePorts.includes(adapterEntry(names))) {
1696
- throw new Error(`Could not find the generated ${names.interfaceName} infrastructure stub in ${config.paths.infrastructurePorts}. Wire ${names.adapterFactoryName}() manually or restore the generated stub before running make adapter.`);
1697
- }
1698
- }
1699
- async function assertPolicyApp(targetDir, config) {
1700
- try {
1701
- await stat(path.join(targetDir, config.paths.appContext));
1702
- }
1703
- catch {
1704
- throw new Error(`beignet make policy expects a standard Beignet app. Missing ${config.paths.appContext}.`);
1705
- }
1706
- }
1707
- async function assertFeatureArtifactApp(targetDir, config, artifact, options = {}) {
1708
- const requiredFiles = [
1709
- config.paths.appContext,
1710
- ...(options.requireServer ? [config.paths.server] : []),
1711
- ];
1712
- for (const file of requiredFiles) {
1713
- try {
1714
- await stat(path.join(targetDir, file));
1715
- }
1716
- catch {
1717
- throw new Error(`beignet make ${artifact} expects a standard Beignet app. Missing ${file}.`);
1718
- }
1719
- }
1720
- }
1721
- async function assertFeatureUiApp(targetDir, config) {
1722
- const clientPath = clientIndexPath(config);
1723
- const requiredFiles = [config.paths.appContext, clientPath];
1724
- for (const file of requiredFiles) {
1725
- try {
1726
- await stat(path.join(targetDir, file));
1727
- }
1728
- catch {
1729
- throw new Error(`beignet make feature --with ui expects a standard Beignet app with React Query client helpers. Missing ${file}.`);
1730
- }
1731
- }
1732
- }
1733
- async function assertServerRuntimeApp(targetDir, config, artifact) {
1734
- const requiredFiles = [
1735
- config.paths.appContext,
1736
- config.paths.server,
1737
- config.paths.infrastructurePorts,
1738
- ];
1739
- for (const file of requiredFiles) {
1740
- try {
1741
- await stat(path.join(targetDir, file));
1742
- }
1743
- catch {
1744
- throw new Error(`beignet make ${artifact} expects a standard Beignet app with a server runtime. Missing ${file}.`);
1745
- }
1746
- }
1747
- }
1748
- async function assertPersistenceArtifactApp(targetDir, names, config, artifact) {
1749
- const requiredFiles = [
1750
- config.paths.appContext,
1751
- config.paths.ports,
1752
- resourcePortFilePath(featureResourceNames(names), config),
1753
- ];
1754
- for (const file of requiredFiles) {
1755
- try {
1756
- await stat(path.join(targetDir, file));
1757
- }
1758
- catch {
1759
- 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.`);
1760
- }
1761
- }
1762
- }
1763
- async function writeGeneratedFile(targetDir, file, options) {
1764
- const plannedFile = await planGeneratedFile(targetDir, file, {
1765
- force: options.force,
1766
- });
1767
- if (!options.dryRun) {
1768
- await writePlannedGeneratedFile(targetDir, plannedFile);
1769
- }
1770
- return plannedFile.result;
1771
- }
1772
- async function planGeneratedFiles(targetDir, files, options) {
1773
- const plannedFiles = [];
1774
- for (const file of files) {
1775
- plannedFiles.push(await planGeneratedFile(targetDir, file, options));
1776
- }
1777
- return plannedFiles;
1778
- }
1779
- async function planGeneratedFile(targetDir, file, options) {
1780
- const destination = path.join(targetDir, file.path);
1781
- const existing = await readOptionalFile(destination);
1782
- if (existing === file.content) {
1783
- return { ...file, result: "skipped" };
1784
- }
1785
- if (existing !== undefined && !options.force) {
1786
- throw new Error(`File ${file.path} already exists with different content. Pass --force to overwrite it.`);
1787
- }
1788
- return { ...file, result: existing === undefined ? "created" : "updated" };
1789
- }
1790
- async function writePlannedGeneratedFile(targetDir, file) {
1791
- if (file.result === "skipped")
1792
- return;
1793
- const destination = path.join(targetDir, file.path);
1794
- await mkdir(path.dirname(destination), { recursive: true });
1795
- await writeFile(destination, file.content);
1796
- }
1797
- async function fileExists(filePath) {
1798
- try {
1799
- await stat(filePath);
1800
- return true;
1801
- }
1802
- catch {
1803
- return false;
1804
- }
1805
- }
1806
- async function readOptionalFile(filePath) {
1807
- try {
1808
- return await readFile(filePath, "utf8");
1809
- }
1810
- catch {
1811
- return undefined;
1812
- }
1813
- }
1814
- const capabilityBuilders = {
1815
- listeners: {
1816
- factoryName: "createListeners",
1817
- defineName: "defineListener",
1818
- module: "@beignet/core/events",
1819
- },
1820
- jobs: {
1821
- factoryName: "createJobs",
1822
- defineName: "defineJob",
1823
- module: "@beignet/core/jobs",
1824
- },
1825
- schedules: {
1826
- factoryName: "createSchedules",
1827
- defineName: "defineSchedule",
1828
- module: "@beignet/core/schedules",
1829
- },
1830
- notifications: {
1831
- factoryName: "createNotifications",
1832
- defineName: "defineNotification",
1833
- module: "@beignet/core/notifications",
1834
- },
1835
- tasks: {
1836
- factoryName: "createTasks",
1837
- defineName: "defineTask",
1838
- module: "@beignet/core/tasks",
1839
- },
1840
- };
1841
- function capabilityBuilderPath(capability, config) {
1842
- if (capability === "listeners")
1843
- return config.paths.listenersBuilder;
1844
- if (capability === "jobs")
1845
- return config.paths.jobsBuilder;
1846
- if (capability === "schedules")
1847
- return config.paths.schedulesBuilder;
1848
- if (capability === "notifications")
1849
- return config.paths.notificationsBuilder;
1850
- return config.paths.tasksBuilder;
1851
- }
1852
- function capabilityBuilderFile(capability, config) {
1853
- const builder = capabilityBuilders[capability];
1854
- return {
1855
- path: capabilityBuilderPath(capability, config),
1856
- content: `import { ${builder.factoryName} } from "${builder.module}";
1857
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
1858
-
1859
- export const { ${builder.defineName} } = ${builder.factoryName}<AppContext>();
1860
- `,
1861
- };
1862
- }
1863
- async function missingCapabilityBuilderFiles(targetDir, capability, config) {
1864
- const file = capabilityBuilderFile(capability, config);
1865
- if (await fileExists(path.join(targetDir, file.path))) {
1866
- return [];
1867
- }
1868
- return [file];
1869
- }
1870
1357
  async function makeFeatureArtifact(options) {
1871
1358
  const plannedFiles = await planGeneratedFiles(options.targetDir, options.files, {
1872
1359
  force: options.force,
@@ -1908,44 +1395,6 @@ async function makeFeatureArtifact(options) {
1908
1395
  skippedFiles,
1909
1396
  };
1910
1397
  }
1911
- async function updatePackageDependencies(targetDir, dependencies, options) {
1912
- const filePath = path.join(targetDir, "package.json");
1913
- const original = await readFile(filePath, "utf8");
1914
- const packageJson = JSON.parse(original);
1915
- packageJson.dependencies = packageJson.dependencies ?? {};
1916
- for (const [name, version] of Object.entries(dependencies)) {
1917
- packageJson.dependencies[name] ??= version(packageJson);
1918
- }
1919
- const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
1920
- if (next === original)
1921
- return false;
1922
- if (!options.dryRun)
1923
- await writeFile(filePath, next);
1924
- return true;
1925
- }
1926
- async function updatePackageScripts(targetDir, scripts, options) {
1927
- const filePath = path.join(targetDir, "package.json");
1928
- const original = await readFile(filePath, "utf8");
1929
- const packageJson = JSON.parse(original);
1930
- packageJson.scripts = packageJson.scripts ?? {};
1931
- for (const [name, command] of Object.entries(scripts)) {
1932
- packageJson.scripts[name] ??= command;
1933
- }
1934
- const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
1935
- if (next === original)
1936
- return false;
1937
- if (!options.dryRun)
1938
- await writeFile(filePath, next);
1939
- return true;
1940
- }
1941
- function beignetDependencyVersion(packageJson) {
1942
- return (packageJson.dependencies?.["@beignet/core"] ??
1943
- packageJson.dependencies?.["@beignet/next"] ??
1944
- "*");
1945
- }
1946
- function tanstackReactQueryDependencyVersion(packageJson) {
1947
- return packageJson.dependencies?.["@tanstack/react-query"] ?? "^5.100.7";
1948
- }
1949
1398
  async function updateResourceWiring(targetDir, names, config, persistenceOrOptions, maybeOptions) {
1950
1399
  const persistence = typeof persistenceOrOptions === "string"
1951
1400
  ? persistenceOrOptions
@@ -2009,1265 +1458,18 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
2009
1458
  updated.add(routesFile);
2010
1459
  return [...updated];
2011
1460
  }
2012
- async function updatePaymentsWiring(targetDir, billingNames, config, options) {
2013
- const updated = new Set();
2014
- if (await updatePackageJson(targetDir, options)) {
2015
- updated.add("package.json");
2016
- }
2017
- if (await updatePortsIndex(targetDir, billingNames, config, {
2018
- ...options,
2019
- generationOptions: {
2020
- auth: false,
2021
- tenant: false,
2022
- events: false,
2023
- softDelete: false,
2024
- },
2025
- })) {
2026
- updated.add(config.paths.ports);
2027
- }
2028
- if (await updatePaymentsEntitlementsPort(targetDir, config, options)) {
2029
- updated.add(config.paths.ports);
2030
- updated.add(config.paths.infrastructurePorts);
2031
- }
2032
- if (await updateInfrastructureDeferredPorts(targetDir, billingNames, config, options)) {
2033
- updated.add(config.paths.infrastructurePorts);
2034
- }
2035
- if (await updateBillingDrizzleSchemaIndex(targetDir, config, options)) {
2036
- updated.add(drizzleSchemaIndexPath(config));
2037
- }
2038
- if (await updateDrizzleRepositories(targetDir, billingNames, config, options)) {
2039
- updated.add(drizzleRepositoriesPath(config));
2040
- }
2041
- if (await updateDatabaseProviderPick(targetDir, config, options)) {
2042
- updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
2043
- }
2044
- if (await updateDatabaseProviderEntitlements(targetDir, config, options)) {
2045
- updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
2046
- }
2047
- if (await updateBillingTransactionPorts(targetDir, config, options)) {
2048
- updated.add(path.join(infrastructureDir(config), "db/transaction-ports.ts"));
2049
- }
2050
- if (await updateDatabaseResetTables(targetDir, config, options)) {
2051
- updated.add(path.join(infrastructureDir(config), "db/reset.ts"));
2052
- }
2053
- if (await updateBillingSharedErrors(targetDir, config, options)) {
2054
- updated.add(resourceSharedErrorsPath(config));
2055
- }
2056
- if (await updateBillingEnv(targetDir, options)) {
2057
- updated.add("lib/env.ts");
2058
- }
2059
- if (await updateBillingEnvExample(targetDir, options)) {
2060
- updated.add(".env.example");
2061
- }
2062
- const routesFile = await updateServerRoutes(targetDir, billingNames, config, options);
2063
- if (routesFile)
2064
- updated.add(routesFile);
2065
- return [...updated];
2066
- }
2067
- async function updatePackageJson(targetDir, options) {
2068
- const filePath = path.join(targetDir, "package.json");
2069
- const original = await readFile(filePath, "utf8");
2070
- const packageJson = JSON.parse(original);
2071
- packageJson.scripts = packageJson.scripts ?? {};
2072
- packageJson.scripts.test ??= "bun test";
2073
- if (options.webTestingDependency) {
2074
- packageJson.devDependencies = packageJson.devDependencies ?? {};
2075
- packageJson.devDependencies["@beignet/web"] ??=
2076
- packageJson.dependencies?.["@beignet/core"] ??
2077
- packageJson.dependencies?.["@beignet/next"] ??
2078
- "*";
2079
- }
2080
- const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
2081
- if (!options.dryRun)
2082
- await writeFile(filePath, next);
2083
- return next !== original;
1461
+ function typeHasTopLevelProperty(source, typeName, propertyName) {
1462
+ const body = typeBodySource(source, typeName);
1463
+ return body !== undefined && hasTopLevelObjectProperty(body, propertyName);
2084
1464
  }
2085
- async function updatePortsIndex(targetDir, names, config, options) {
2086
- const filePath = path.join(targetDir, config.paths.ports);
2087
- const original = await readFile(filePath, "utf8");
2088
- let next = original;
2089
- const repositoryPath = resourcePortFilePath(names, config);
2090
- const importLine = `import type { ${names.singularPascal}Repository } from "${relativeModule(config.paths.ports, repositoryPath)}";`;
2091
- if (!next.includes(importLine)) {
2092
- next = insertAfterImports(next, importLine);
2093
- }
2094
- if (options.generationOptions?.auth) {
2095
- const policyPath = path.join(config.paths.features, names.pluralKebab, "policy.ts");
2096
- const policyImportLine = `import type { AuthorizationContext as ${names.singularPascal}AuthorizationContext, ${names.singularCamel}Policy } from "${relativeModule(config.paths.ports, policyPath)}";`;
2097
- if (!next.includes(policyImportLine)) {
2098
- next = insertAfterImports(next, policyImportLine);
2099
- }
2100
- next = appendUnionTypeMember(next, "AppAuthorizationContext", `${names.singularPascal}AuthorizationContext`);
2101
- next = appendTupleTypeMember(next, "AppGate", `typeof ${names.singularCamel}Policy`);
2102
- next = appendGatePolicyMember(next, `typeof ${names.singularCamel}Policy`);
2103
- }
2104
- const entry = `\t${names.pluralCamel}: ${names.singularPascal}Repository;`;
2105
- if (!next.includes(entry.trim())) {
2106
- 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.`);
2107
- }
2108
- const transactionPorts = typeBodySource(next, "AppTransactionPorts");
2109
- if (transactionPorts !== undefined &&
2110
- !transactionPorts.includes(entry.trim())) {
2111
- next = replaceTransactionPortsRequired(next, entry, `Could not find AppTransactionPorts in ${config.paths.ports}. Add ${entry.trim()} manually, or restore the generated ports file before running make resource.`);
2112
- }
2113
- if (next === original)
2114
- return false;
2115
- if (!options.dryRun)
2116
- await writeFile(filePath, next);
2117
- return true;
2118
- }
2119
- async function updatePortIndex(targetDir, names, config, options) {
2120
- const filePath = path.join(targetDir, config.paths.ports);
2121
- const original = await readFile(filePath, "utf8");
2122
- let next = original;
2123
- const portPath = path.join(path.dirname(config.paths.ports), `${names.kebab}.ts`);
2124
- const importLine = `import type { ${names.interfaceName} } from "${relativeModule(config.paths.ports, portPath)}";`;
2125
- if (!next.includes(importLine)) {
2126
- next = insertAfterImports(next, importLine);
2127
- }
2128
- const entry = `\t${names.camel}: ${names.interfaceName};`;
2129
- if (!next.includes(entry.trim())) {
2130
- 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 port.`);
2131
- }
2132
- if (next === original)
2133
- return false;
2134
- if (!options.dryRun)
2135
- await writeFile(filePath, next);
2136
- return true;
2137
- }
2138
- async function updatePaymentsEntitlementsPort(targetDir, config, options) {
2139
- let changed = false;
2140
- const portsFilePath = path.join(targetDir, config.paths.ports);
2141
- const portsOriginal = await readFile(portsFilePath, "utf8");
2142
- let portsNext = addNamedTypeImport(portsOriginal, "EntitlementsPort", "@beignet/core/entitlements");
2143
- if (!/\bentitlements\s*:/.test(portsNext)) {
2144
- portsNext = insertTypeProperty(portsNext, "AppPorts", "entitlements: EntitlementsPort;", `Could not find AppPorts in ${config.paths.ports}. Add entitlements: EntitlementsPort; manually, or restore the generated ports file before running make payments.`);
2145
- }
2146
- if (portsNext !== portsOriginal) {
2147
- changed = true;
2148
- if (!options.dryRun)
2149
- await writeFile(portsFilePath, portsNext);
2150
- }
2151
- const infrastructureFilePath = path.join(targetDir, config.paths.infrastructurePorts);
2152
- const infrastructureOriginal = await readFile(infrastructureFilePath, "utf8");
2153
- const infrastructureNext = appendDeferredPortKey(infrastructureOriginal, "entitlements");
2154
- if (infrastructureNext === infrastructureOriginal &&
2155
- !/["']entitlements["']/.test(infrastructureOriginal)) {
2156
- throw new Error(`Could not find the deferred port list in ${config.paths.infrastructurePorts}. Add "entitlements" manually, or restore the generated infrastructure ports file before running make payments.`);
2157
- }
2158
- if (infrastructureNext !== infrastructureOriginal) {
2159
- changed = true;
2160
- if (!options.dryRun) {
2161
- await writeFile(infrastructureFilePath, infrastructureNext);
2162
- }
2163
- }
2164
- return changed;
2165
- }
2166
- async function updateInfrastructurePortStub(targetDir, names, config, options) {
2167
- const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2168
- const original = await readFile(filePath, "utf8");
2169
- let next = original;
2170
- const portPath = portFilePath(names, config);
2171
- const importLine = `import type { ${names.interfaceName} } from "${relativeModule(config.paths.infrastructurePorts, portPath)}";`;
2172
- if (!next.includes(importLine)) {
2173
- next = insertAfterImports(next, importLine);
2174
- }
2175
- const entry = portStubEntry(names);
2176
- const definePortsBody = definePortsBodySource(next);
2177
- if (definePortsBody === undefined ||
2178
- !hasTopLevelObjectProperty(definePortsBody, names.camel)) {
2179
- next = insertDefinePortsProperty(next, entry.trim(), `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add the ${names.interfaceName} stub manually, or restore the generated infrastructure ports file before running make port.`);
2180
- }
2181
- if (next === original)
2182
- return false;
2183
- if (!options.dryRun)
2184
- await writeFile(filePath, next);
2185
- return true;
2186
- }
2187
- async function updateInfrastructureAdapterWiring(targetDir, names, config, options) {
2188
- const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2189
- const original = await readFile(filePath, "utf8");
2190
- let next = original;
2191
- const adapterPath = adapterFilePath(names, config);
2192
- const adapterImportLine = `import { ${names.adapterFactoryName} } from "${relativeModule(config.paths.infrastructurePorts, adapterPath)}";`;
2193
- if (!next.includes(adapterImportLine)) {
2194
- next = insertAfterImports(next, adapterImportLine);
2195
- }
2196
- const portPath = portFilePath(names, config);
2197
- const portImportLine = `import type { ${names.interfaceName} } from "${relativeModule(config.paths.infrastructurePorts, portPath)}";`;
2198
- if (next.includes(portImportLine)) {
2199
- next = next.replace(`${portImportLine}\n`, "");
2200
- }
2201
- const adapter = adapterEntry(names);
2202
- const stub = portStubEntry(names);
2203
- if (next.includes(stub)) {
2204
- next = next.replace(stub, adapter);
2205
- }
2206
- else if (!next.includes(adapter)) {
2207
- throw new Error(`Could not find the generated ${names.interfaceName} infrastructure stub in ${config.paths.infrastructurePorts}. Wire ${names.adapterFactoryName}() manually or restore the generated stub before running make adapter.`);
2208
- }
2209
- if (next === original)
2210
- return false;
2211
- if (!options.dryRun)
2212
- await writeFile(filePath, next);
2213
- return true;
2214
- }
2215
- async function updateInfrastructurePorts(targetDir, names, config, options) {
2216
- const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2217
- const original = await readFile(filePath, "utf8");
2218
- let next = original;
2219
- const repositoryPath = path.join(path.dirname(config.paths.infrastructurePorts), names.pluralKebab, `in-memory-${names.singularKebab}-repository.ts`);
2220
- const importLine = `import { createInMemory${names.singularPascal}Repository } from "${relativeModule(config.paths.infrastructurePorts, repositoryPath)}";`;
2221
- if (!next.includes(importLine)) {
2222
- next = insertAfterImports(next, importLine);
2223
- }
2224
- const repositoryConst = `const ${names.pluralCamel} = createInMemory${names.singularPascal}Repository();`;
2225
- if (!next.includes(repositoryConst)) {
2226
- 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.`);
2227
- }
2228
- const entry = `\t${names.pluralCamel},`;
2229
- const definePortsBody = definePortsBodySource(next);
2230
- if (definePortsBody === undefined ||
2231
- !hasTopLevelObjectProperty(definePortsBody, names.pluralCamel)) {
2232
- next = insertDefinePortsProperty(next, entry.trim(), `Could not find definePorts({ in ${config.paths.infrastructurePorts}. Add ${entry.trim()} to appPorts manually, or restore the generated infrastructure ports file before running make resource.`);
2233
- }
2234
- const transactionEntry = `\t\t${names.pluralCamel},`;
2235
- const uowBody = noopUnitOfWorkBodySource(next);
2236
- if (uowBody !== undefined &&
2237
- !new RegExp(`\\b${names.pluralCamel}\\b\\s*,`).test(uowBody)) {
2238
- next = insertNoopUnitOfWorkProperty(next, transactionEntry.trim(), `Could not find the generated Unit of Work port list in ${config.paths.infrastructurePorts}. Add ${transactionEntry.trim()} manually, or restore the generated infrastructure ports file before running make resource.`);
2239
- }
2240
- if (next === original)
2241
- return false;
2242
- if (!options.dryRun)
2243
- await writeFile(filePath, next);
2244
- return true;
2245
- }
2246
- async function updateInfrastructureGatePolicies(targetDir, names, config, options) {
2247
- const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2248
- const original = await readFile(filePath, "utf8");
2249
- let next = original;
2250
- const policyPath = path.join(config.paths.features, names.pluralKebab, "policy.ts");
2251
- const importLine = `import { ${names.singularCamel}Policy } from "${relativeModule(config.paths.infrastructurePorts, policyPath)}";`;
2252
- if (!next.includes(importLine)) {
2253
- next = insertAfterImports(next, importLine);
2254
- }
2255
- 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.`);
2256
- if (next === original)
2257
- return false;
2258
- if (!options.dryRun)
2259
- await writeFile(filePath, next);
2260
- return true;
2261
- }
2262
- async function updateDrizzleSchemaIndex(targetDir, names, config, options) {
2263
- const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
2264
- if (!(await fileExists(filePath)))
2265
- return false;
2266
- const original = await readFile(filePath, "utf8");
2267
- const exportLine = `export { ${names.pluralCamel} } from "./${names.pluralKebab}";`;
2268
- if (original.includes(exportLine))
2269
- return false;
2270
- const next = `${original.trimEnd()}\n${exportLine}\n`;
2271
- if (!options.dryRun)
2272
- await writeFile(filePath, next);
2273
- return true;
2274
- }
2275
- async function updateBillingDrizzleSchemaIndex(targetDir, config, options) {
2276
- const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
2277
- if (!(await fileExists(filePath)))
2278
- return false;
2279
- const original = await readFile(filePath, "utf8");
2280
- const exportLine = 'export { billingAccounts } from "./billing";';
2281
- if (original.includes(exportLine))
2282
- return false;
2283
- const next = `${original.trimEnd()}\n${exportLine}\n`;
2284
- if (!options.dryRun)
2285
- await writeFile(filePath, next);
2286
- return true;
2287
- }
2288
- async function updateDrizzleRepositories(targetDir, names, config, options) {
2289
- const filePath = path.join(targetDir, drizzleRepositoriesPath(config));
2290
- if (!(await fileExists(filePath)))
2291
- return false;
2292
- const original = await readFile(filePath, "utf8");
2293
- let next = original;
2294
- const repositoryPath = drizzleResourceRepositoryFilePath(names, config);
2295
- const importLine = `import { createDrizzle${names.singularPascal}Repository } from "${relativeModule(drizzleRepositoriesPath(config), repositoryPath)}";`;
2296
- const repositoriesBody = createRepositoriesReturnBodySource(next);
2297
- const entry = `\t\t${names.pluralCamel}: createDrizzle${names.singularPascal}Repository(db),`;
2298
- if (repositoriesBody === undefined) {
2299
- throw new Error(`Could not find return { in createRepositories in ${drizzleRepositoriesPath(config)}. Add ${entry.trim()} manually, or restore the generated repositories file before running make resource.`);
2300
- }
2301
- if (!next.includes(importLine)) {
2302
- next = insertAfterImports(next, importLine);
2303
- }
2304
- if (!hasTopLevelObjectProperty(repositoriesBody, names.pluralCamel)) {
2305
- next = insertCreateRepositoriesProperty(next, entry.trim(), `Could not find return { in createRepositories in ${drizzleRepositoriesPath(config)}. Add ${entry.trim()} manually, or restore the generated repositories file before running make resource.`);
2306
- }
2307
- if (next === original)
2308
- return false;
2309
- if (!options.dryRun)
2310
- await writeFile(filePath, next);
2311
- return true;
2312
- }
2313
- async function updateDatabaseProviderPick(targetDir, config, options) {
2314
- const file = path.join(infrastructureDir(config), "db/provider.ts");
2315
- const filePath = path.join(targetDir, file);
2316
- if (!(await fileExists(filePath)))
2317
- return false;
2318
- const original = await readFile(filePath, "utf8");
2319
- const next = appendPickStringLiteralMember(original, "AppPorts", "billing");
2320
- if (next === original)
2321
- return false;
2322
- if (!options.dryRun)
2323
- await writeFile(filePath, next);
2324
- return true;
2325
- }
2326
- async function updateDatabaseProviderEntitlements(targetDir, config, options) {
2327
- const file = path.join(infrastructureDir(config), "db/provider.ts");
2328
- const filePath = path.join(targetDir, file);
2329
- if (!(await fileExists(filePath)))
2330
- return false;
2331
- const original = await readFile(filePath, "utf8");
2332
- let next = original;
2333
- const importLine = 'import { createBillingEntitlements } from "@/features/billing/entitlements";';
2334
- if (!next.includes(importLine)) {
2335
- next = insertAfterImports(next, importLine);
2336
- }
2337
- next = appendPickStringLiteralMember(next, "AppPorts", "entitlements");
2338
- if (!/\bconst\s+entitlements\s*=/.test(next)) {
2339
- const withEntitlementsConst = next.replace(/(\n\s*const repositories = createRepositories\(dbPort\.(?:drizzle|db)\);)/, `$1\n\t\tconst entitlements = createBillingEntitlements(repositories.billing);`);
2340
- if (withEntitlementsConst === next) {
2341
- throw new Error(`Could not find createRepositories(dbPort.drizzle) in ${file}. Add const entitlements = createBillingEntitlements(repositories.billing) manually, or restore the generated database provider before running make payments.`);
2342
- }
2343
- next = withEntitlementsConst;
2344
- }
2345
- if (!/\bentitlements\s*,/.test(next)) {
2346
- const withEntitlementsPort = next.replace(/(\n\s*\.\.\.repositories,\n)/, `$1\t\t\tentitlements,\n`);
2347
- if (withEntitlementsPort === next) {
2348
- throw new Error(`Could not find the repository spread in ${file}. Add entitlements to providedPorts manually, or restore the generated database provider before running make payments.`);
2349
- }
2350
- next = withEntitlementsPort;
2351
- }
2352
- if (next === original)
2353
- return false;
2354
- if (!options.dryRun)
2355
- await writeFile(filePath, next);
2356
- return true;
2357
- }
2358
- async function updateBillingTransactionPorts(targetDir, config, options) {
2359
- const file = path.join(infrastructureDir(config), "db/transaction-ports.ts");
2360
- const filePath = path.join(targetDir, file);
2361
- if (!(await fileExists(filePath)))
2362
- return false;
2363
- const original = await readFile(filePath, "utf8");
2364
- if (/Pick<AppTransactionPorts,\s*["']billing["']>/.test(original)) {
2365
- return false;
2366
- }
2367
- const repositoryProperty = /repositories:\s*([^;\n]+);/.exec(original);
2368
- if (!repositoryProperty) {
2369
- throw new Error(`Could not find repositories: ... in ${file}. Add billing to the transaction repository dependency manually, or restore the generated transaction ports file before running make payments.`);
2370
- }
2371
- const repositoryType = repositoryProperty[1]?.trim();
2372
- if (!repositoryType) {
2373
- throw new Error(`Could not update repositories in ${file}. Add billing to the transaction repository dependency manually, or restore the generated transaction ports file before running make payments.`);
2374
- }
2375
- const next = original.replace(repositoryProperty[0], `repositories: ${repositoryType} & Pick<AppTransactionPorts, "billing">;`);
2376
- if (next === original)
2377
- return false;
2378
- if (!options.dryRun)
2379
- await writeFile(filePath, next);
2380
- return true;
2381
- }
2382
- async function updateDatabaseResetTables(targetDir, config, options) {
2383
- const file = path.join(infrastructureDir(config), "db/reset.ts");
2384
- const filePath = path.join(targetDir, file);
2385
- if (!(await fileExists(filePath)))
2386
- return false;
2387
- const original = await readFile(filePath, "utf8");
2388
- if (/["']billing_accounts["']/.test(original))
2389
- return false;
2390
- const tables = arrayInitializerInfo(original, "tables");
2391
- if (!tables)
2392
- return false;
2393
- const nextArray = appendToArrayExpression(tables.text, [
2394
- '"billing_accounts"',
2395
- ]);
2396
- const next = `${original.slice(0, tables.start)}${nextArray}${original.slice(tables.end)}`;
2397
- if (next === original)
2398
- return false;
2399
- if (!options.dryRun)
2400
- await writeFile(filePath, next);
2401
- return true;
2402
- }
2403
- async function updateInfrastructureDeferredPorts(targetDir, names, config, options) {
2404
- const filePath = path.join(targetDir, config.paths.infrastructurePorts);
2405
- const original = await readFile(filePath, "utf8");
2406
- // Drizzle resources are provided by the app database provider at startup,
2407
- // so deferred-form ports files defer the new repository key. Identity-form
2408
- // ports files are left untouched.
2409
- const next = appendDeferredPortKey(original, names.pluralCamel);
2410
- if (next === original)
2411
- return false;
2412
- if (!options.dryRun)
2413
- await writeFile(filePath, next);
2414
- return true;
2415
- }
2416
- async function updateServerTransactionPorts(targetDir, names, config, options) {
2417
- const filePath = path.join(targetDir, config.paths.server);
2418
- const original = await readFile(filePath, "utf8");
2419
- let next = original;
2420
- const transactionPortsBody = drizzleTransactionPortsBodySource(next);
2421
- if (transactionPortsBody === undefined ||
2422
- hasTopLevelObjectProperty(transactionPortsBody, names.pluralCamel)) {
2423
- return false;
2424
- }
2425
- if (!/\bappPorts\b/.test(next)) {
2426
- throw new Error(`Could not find appPorts in ${config.paths.server}. Add ${names.pluralCamel}: appPorts.${names.pluralCamel} to createTransactionPorts manually, or restore the generated server file before running make resource.`);
2427
- }
2428
- const entry = `\t\t\t\t\t\t${names.pluralCamel}: appPorts.${names.pluralCamel},`;
2429
- next = insertDrizzleTransactionPortsProperty(next, entry.trim(), `Could not find createTransactionPorts({ in ${config.paths.server}. Add ${entry.trim()} manually, or restore the generated server file before running make resource.`);
2430
- if (next === original)
2431
- return false;
2432
- if (!options.dryRun)
2433
- await writeFile(filePath, next);
2434
- return true;
2435
- }
2436
- async function updateOpenApiRoute(targetDir, names, config, options) {
2437
- const filePath = path.join(targetDir, config.paths.openapiRoute);
2438
- if (!(await fileExists(filePath)))
2439
- return false;
2440
- const original = await readFile(filePath, "utf8");
2441
- const firstArg = firstCreateOpenAPIHandlerArgInfo(original);
2442
- if (!firstArg?.text.startsWith("["))
2443
- return false;
2444
- const contractNames = resourceContractNames(names, options.mode);
2445
- const listedContracts = identifiersFromArrayExpression(firstArg.text);
2446
- const missingContracts = contractNames.filter((contractName) => !listedContracts.has(contractName));
2447
- if (missingContracts.length === 0)
2448
- return false;
2449
- const importLine = `import { ${contractNames.join(", ")} } from "${aliasModule(resourceContractFilePath(names, config))}";`;
2450
- const nextArray = appendToArrayExpression(firstArg.text, missingContracts);
2451
- let next = `${original.slice(0, firstArg.start)}${nextArray}${original.slice(firstArg.end)}`;
2452
- if (!next.includes(importLine)) {
2453
- next = insertAfterImports(next, importLine);
2454
- }
2455
- if (next === original)
2456
- return false;
2457
- if (!options.dryRun)
2458
- await writeFile(filePath, next);
2459
- return true;
2460
- }
2461
- async function updateSharedErrors(targetDir, names, config, options) {
2462
- if (options.mode !== "resource")
2463
- return false;
2464
- const filePath = path.join(targetDir, resourceSharedErrorsPath(config));
2465
- if (!(await fileExists(filePath)))
2466
- return false;
2467
- const original = await readFile(filePath, "utf8");
2468
- const notFoundErrorName = `${names.singularPascal}NotFound`;
2469
- const conflictErrorName = `${names.singularPascal}Conflict`;
2470
- const missingErrorNames = [notFoundErrorName, conflictErrorName].filter((errorName) => !new RegExp(`\\b${errorName}\\b`).test(original));
2471
- if (missingErrorNames.length === 0)
2472
- return false;
2473
- const expectedErrorNames = missingErrorNames.join(" and ");
2474
- const defineErrorsStart = original.indexOf("defineErrors(");
2475
- if (defineErrorsStart === -1) {
2476
- throw new Error(`Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
2477
- }
2478
- const openBrace = original.indexOf("{", defineErrorsStart);
2479
- if (openBrace === -1) {
2480
- throw new Error(`Could not find defineErrors({ in ${resourceSharedErrorsPath(config)}. Add ${expectedErrorNames} manually, or restore the generated shared error catalog before running make resource.`);
2481
- }
2482
- const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
2483
- if (closeBrace === -1) {
2484
- 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.`);
2485
- }
2486
- const entries = [];
2487
- if (missingErrorNames.includes(notFoundErrorName)) {
2488
- entries.push(`${notFoundErrorName}: {
2489
- \t\tcode: "${constantCase(names.singularKebab)}_NOT_FOUND",
2490
- \t\tstatus: 404,
2491
- \t\tmessage: "${names.singularPascal} not found",
2492
- \t},`);
2493
- }
2494
- if (missingErrorNames.includes(conflictErrorName)) {
2495
- entries.push(`${conflictErrorName}: {
2496
- \t\tcode: "${constantCase(names.singularKebab)}_CONFLICT",
2497
- \t\tstatus: 409,
2498
- \t\tmessage: "${names.singularPascal} was changed by another request",
2499
- \t},`);
2500
- }
2501
- const next = insertBeforeClosingBrace(original, openBrace, closeBrace, entries.join("\n"));
2502
- if (next === original)
2503
- return false;
2504
- if (!options.dryRun)
2505
- await writeFile(filePath, next);
2506
- return true;
2507
- }
2508
- async function updateBillingSharedErrors(targetDir, config, options) {
2509
- const file = resourceSharedErrorsPath(config);
2510
- const filePath = path.join(targetDir, file);
2511
- if (!(await fileExists(filePath)))
2512
- return false;
2513
- const original = await readFile(filePath, "utf8");
2514
- const missingEntries = [
2515
- {
2516
- name: "BillingAccountNotFound",
2517
- entry: `BillingAccountNotFound: {
2518
- \t\tcode: "BILLING_ACCOUNT_NOT_FOUND",
2519
- \t\tstatus: 404,
2520
- \t\tmessage: "Billing account not found",
2521
- \t},`,
2522
- },
2523
- {
2524
- name: "IdempotencyConflict",
2525
- entry: `IdempotencyConflict: {
2526
- \t\tcode: "IDEMPOTENCY_CONFLICT",
2527
- \t\tstatus: 409,
2528
- \t\tmessage: "Idempotency key was already used with a different request",
2529
- \t},`,
2530
- },
2531
- {
2532
- name: "IdempotencyInProgress",
2533
- entry: `IdempotencyInProgress: {
2534
- \t\tcode: "IDEMPOTENCY_IN_PROGRESS",
2535
- \t\tstatus: 409,
2536
- \t\tmessage: "Idempotency key is already being processed",
2537
- \t},`,
2538
- },
2539
- {
2540
- name: "BillingCheckoutUnavailable",
2541
- entry: `BillingCheckoutUnavailable: {
2542
- \t\tcode: "BILLING_CHECKOUT_UNAVAILABLE",
2543
- \t\tstatus: 502,
2544
- \t\tmessage: "Billing checkout is unavailable",
2545
- \t},`,
2546
- },
2547
- ].filter(({ name }) => !new RegExp(`\\b${name}\\b`).test(original));
2548
- if (missingEntries.length === 0)
2549
- return false;
2550
- const defineErrorsStart = original.indexOf("defineErrors(");
2551
- if (defineErrorsStart === -1) {
2552
- throw new Error(`Could not find defineErrors({ in ${file}. Add BillingAccountNotFound and BillingCheckoutUnavailable manually, or restore the generated shared error catalog before running make payments.`);
2553
- }
2554
- const openBrace = original.indexOf("{", defineErrorsStart);
2555
- if (openBrace === -1) {
2556
- throw new Error(`Could not find defineErrors({ in ${file}. Add BillingAccountNotFound and BillingCheckoutUnavailable manually, or restore the generated shared error catalog before running make payments.`);
2557
- }
2558
- const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
2559
- if (closeBrace === -1) {
2560
- throw new Error(`Could not update ${file} because the error catalog object is malformed. Add BillingAccountNotFound and BillingCheckoutUnavailable manually, or restore the generated shared error catalog before running make payments.`);
2561
- }
2562
- const next = insertBeforeClosingBrace(original, openBrace, closeBrace, missingEntries.map(({ entry }) => entry).join("\n"));
2563
- if (next === original)
2564
- return false;
2565
- if (!options.dryRun)
2566
- await writeFile(filePath, next);
2567
- return true;
2568
- }
2569
- async function updateBillingEnv(targetDir, options) {
2570
- const file = "lib/env.ts";
2571
- const filePath = path.join(targetDir, file);
2572
- const original = await readOptionalFile(filePath);
2573
- if (original === undefined) {
2574
- throw new Error(`Could not find ${file}. Add BILLING_TEAM_PRICE_ID to your env schema manually, or restore the generated env file before running make payments.`);
2575
- }
2576
- if (/\bBILLING_TEAM_PRICE_ID\b/.test(original))
2577
- return false;
2578
- const match = /\n([\t ]*)LOG_LEVEL:/.exec(original);
2579
- if (!match) {
2580
- throw new Error(`Could not find LOG_LEVEL in ${file}. Add BILLING_TEAM_PRICE_ID: z.string().min(1).default("price_team_example") to the createEnv server block manually, or restore the generated env file before running make payments.`);
2581
- }
2582
- const entry = `\n${match[1]}BILLING_TEAM_PRICE_ID: z.string().min(1).default("price_team_example"),`;
2583
- const next = `${original.slice(0, match.index)}${entry}${original.slice(match.index)}`;
2584
- if (!options.dryRun)
2585
- await writeFile(filePath, next);
2586
- return true;
2587
- }
2588
- async function updateBillingEnvExample(targetDir, options) {
2589
- const file = ".env.example";
2590
- const filePath = path.join(targetDir, file);
2591
- const original = await readOptionalFile(filePath);
2592
- if (original === undefined)
2593
- return false;
2594
- if (/^BILLING_TEAM_PRICE_ID=/m.test(original))
2595
- return false;
2596
- const match = /\nLOG_LEVEL=/.exec(original);
2597
- const next = match
2598
- ? `${original.slice(0, match.index)}\nBILLING_TEAM_PRICE_ID=price_team_example${original.slice(match.index)}`
2599
- : `${original.trimEnd()}\nBILLING_TEAM_PRICE_ID=price_team_example\n`;
2600
- if (!options.dryRun)
2601
- await writeFile(filePath, next);
2602
- return true;
2603
- }
2604
- async function updateServerRoutes(targetDir, names, config, options) {
2605
- const filePath = path.join(targetDir, config.paths.server);
2606
- const original = await readFile(filePath, "utf8");
2607
- const routeRegistryPath = routeRegistryFileFromServerSource(original, config.paths.server);
2608
- if (routeRegistryPath) {
2609
- await assertImportedRoutesFileExists(targetDir, routeRegistryPath);
2610
- return updateDefineRoutesFile(targetDir, routeRegistryPath, names, config, options);
2611
- }
2612
- return updateDefineRoutesFile(targetDir, config.paths.server, names, config, options);
2613
- }
2614
- async function updateDefineRoutesFile(targetDir, file, names, config, options) {
2615
- const filePath = path.join(targetDir, file);
2616
- const original = await readFile(filePath, "utf8");
2617
- let next = original;
2618
- const routeGroupName = `${names.singularCamel}Routes`;
2619
- const routeGroupPath = path.join(config.paths.features, names.pluralKebab, "routes.ts");
2620
- const importLine = `import { ${routeGroupName} } from "${relativeModule(file, routeGroupPath)}";`;
2621
- if (!next.includes(importLine)) {
2622
- next = insertAfterImports(next, importLine);
2623
- }
2624
- if (!routeGroupIsRegistered(next, routeGroupName)) {
2625
- const firstArg = firstDefineRoutesArgInfo(next);
2626
- if (!firstArg?.text.startsWith("[")) {
2627
- throw new Error(`Could not find a central defineRoutes([...]) registration in ${file}. Import ${routeGroupName} and add it to defineRoutes manually.`);
2628
- }
2629
- const nextArray = appendToArrayExpression(firstArg.text, [routeGroupName]);
2630
- next = `${next.slice(0, firstArg.start)}${nextArray}${next.slice(firstArg.end)}`;
2631
- }
2632
- if (!routeGroupIsRegistered(next, routeGroupName)) {
2633
- throw new Error(`Could not find a central defineRoutes([...]) registration in ${file}. Import ${routeGroupName} and add it to defineRoutes manually.`);
2634
- }
2635
- if (next === original)
2636
- return undefined;
2637
- if (!options.dryRun)
2638
- await writeFile(filePath, next);
2639
- return file;
2640
- }
2641
- function routeGroupIsRegistered(source, routeGroupName) {
2642
- const firstArg = firstDefineRoutesArgInfo(source);
2643
- if (!firstArg?.text.startsWith("["))
2644
- return false;
2645
- return identifiersFromArrayExpression(firstArg.text).has(routeGroupName);
2646
- }
2647
- function routeRegistryFileFromServerSource(source, serverFile) {
2648
- const serverOptions = firstCreateNextServerArgInfo(source);
2649
- if (!serverOptions)
2650
- return undefined;
2651
- const routeIdentifier = routeOptionIdentifier(serverOptions.text);
2652
- if (!routeIdentifier)
2653
- return undefined;
2654
- const imported = parseNamedImportSources(source).get(routeIdentifier);
2655
- if (!imported)
2656
- return undefined;
2657
- return resolveImportFile(imported.sourcePath, serverFile);
2658
- }
2659
- function routeOptionIdentifier(source) {
2660
- const explicitMatch = /(?:^|[,{])\s*routes\s*:\s*([A-Za-z_$][\w$]*)/m.exec(source);
2661
- if (explicitMatch)
2662
- return explicitMatch[1];
2663
- if (/(?:^|[,{])\s*routes\s*(?:[,}])/m.test(source)) {
2664
- return "routes";
2665
- }
2666
- return undefined;
2667
- }
2668
- function parseNamedImportSources(source) {
2669
- const imports = new Map();
2670
- const importRegex = /import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
2671
- for (const match of source.matchAll(importRegex)) {
2672
- for (const member of match[1].split(",")) {
2673
- const parsed = parseImportMember(member);
2674
- if (!parsed)
2675
- continue;
2676
- imports.set(parsed.localName, {
2677
- importedName: parsed.importedName,
2678
- sourcePath: match[2],
2679
- });
2680
- }
2681
- }
2682
- return imports;
2683
- }
2684
- function parseImportMember(member) {
2685
- const trimmed = member.trim();
2686
- if (!trimmed)
2687
- return undefined;
2688
- const aliasMatch = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(trimmed);
2689
- if (aliasMatch) {
2690
- return {
2691
- importedName: aliasMatch[1],
2692
- localName: aliasMatch[2],
2693
- };
2694
- }
2695
- if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
2696
- return {
2697
- importedName: trimmed,
2698
- localName: trimmed,
2699
- };
2700
- }
2701
- return undefined;
2702
- }
2703
- function resolveImportFile(sourcePath, importerFile) {
2704
- if (sourcePath.startsWith("@/")) {
2705
- return `${sourcePath.slice(2)}.ts`;
2706
- }
2707
- if (sourcePath.startsWith(".")) {
2708
- return `${normalizePath(path.join(path.dirname(importerFile), sourcePath))}.ts`;
2709
- }
2710
- return undefined;
2711
- }
2712
- async function assertImportedRoutesFileExists(targetDir, file) {
2713
- try {
2714
- await stat(path.join(targetDir, file));
2715
- }
2716
- catch {
2717
- throw new Error(`Could not find the route registry ${file}. Restore the imported routes file or add the new route group manually.`);
2718
- }
2719
- }
2720
- function firstDefineRoutesArgInfo(source) {
2721
- const match = /defineRoutes(?:<[^>]+>)?\(/.exec(source);
2722
- if (!match)
2723
- return undefined;
2724
- return firstCallArgInfo(source, match.index + match[0].length);
2725
- }
2726
- function firstCreateNextServerArgInfo(source) {
2727
- const match = /createNextServer(?:<[^>]+>)?\(/.exec(source);
2728
- if (!match)
2729
- return undefined;
2730
- return firstCallArgInfo(source, match.index + match[0].length);
2731
- }
2732
- function firstCreateOpenAPIHandlerArgInfo(source) {
2733
- const start = source.indexOf("createOpenAPIHandler(");
2734
- if (start === -1)
2735
- return undefined;
2736
- return firstCallArgInfo(source, start + "createOpenAPIHandler(".length);
2737
- }
2738
- function firstCallArgInfo(source, argStart) {
2739
- let depth = 0;
2740
- let inString;
2741
- let escaped = false;
2742
- for (let index = argStart; index < source.length; index++) {
2743
- const char = source[index];
2744
- if (inString) {
2745
- if (escaped) {
2746
- escaped = false;
2747
- }
2748
- else if (char === "\\") {
2749
- escaped = true;
2750
- }
2751
- else if (char === inString) {
2752
- inString = undefined;
2753
- }
2754
- continue;
2755
- }
2756
- if (char === '"' || char === "'" || char === "`") {
2757
- inString = char;
2758
- continue;
2759
- }
2760
- if (char === "[" || char === "(" || char === "{") {
2761
- depth++;
2762
- continue;
2763
- }
2764
- if (char === "]" || char === ")" || char === "}") {
2765
- if (depth === 0) {
2766
- return trimmedSlice(source, argStart, index);
2767
- }
2768
- depth--;
2769
- continue;
2770
- }
2771
- if (char === "," && depth === 0) {
2772
- return trimmedSlice(source, argStart, index);
2773
- }
2774
- }
2775
- return undefined;
2776
- }
2777
- function trimmedSlice(source, start, end) {
2778
- let trimmedStart = start;
2779
- let trimmedEnd = end;
2780
- while (trimmedStart < trimmedEnd && /\s/.test(source[trimmedStart])) {
2781
- trimmedStart++;
2782
- }
2783
- while (trimmedEnd > trimmedStart && /\s/.test(source[trimmedEnd - 1])) {
2784
- trimmedEnd--;
2785
- }
2786
- return {
2787
- text: source.slice(trimmedStart, trimmedEnd),
2788
- start: trimmedStart,
2789
- end: trimmedEnd,
2790
- };
2791
- }
2792
- function replaceRequired(source, searchValue, replaceValue, message) {
2793
- const next = source.replace(searchValue, replaceValue);
2794
- if (next === source)
2795
- throw new Error(message);
2796
- return next;
2797
- }
2798
- function insertTypeProperty(source, typeName, property, message) {
2799
- const match = new RegExp(`export\\s+type\\s+${typeName}\\b`).exec(source);
2800
- if (!match)
2801
- throw new Error(message);
2802
- const openBrace = source.indexOf("{", match.index);
2803
- if (openBrace === -1)
2804
- throw new Error(message);
2805
- const closeBrace = matchingBraceIndex(source, openBrace);
2806
- if (closeBrace === -1)
2807
- throw new Error(message);
2808
- return insertBeforeClosingBrace(source, openBrace, closeBrace, property);
2809
- }
2810
- function typeBodySource(source, typeName) {
2811
- const match = new RegExp(`export\\s+type\\s+${typeName}\\b`).exec(source);
2812
- if (!match)
2813
- return undefined;
2814
- const openBrace = source.indexOf("{", match.index);
2815
- if (openBrace === -1)
2816
- return undefined;
2817
- const closeBrace = matchingBraceIndex(source, openBrace);
2818
- if (closeBrace === -1)
2819
- return undefined;
2820
- return source.slice(openBrace + 1, closeBrace);
2821
- }
2822
- function typeHasTopLevelProperty(source, typeName, propertyName) {
2823
- const body = typeBodySource(source, typeName);
2824
- return body !== undefined && hasTopLevelObjectProperty(body, propertyName);
2825
- }
2826
- function appendUnionTypeMember(source, typeName, member) {
2827
- const match = new RegExp(`(export\\s+type\\s+${typeName}\\s*=)([\\s\\S]*?);`).exec(source);
2828
- if (!match)
2829
- return source;
2830
- const declaration = match[0];
2831
- if (new RegExp(`\\b${member}\\b`).test(declaration))
2832
- return source;
2833
- const nextDeclaration = declaration.replace(/;\s*$/, ` | ${member};`);
2834
- return `${source.slice(0, match.index)}${nextDeclaration}${source.slice(match.index + declaration.length)}`;
2835
- }
2836
- function appendPickStringLiteralMember(source, typeName, member) {
2837
- const match = new RegExp(`Pick<\\s*${typeName}\\s*,([\\s\\S]*?)>`).exec(source);
2838
- if (!match)
2839
- return source;
2840
- const literal = `"${member}"`;
2841
- if (match[1].includes(literal))
2842
- return source;
2843
- const union = match[1];
2844
- const indent = union.match(/\n([\t ]*)\|/)?.[1] ?? "\t";
2845
- const nextUnion = union.includes("\n")
2846
- ? `${union.replace(/\s*$/, "")}\n${indent}| ${literal}\n`
2847
- : `${union.trimEnd()} | ${literal}`;
2848
- return `${source.slice(0, match.index)}Pick<${typeName},${nextUnion}>${source.slice(match.index + match[0].length)}`;
2849
- }
2850
- function appendTupleTypeMember(source, typeName, member) {
2851
- const match = new RegExp(`export\\s+type\\s+${typeName}\\s*=\\s*`).exec(source);
2852
- if (!match)
2853
- return source;
2854
- const tupleStart = source.indexOf("[", match.index);
2855
- if (tupleStart === -1)
2856
- return source;
2857
- const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
2858
- if (tupleEnd === -1)
2859
- return source;
2860
- const tuple = source.slice(tupleStart, tupleEnd + 1);
2861
- if (tuple.includes(member))
2862
- return source;
2863
- const nextTuple = appendToArrayExpression(tuple, [member]);
2864
- return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(tupleEnd + 1)}`;
2865
- }
2866
- function appendGatePolicyMember(source, member) {
2867
- const gateMatch = /\bGatePort\s*</.exec(source);
2868
- if (!gateMatch)
2869
- return source;
2870
- const tupleStart = source.indexOf("[", gateMatch.index);
2871
- if (tupleStart === -1)
2872
- return source;
2873
- const tupleEnd = matchingDelimiterIndex(source, tupleStart, "[", "]");
2874
- if (tupleEnd === -1)
2875
- return source;
2876
- const tuple = source.slice(tupleStart, tupleEnd + 1);
2877
- if (tuple.includes(member))
2878
- return source;
2879
- const nextTuple = appendToArrayExpression(tuple, [member]);
2880
- return `${source.slice(0, tupleStart)}${nextTuple}${source.slice(tupleEnd + 1)}`;
2881
- }
2882
- function appendCreateGatePolicy(source, policy, failureMessage) {
2883
- const policyMatch = /\bpolicies\s*:\s*\[/.exec(source);
2884
- if (!policyMatch) {
2885
- throw new Error(failureMessage);
2886
- }
2887
- const arrayStart = source.indexOf("[", policyMatch.index);
2888
- if (arrayStart === -1) {
2889
- throw new Error(failureMessage);
2890
- }
2891
- const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
2892
- if (arrayEnd === -1) {
2893
- throw new Error(failureMessage);
2894
- }
2895
- const policies = source.slice(arrayStart, arrayEnd + 1);
2896
- if (policies.includes(policy))
2897
- return source;
2898
- const nextPolicies = appendToArrayExpression(policies, [policy]);
2899
- return `${source.slice(0, arrayStart)}${nextPolicies}${source.slice(arrayEnd + 1)}`;
2900
- }
2901
- function insertDefinePortsProperty(source, property, message) {
2902
- const body = definePortsBodyRange(source);
2903
- if (!body)
2904
- throw new Error(message);
2905
- return insertBeforeClosingBrace(source, body.openBrace, body.closeBrace, property);
2906
- }
2907
- function insertNoopUnitOfWorkProperty(source, property, message) {
2908
- const body = noopUnitOfWorkBodyRange(source);
2909
- if (!body)
2910
- throw new Error(message);
2911
- return insertBeforeClosingBrace(source, body.openBrace, body.closeBrace, property);
2912
- }
2913
- function insertDrizzleTransactionPortsProperty(source, property, message) {
2914
- const body = drizzleTransactionPortsBodyRange(source);
2915
- if (!body)
2916
- throw new Error(message);
2917
- return insertBeforeClosingBrace(source, body.openBrace, body.closeBrace, property);
2918
- }
2919
- function insertCreateRepositoriesProperty(source, property, message) {
2920
- const body = createRepositoriesReturnBodyRange(source);
2921
- if (!body)
2922
- throw new Error(message);
2923
- return insertBeforeClosingBrace(source, body.openBrace, body.closeBrace, property);
2924
- }
2925
- function noopUnitOfWorkBodySource(source) {
2926
- const body = noopUnitOfWorkBodyRange(source);
2927
- if (!body)
2928
- return undefined;
2929
- return source.slice(body.openBrace + 1, body.closeBrace);
2930
- }
2931
- function drizzleTransactionPortsBodySource(source) {
2932
- const body = drizzleTransactionPortsBodyRange(source);
2933
- if (!body)
2934
- return undefined;
2935
- return source.slice(body.openBrace + 1, body.closeBrace);
2936
- }
2937
- function createRepositoriesReturnBodySource(source) {
2938
- const body = createRepositoriesReturnBodyRange(source);
2939
- if (!body)
2940
- return undefined;
2941
- return source.slice(body.openBrace + 1, body.closeBrace);
2942
- }
2943
- function definePortsBodySource(source) {
2944
- const body = definePortsBodyRange(source);
2945
- if (!body)
2946
- return undefined;
2947
- return source.slice(body.openBrace + 1, body.closeBrace);
2948
- }
2949
- function definePortsBodyRange(source) {
2950
- const outer = definePortsOuterBodyRange(source);
2951
- if (!outer)
2952
- return undefined;
2953
- // The curried `definePorts<AppPorts>()({ bound, deferred })` form binds app
2954
- // ports inside `bound`; generated bindings belong there.
2955
- return (topLevelBoundObjectRange(source, outer.openBrace, outer.closeBrace) ?? outer);
2956
- }
2957
- function definePortsOuterBodyRange(source) {
2958
- const match = /definePorts(?:<[^>]+>)?\(\s*(?:\)\s*\(\s*)?\{/.exec(source);
2959
- if (!match)
2960
- return undefined;
2961
- const openBrace = source.indexOf("{", match.index);
2962
- const closeBrace = matchingBraceIndex(source, openBrace);
2963
- if (closeBrace === -1)
2964
- return undefined;
2965
- return { openBrace, closeBrace };
2966
- }
2967
- function appendDeferredPortKey(source, portKey) {
2968
- const outer = definePortsOuterBodyRange(source);
2969
- if (!outer)
2970
- return source;
2971
- const body = source.slice(outer.openBrace + 1, outer.closeBrace);
2972
- if (!hasTopLevelObjectProperty(body, "deferred"))
2973
- return source;
2974
- const deferredMatch = /\bdeferred\s*:\s*\[/.exec(body);
2975
- if (!deferredMatch)
2976
- return source;
2977
- const arrayStart = source.indexOf("[", outer.openBrace + 1 + deferredMatch.index);
2978
- const arrayEnd = matchingDelimiterIndex(source, arrayStart, "[", "]");
2979
- if (arrayEnd === -1)
2980
- return source;
2981
- const tuple = source.slice(arrayStart, arrayEnd + 1);
2982
- if (new RegExp(`["']${portKey}["']`).test(tuple))
2983
- return source;
2984
- const nextTuple = appendToArrayExpression(tuple, [`"${portKey}"`]);
2985
- return `${source.slice(0, arrayStart)}${nextTuple}${source.slice(arrayEnd + 1)}`;
2986
- }
2987
- /**
2988
- * Add a name to an `import type { ... } from "<module>";` statement, creating
2989
- * the import when the module is not imported yet. Names are inserted in sorted
2990
- * position and single-line versus multiline formatting is preserved.
2991
- */
2992
- function addNamedTypeImport(source, name, moduleName) {
2993
- const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2994
- const importPattern = new RegExp(`import type \\{([^}]*)\\} from "${escapedModule}";`);
2995
- const match = importPattern.exec(source);
2996
- if (!match) {
2997
- return insertAfterImports(source, `import type { ${name} } from "${moduleName}";`);
2998
- }
2999
- const names = match[1]
3000
- .split(",")
3001
- .map((entry) => entry.trim())
3002
- .filter(Boolean);
3003
- if (names.includes(name))
3004
- return source;
3005
- const insertIndex = names.findIndex((existing) => existing.localeCompare(name) > 0);
3006
- if (insertIndex === -1)
3007
- names.push(name);
3008
- else
3009
- names.splice(insertIndex, 0, name);
3010
- const indent = match[1].match(/\n([\t ]+)/)?.[1] ?? "\t";
3011
- const replacement = match[1].includes("\n")
3012
- ? `import type {\n${indent}${names.join(`,\n${indent}`)},\n} from "${moduleName}";`
3013
- : `import type { ${names.join(", ")} } from "${moduleName}";`;
3014
- return `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`;
3015
- }
3016
- function addNamedImport(source, name, moduleName) {
3017
- const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3018
- const importPattern = new RegExp(`import \\{([^}]*)\\} from "${escapedModule}";`);
3019
- const match = importPattern.exec(source);
3020
- if (!match) {
3021
- return insertAfterImports(source, `import { ${name} } from "${moduleName}";`);
3022
- }
3023
- const names = match[1]
3024
- .split(",")
3025
- .map((entry) => entry.trim())
3026
- .filter(Boolean);
3027
- if (names.includes(name))
3028
- return source;
3029
- const insertIndex = names.findIndex((existing) => existing.localeCompare(name) > 0);
3030
- if (insertIndex === -1)
3031
- names.push(name);
3032
- else
3033
- names.splice(insertIndex, 0, name);
3034
- const indent = match[1].match(/\n([\t ]+)/)?.[1] ?? "\t";
3035
- const replacement = match[1].includes("\n")
3036
- ? `import {\n${indent}${names.join(`,\n${indent}`)},\n} from "${moduleName}";`
3037
- : `import { ${names.join(", ")} } from "${moduleName}";`;
3038
- return `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`;
3039
- }
3040
- function topLevelBoundObjectRange(source, openBrace, closeBrace) {
3041
- const body = source.slice(openBrace + 1, closeBrace);
3042
- if (!hasTopLevelObjectProperty(body, "bound"))
3043
- return undefined;
3044
- const boundMatch = /\bbound\s*:\s*\{/.exec(body);
3045
- if (!boundMatch)
3046
- return undefined;
3047
- const boundOpen = source.indexOf("{", openBrace + 1 + boundMatch.index);
3048
- const boundClose = matchingBraceIndex(source, boundOpen);
3049
- if (boundClose === -1)
3050
- return undefined;
3051
- return { openBrace: boundOpen, closeBrace: boundClose };
3052
- }
3053
- function hasTopLevelObjectProperty(source, propertyName) {
3054
- let depth = 0;
3055
- let inString;
3056
- let escaped = false;
3057
- let lineComment = false;
3058
- let blockComment = false;
3059
- for (let index = 0; index < source.length; index++) {
3060
- const char = source[index];
3061
- const next = source[index + 1];
3062
- if (lineComment) {
3063
- if (char === "\n")
3064
- lineComment = false;
3065
- continue;
3066
- }
3067
- if (blockComment) {
3068
- if (char === "*" && next === "/") {
3069
- blockComment = false;
3070
- index++;
3071
- }
3072
- continue;
3073
- }
3074
- if (inString) {
3075
- if (escaped) {
3076
- escaped = false;
3077
- }
3078
- else if (char === "\\") {
3079
- escaped = true;
3080
- }
3081
- else if (char === inString) {
3082
- inString = undefined;
3083
- }
3084
- continue;
3085
- }
3086
- if (char === "/" && next === "/") {
3087
- lineComment = true;
3088
- index++;
3089
- continue;
3090
- }
3091
- if (char === "/" && next === "*") {
3092
- blockComment = true;
3093
- index++;
3094
- continue;
3095
- }
3096
- if (char === '"' || char === "'" || char === "`") {
3097
- inString = char;
3098
- continue;
3099
- }
3100
- if (char === "{" || char === "[" || char === "(") {
3101
- depth++;
3102
- continue;
3103
- }
3104
- if (char === "}" || char === "]" || char === ")") {
3105
- depth = Math.max(0, depth - 1);
3106
- continue;
3107
- }
3108
- if (depth !== 0 || !identifierStartPattern.test(char)) {
3109
- continue;
3110
- }
3111
- const property = identifierAt(source, index);
3112
- if (property !== propertyName) {
3113
- index += property.length - 1;
3114
- continue;
3115
- }
3116
- let cursor = index + property.length;
3117
- while (/\s/.test(source[cursor] ?? ""))
3118
- cursor++;
3119
- if (source[cursor] === ":" || source[cursor] === ",")
3120
- return true;
3121
- index = cursor;
3122
- }
3123
- return false;
3124
- }
3125
- const identifierStartPattern = /[$A-Z_a-z]/;
3126
- const identifierPartPattern = /[$\w]/;
3127
- function identifierAt(source, index) {
3128
- let cursor = index;
3129
- while (identifierPartPattern.test(source[cursor] ?? "")) {
3130
- cursor++;
3131
- }
3132
- return source.slice(index, cursor);
3133
- }
3134
- function noopUnitOfWorkBodyRange(source) {
3135
- const match = /createNoopUnitOfWork\(\s*\(\)\s*=>\s*\(\s*\{/.exec(source);
3136
- if (!match)
3137
- return undefined;
3138
- const openBrace = source.indexOf("{", match.index);
3139
- const closeBrace = matchingBraceIndex(source, openBrace);
3140
- if (closeBrace === -1)
3141
- return undefined;
3142
- return { openBrace, closeBrace };
3143
- }
3144
- function drizzleTransactionPortsBodyRange(source) {
3145
- const match = /createTransactionPorts\s*:\s*\([^)]*\)\s*=>\s*\(\s*\{/.exec(source);
3146
- if (!match)
3147
- return undefined;
3148
- const openBrace = source.indexOf("{", match.index);
3149
- const closeBrace = matchingBraceIndex(source, openBrace);
3150
- if (closeBrace === -1)
3151
- return undefined;
3152
- return { openBrace, closeBrace };
3153
- }
3154
- function createRepositoriesReturnBodyRange(source) {
3155
- const functionBody = createRepositoriesFunctionBodyRange(source);
3156
- if (!functionBody)
3157
- return undefined;
3158
- const functionSource = source.slice(functionBody.openBrace + 1, functionBody.closeBrace);
3159
- const match = /return\s*\{\s*/.exec(functionSource);
3160
- if (!match)
3161
- return undefined;
3162
- const openBrace = source.indexOf("{", functionBody.openBrace + 1 + match.index);
3163
- if (openBrace === -1)
3164
- return undefined;
3165
- const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
3166
- if (closeBrace === -1 || closeBrace > functionBody.closeBrace) {
3167
- return undefined;
3168
- }
3169
- return { openBrace, closeBrace };
3170
- }
3171
- function createRepositoriesFunctionBodyRange(source) {
3172
- const match = /(?:export\s+)?function\s+createRepositories\b/.exec(source);
3173
- if (!match)
3174
- return undefined;
3175
- const openParen = source.indexOf("(", match.index);
3176
- if (openParen === -1)
3177
- return undefined;
3178
- const closeParen = matchingDelimiterIndex(source, openParen, "(", ")");
3179
- if (closeParen === -1)
3180
- return undefined;
3181
- const bodyMatch = /(?:\s*:[^{]+)?\s*\{/.exec(source.slice(closeParen + 1));
3182
- if (!bodyMatch)
3183
- return undefined;
3184
- const openBrace = closeParen + 1 + bodyMatch.index + bodyMatch[0].lastIndexOf("{");
3185
- const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
3186
- if (closeBrace === -1)
3187
- return undefined;
3188
- return { openBrace, closeBrace };
3189
- }
3190
- function insertBeforeClosingBrace(source, openBrace, closeBrace, property) {
3191
- const body = source.slice(openBrace + 1, closeBrace);
3192
- const propertyIndent = detectPropertyIndent(body);
3193
- const closingIndent = source.slice(0, closeBrace).match(/\n([\t ]*)$/)?.[1] ?? "";
3194
- const trimmedBody = body.replace(/\s*$/, "");
3195
- const nextBody = `${trimmedBody}\n${propertyIndent}${property}\n${closingIndent}`;
3196
- return `${source.slice(0, openBrace + 1)}${nextBody}${source.slice(closeBrace)}`;
3197
- }
3198
- function detectPropertyIndent(body) {
3199
- return (body
3200
- .split("\n")
3201
- .find((line) => line.trim())
3202
- ?.match(/^[\t ]*/)?.[0] ?? "\t");
3203
- }
3204
- function matchingBraceIndex(source, openBrace) {
3205
- return matchingDelimiterIndex(source, openBrace, "{", "}");
3206
- }
3207
- function replaceTransactionPortsRequired(source, entry, message) {
3208
- return insertTypeProperty(source, "AppTransactionPorts", entry.trim(), message);
3209
- }
3210
- function aliasModule(filePath) {
3211
- return `@/${modulePath(filePath)}`;
3212
- }
3213
- function relativeModule(fromFile, toFile) {
3214
- const fromDir = path.posix.dirname(normalizePath(fromFile));
3215
- const relative = path.posix.relative(fromDir, normalizePath(toFile));
3216
- const specifier = modulePath(relative);
3217
- if (specifier.startsWith("../"))
3218
- return specifier;
3219
- if (specifier === "..")
3220
- return specifier;
3221
- return specifier.startsWith(".") ? specifier : `./${specifier}`;
3222
- }
3223
- function modulePath(filePath) {
3224
- return directoryPath(filePath)
3225
- .replace(/\.(?:[cm]?[jt]sx?)$/, "")
3226
- .replace(/\/index$/, "");
3227
- }
3228
- function resourceNames(input) {
3229
- const pluralKebab = normalizeResourceName(input);
3230
- const singularKebab = singularize(pluralKebab);
3231
- const names = {
3232
- input,
3233
- pluralKebab,
3234
- singularKebab,
3235
- pluralCamel: camelCase(pluralKebab),
3236
- singularCamel: camelCase(singularKebab),
3237
- singularPascal: pascalCase(singularKebab),
3238
- pluralPascal: pascalCase(pluralKebab),
3239
- };
3240
- assertGeneratedIdentifiers(input, "Resource name", [
3241
- names.pluralCamel,
3242
- names.singularCamel,
3243
- names.singularPascal,
3244
- names.pluralPascal,
3245
- ]);
3246
- return names;
3247
- }
3248
- function featureUiNames(input) {
3249
- const names = resourceNames(input);
3250
- const resolvedNames = {
3251
- ...names,
3252
- componentExportName: `${names.pluralPascal}Panel`,
3253
- componentFileName: `${names.pluralKebab}-panel`,
3254
- };
3255
- assertGeneratedIdentifiers(input, "Feature UI name", [
3256
- resolvedNames.componentExportName,
3257
- ]);
3258
- return resolvedNames;
3259
- }
3260
- function featureResourceNames(names) {
3261
- return resourceNames(names.feature.kebab);
3262
- }
3263
- function useCaseNames(input) {
3264
- const parts = input
3265
- .trim()
3266
- .split("/")
3267
- .map((part) => part.trim())
3268
- .filter(Boolean);
3269
- if (parts.length !== 2) {
3270
- throw new Error("Use case name must use feature/action format, for example projects/archive-project.");
1465
+ function useCaseNames(input) {
1466
+ const parts = input
1467
+ .trim()
1468
+ .split("/")
1469
+ .map((part) => part.trim())
1470
+ .filter(Boolean);
1471
+ if (parts.length !== 2) {
1472
+ throw new Error("Use case name must use feature/action format, for example projects/archive-project.");
3271
1473
  }
3272
1474
  const feature = identifierNames(parts[0], "Use case feature");
3273
1475
  const action = identifierNames(parts[1], "Use case action");
@@ -3289,32 +1491,6 @@ function useCaseNames(input) {
3289
1491
  kind: inferUseCaseKind(action.kebab),
3290
1492
  };
3291
1493
  }
3292
- function identifierNames(input, label) {
3293
- const kebab = normalizeName(input, label);
3294
- return {
3295
- input,
3296
- kebab,
3297
- camel: camelCase(kebab),
3298
- pascal: pascalCase(kebab),
3299
- };
3300
- }
3301
- function portNames(input) {
3302
- const names = identifierNames(input, "Port name");
3303
- const resolvedNames = {
3304
- ...names,
3305
- interfaceName: `${names.pascal}Port`,
3306
- inputName: `Execute${names.pascal}Input`,
3307
- adapterFactoryName: `create${names.pascal}Adapter`,
3308
- };
3309
- assertGeneratedIdentifiers(input, "Port name", [
3310
- resolvedNames.camel,
3311
- resolvedNames.pascal,
3312
- resolvedNames.interfaceName,
3313
- resolvedNames.inputName,
3314
- resolvedNames.adapterFactoryName,
3315
- ]);
3316
- return resolvedNames;
3317
- }
3318
1494
  function policyNames(input) {
3319
1495
  const names = identifierNames(input, "Policy name");
3320
1496
  const resolvedNames = {
@@ -3324,32 +1500,6 @@ function policyNames(input) {
3324
1500
  assertGeneratedIdentifiers(input, "Policy name", [resolvedNames.policyName]);
3325
1501
  return resolvedNames;
3326
1502
  }
3327
- function featureArtifactNames(input, label) {
3328
- const parts = artifactNameParts(input, label);
3329
- const feature = identifierNames(parts[0], `${label} feature`);
3330
- const artifact = identifierNames(parts[1], `${label} name`);
3331
- const featureSingularKebab = singularize(feature.kebab);
3332
- const names = {
3333
- input,
3334
- feature,
3335
- featureSingularKebab,
3336
- featureSingularCamel: camelCase(featureSingularKebab),
3337
- featureSingularPascal: pascalCase(featureSingularKebab),
3338
- artifact,
3339
- stableName: `${feature.kebab}.${artifact.kebab}`,
3340
- };
3341
- assertGeneratedIdentifiers(parts[0], `${label} feature`, [
3342
- names.feature.camel,
3343
- names.feature.pascal,
3344
- names.featureSingularCamel,
3345
- names.featureSingularPascal,
3346
- ]);
3347
- assertGeneratedIdentifiers(parts[1], `${label} name`, [
3348
- names.artifact.camel,
3349
- names.artifact.pascal,
3350
- ]);
3351
- return names;
3352
- }
3353
1503
  function eventNames(input) {
3354
1504
  const names = featureArtifactNames(normalizeEventReference(input), "Event");
3355
1505
  const eventExportName = `${names.featureSingularPascal}${names.artifact.pascal}`;
@@ -3396,16 +1546,6 @@ function factoryNames(input) {
3396
1546
  factoryExportName,
3397
1547
  };
3398
1548
  }
3399
- function seedNames(input) {
3400
- const names = featureArtifactNames(input, "Seed");
3401
- const seedExportName = `${names.artifact.camel}Seed`;
3402
- assertGeneratedIdentifiers(input, "Seed name", [seedExportName]);
3403
- return {
3404
- ...names,
3405
- seedName: names.stableName,
3406
- seedExportName,
3407
- };
3408
- }
3409
1549
  function defaultFactoryNamesForSeed(names) {
3410
1550
  return factoryNames(`${names.feature.kebab}/${names.featureSingularKebab}`);
3411
1551
  }
@@ -3469,127 +1609,12 @@ function normalizeEventReference(input) {
3469
1609
  return trimmed;
3470
1610
  return `${trimmed.slice(0, dotIndex)}/${trimmed.slice(dotIndex + 1)}`;
3471
1611
  }
3472
- function artifactNameParts(input, label) {
3473
- const parts = input
3474
- .trim()
3475
- .split("/")
3476
- .map((part) => part.trim())
3477
- .filter(Boolean);
3478
- if (parts.length !== 2) {
3479
- throw new Error(`${label} name must use feature/name format, for example posts/published.`);
3480
- }
3481
- return [parts[0], parts[1]];
3482
- }
3483
- function portStubEntry(names) {
3484
- return `\t${names.camel}: {\n\t\tasync execute() {\n\t\t\tthrow new Error("${names.interfaceName} is not implemented.");\n\t\t},\n\t} satisfies ${names.interfaceName},`;
3485
- }
3486
- function adapterEntry(names) {
3487
- return `\t${names.camel}: ${names.adapterFactoryName}(),`;
3488
- }
3489
- function normalizeResourceName(input) {
3490
- return normalizeName(input, "Resource name");
3491
- }
3492
- function normalizeName(input, label) {
3493
- const normalized = input
3494
- .trim()
3495
- .toLowerCase()
3496
- .replaceAll(/[^a-z0-9]+/g, "-")
3497
- .replaceAll(/^-+|-+$/g, "");
3498
- if (!normalized) {
3499
- throw new Error(`${label} is required.`);
3500
- }
3501
- return normalized;
3502
- }
3503
- function assertGeneratedIdentifiers(input, label, identifiers) {
3504
- for (const identifier of new Set(identifiers)) {
3505
- if (isValidGeneratedIdentifier(identifier)) {
3506
- continue;
3507
- }
3508
- throw new Error(`${label} "${input}" generates invalid JavaScript identifier "${identifier}". Use a name that starts with a letter and is not a reserved word.`);
3509
- }
3510
- }
3511
- function isValidGeneratedIdentifier(identifier) {
3512
- return (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier) &&
3513
- !reservedJavaScriptIdentifiers.has(identifier));
3514
- }
3515
- const reservedJavaScriptIdentifiers = new Set([
3516
- "arguments",
3517
- "await",
3518
- "break",
3519
- "case",
3520
- "catch",
3521
- "class",
3522
- "const",
3523
- "continue",
3524
- "debugger",
3525
- "default",
3526
- "delete",
3527
- "do",
3528
- "else",
3529
- "enum",
3530
- "eval",
3531
- "export",
3532
- "extends",
3533
- "false",
3534
- "finally",
3535
- "for",
3536
- "function",
3537
- "if",
3538
- "implements",
3539
- "import",
3540
- "in",
3541
- "instanceof",
3542
- "interface",
3543
- "let",
3544
- "new",
3545
- "null",
3546
- "package",
3547
- "private",
3548
- "protected",
3549
- "public",
3550
- "return",
3551
- "static",
3552
- "super",
3553
- "switch",
3554
- "this",
3555
- "throw",
3556
- "true",
3557
- "try",
3558
- "typeof",
3559
- "var",
3560
- "void",
3561
- "while",
3562
- "with",
3563
- "yield",
3564
- ]);
3565
1612
  function inferUseCaseKind(action) {
3566
1613
  if (/^(count|find|get|list|search)-/.test(`${action}-`)) {
3567
1614
  return "query";
3568
1615
  }
3569
1616
  return "command";
3570
1617
  }
3571
- function singularize(value) {
3572
- if (value.endsWith("ies") && value.length > 3) {
3573
- return `${value.slice(0, -3)}y`;
3574
- }
3575
- if (value.endsWith("ses") && value.length > 3) {
3576
- return value.slice(0, -2);
3577
- }
3578
- if (value.endsWith("s") && value.length > 1) {
3579
- return value.slice(0, -1);
3580
- }
3581
- return value;
3582
- }
3583
- function camelCase(value) {
3584
- return value.replaceAll(/-([a-z0-9])/g, (_, char) => char.toUpperCase());
3585
- }
3586
- function pascalCase(value) {
3587
- const camel = camelCase(value);
3588
- return `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
3589
- }
3590
- function constantCase(value) {
3591
- return value.replaceAll("-", "_").toUpperCase();
3592
- }
3593
1618
  function resourceFiles(names, config, persistence = "memory", mode = "feature", options = {
3594
1619
  auth: false,
3595
1620
  tenant: false,
@@ -3685,54 +1710,6 @@ function resourceFiles(names, config, persistence = "memory", mode = "feature",
3685
1710
  }
3686
1711
  return files;
3687
1712
  }
3688
- function paymentsFiles(config, database) {
3689
- return [
3690
- {
3691
- path: path.join(config.paths.features, "billing/schemas.ts"),
3692
- content: billingSchemasFile(),
3693
- },
3694
- {
3695
- path: path.join(config.paths.features, "billing/pricing.ts"),
3696
- content: billingPricingFile(),
3697
- },
3698
- {
3699
- path: path.join(config.paths.features, "billing/entitlements.ts"),
3700
- content: billingEntitlementsFile(),
3701
- },
3702
- {
3703
- path: path.join(config.paths.features, "billing/ports.ts"),
3704
- content: billingPortsFile(),
3705
- },
3706
- {
3707
- path: path.join(config.paths.features, "billing/contracts.ts"),
3708
- content: billingContractsFile(),
3709
- },
3710
- {
3711
- path: path.join(config.paths.features, "billing/use-cases/index.ts"),
3712
- content: billingUseCasesFile(config),
3713
- },
3714
- {
3715
- path: path.join(config.paths.features, "billing/routes.ts"),
3716
- content: billingRoutesFile(config),
3717
- },
3718
- {
3719
- path: path.join(config.paths.features, "billing/tests/billing.test.ts"),
3720
- content: billingTestFile(config),
3721
- },
3722
- {
3723
- path: path.join(infrastructureDir(config), "billing/drizzle-billing-repository.ts"),
3724
- content: billingDrizzleRepositoryFile(config, database),
3725
- },
3726
- {
3727
- path: path.join(infrastructureDir(config), "db/schema/billing.ts"),
3728
- content: billingDrizzleSchemaFile(database),
3729
- },
3730
- {
3731
- path: "app/api/webhooks/payments/route.ts",
3732
- content: billingWebhookRouteFile(),
3733
- },
3734
- ];
3735
- }
3736
1713
  function featureUiComponentFiles(names, config) {
3737
1714
  const clientQueriesPath = featureUiClientQueriesFilePath(names, config);
3738
1715
  const componentPath = featureUiComponentFilePath(names, config);
@@ -3894,125 +1871,12 @@ function outboxFiles(config) {
3894
1871
  },
3895
1872
  ];
3896
1873
  }
3897
- function portFilePath(names, config) {
3898
- return path.join(path.dirname(config.paths.ports), `${names.kebab}.ts`);
3899
- }
3900
- function adapterFilePath(names, config) {
3901
- return path.join(path.dirname(config.paths.infrastructurePorts), names.kebab, `${names.kebab}-adapter.ts`);
3902
- }
3903
- function infrastructureDir(config) {
3904
- return path.dirname(config.paths.infrastructurePorts);
3905
- }
3906
- function drizzleSchemaIndexPath(config) {
3907
- return path.join(infrastructureDir(config), "db", "schema", "index.ts");
3908
- }
3909
- function drizzleRepositoriesPath(config) {
3910
- return path.join(infrastructureDir(config), "db", "repositories.ts");
3911
- }
3912
1874
  function drizzleResourceSchemaFilePath(names, config) {
3913
1875
  return path.join(infrastructureDir(config), "db", "schema", `${names.pluralKebab}.ts`);
3914
1876
  }
3915
1877
  function outboxDrizzleSchemaFilePath(config) {
3916
1878
  return path.join(infrastructureDir(config), "db", "schema", "outbox-messages.ts");
3917
1879
  }
3918
- function drizzleResourceRepositoryFilePath(names, config) {
3919
- return path.join(infrastructureDir(config), names.pluralKebab, `drizzle-${names.singularKebab}-repository.ts`);
3920
- }
3921
- function resourceSharedErrorsPath(config) {
3922
- return path.join(config.paths.features, "shared", "errors.ts");
3923
- }
3924
- function resourceContractNames(names, mode) {
3925
- const contractNames = [
3926
- `list${names.pluralPascal}`,
3927
- `create${names.singularPascal}`,
3928
- ];
3929
- if (mode === "resource") {
3930
- contractNames.push(`get${names.singularPascal}`, `update${names.singularPascal}`, `delete${names.singularPascal}`);
3931
- }
3932
- return contractNames;
3933
- }
3934
- async function detectResourcePersistence(targetDir, config) {
3935
- return (await fileExists(path.join(targetDir, drizzleRepositoriesPath(config))))
3936
- ? "drizzle"
3937
- : "memory";
3938
- }
3939
- const databaseNames = ["sqlite", "postgres", "mysql"];
3940
- /**
3941
- * Detect the SQL dialect of a Drizzle app from its generated wiring.
3942
- *
3943
- * Precedence:
3944
- * 1. The provider import in the Drizzle repositories file
3945
- * (`@beignet/provider-db-drizzle/<dialect>`), resolved through the
3946
- * configured infrastructure paths so custom layouts keep working.
3947
- * 2. The provider registration tokens in `server/providers.ts`
3948
- * (`drizzle<Dialect>Provider` / `createDrizzle<Dialect>Provider`).
3949
- * 3. `"sqlite"`, the starter default.
3950
- *
3951
- * Self-healing by design: there is no config field to drift, the detector
3952
- * reads the same files the app boots from.
3953
- */
3954
- export async function detectResourceDatabase(targetDir, config) {
3955
- const repositories = await readOptionalFile(path.join(targetDir, drizzleRepositoriesPath(config)));
3956
- const providerImport = repositories?.match(/from\s+["']@beignet\/provider-db-drizzle\/(sqlite|postgres|mysql)["']/);
3957
- if (providerImport)
3958
- return providerImport[1];
3959
- const providers = await readOptionalFile(path.join(targetDir, providersFilePath(config)));
3960
- if (providers !== undefined) {
3961
- for (const database of databaseNames) {
3962
- const dialectPascal = database.charAt(0).toUpperCase() + database.slice(1);
3963
- const tokens = new RegExp(`\\b(?:drizzle${dialectPascal}Provider|createDrizzle${dialectPascal}Provider)\\b`);
3964
- if (tokens.test(providers))
3965
- return database;
3966
- }
3967
- }
3968
- return "sqlite";
3969
- }
3970
- function resourceFeatureDir(names, config) {
3971
- return path.join(config.paths.features, names.pluralKebab);
3972
- }
3973
- function featureUiDir(names, config) {
3974
- return path.join(resourceFeatureDir(names, config), "components");
3975
- }
3976
- function featureUiClientDir(names, config) {
3977
- return path.join(resourceFeatureDir(names, config), "client");
3978
- }
3979
- function featureUiClientQueriesFilePath(names, config) {
3980
- return path.join(featureUiClientDir(names, config), "queries.ts");
3981
- }
3982
- function featureUiComponentFilePath(names, config) {
3983
- return path.join(featureUiDir(names, config), `${names.componentFileName}.tsx`);
3984
- }
3985
- function featureUiIndexFilePath(names, config) {
3986
- return path.join(featureUiDir(names, config), "index.ts");
3987
- }
3988
- function usesFeatureOwnedContracts(config) {
3989
- return (directoryPath(config.paths.contracts) ===
3990
- directoryPath(config.paths.features));
3991
- }
3992
- function usesFeatureOwnedUseCases(config) {
3993
- return (directoryPath(config.paths.useCases) ===
3994
- directoryPath(config.paths.features));
3995
- }
3996
- function usesFeatureOwnedPolicies(config) {
3997
- return (directoryPath(config.paths.policies) ===
3998
- directoryPath(config.paths.features));
3999
- }
4000
- function usesFeatureOwnedTests(config) {
4001
- return (usesFeatureOwnedUseCases(config) &&
4002
- directoryPath(config.paths.tests) === "tests");
4003
- }
4004
- function resourceContractFilePath(names, config) {
4005
- if (usesFeatureOwnedContracts(config)) {
4006
- return path.join(resourceFeatureDir(names, config), "contracts.ts");
4007
- }
4008
- return path.join(config.paths.contracts, `${names.pluralKebab}.ts`);
4009
- }
4010
- function useCaseFeatureDir(feature, config) {
4011
- if (usesFeatureOwnedUseCases(config)) {
4012
- return path.join(config.paths.features, feature, "use-cases");
4013
- }
4014
- return path.join(config.paths.useCases, feature);
4015
- }
4016
1880
  function resourceUseCaseDir(names, config) {
4017
1881
  return useCaseFeatureDir(names.pluralKebab, config);
4018
1882
  }
@@ -4022,12 +1886,6 @@ function resourceUseCaseIndexPath(names, config) {
4022
1886
  function resourceSchemaFilePath(names, config) {
4023
1887
  return path.join(resourceFeatureDir(names, config), "schemas.ts");
4024
1888
  }
4025
- function resourcePortFilePath(names, config) {
4026
- if (usesFeatureOwnedContracts(config)) {
4027
- return path.join(resourceFeatureDir(names, config), "ports.ts");
4028
- }
4029
- return path.join(path.dirname(config.paths.ports), `${names.singularKebab}-repository.ts`);
4030
- }
4031
1889
  function resourceTestFilePath(names, config) {
4032
1890
  if (usesFeatureOwnedTests(config)) {
4033
1891
  return path.join(resourceFeatureDir(names, config), "tests", `${names.pluralKebab}.test.ts`);
@@ -4046,46 +1904,6 @@ function policyFilePath(names, config) {
4046
1904
  }
4047
1905
  return path.join(config.paths.policies, `${names.kebab}.ts`);
4048
1906
  }
4049
- function featureArtifactDir(names, kind, config) {
4050
- const featureDir = path.join(config.paths.features, names.feature.kebab);
4051
- if (kind === "events") {
4052
- return path.join(featureDir, "domain", "events");
4053
- }
4054
- if (kind === "factories") {
4055
- return path.join(featureDir, "tests", "factories");
4056
- }
4057
- return path.join(featureDir, kind);
4058
- }
4059
- function eventFilePath(names, config) {
4060
- return path.join(featureArtifactDir(names, "events", config), `${names.artifact.kebab}.ts`);
4061
- }
4062
- function jobFilePath(names, config) {
4063
- return path.join(featureArtifactDir(names, "jobs", config), `${names.artifact.kebab}.ts`);
4064
- }
4065
- function taskFilePath(names, config) {
4066
- return path.join(featureArtifactDir(names, "tasks", config), `${names.artifact.kebab}.ts`);
4067
- }
4068
- function factoryFilePath(names, config) {
4069
- return path.join(featureArtifactDir(names, "factories", config), `${names.artifact.kebab}.ts`);
4070
- }
4071
- function seedFilePath(names, config) {
4072
- return path.join(featureArtifactDir(names, "seeds", config), `${names.artifact.kebab}.ts`);
4073
- }
4074
- function notificationFilePath(names, config) {
4075
- return path.join(featureArtifactDir(names, "notifications", config), `${names.artifact.kebab}.ts`);
4076
- }
4077
- function listenerFilePath(names, config) {
4078
- return path.join(featureArtifactDir(names, "listeners", config), `${names.artifact.kebab}.ts`);
4079
- }
4080
- function scheduleFilePath(names, config) {
4081
- return path.join(featureArtifactDir(names, "schedules", config), `${names.artifact.kebab}.ts`);
4082
- }
4083
- function uploadFilePath(names, config) {
4084
- return path.join(featureArtifactDir(names, "uploads", config), `${names.artifact.kebab}.ts`);
4085
- }
4086
- function seedEntrypointPath(config) {
4087
- return path.join(infrastructureDir(config), "db", "seed.ts");
4088
- }
4089
1907
  function uploadRoutePath(config) {
4090
1908
  return path.join(config.paths.routes, "uploads", "[uploadName]", "[action]", "route.ts");
4091
1909
  }
@@ -4095,9 +1913,6 @@ function outboxDrainRoutePath(config) {
4095
1913
  function scheduleRouteFilePath(names, config) {
4096
1914
  return path.join(config.paths.routes, "cron", names.feature.kebab, names.artifact.kebab, "route.ts");
4097
1915
  }
4098
- function useCaseFilePath(names, config) {
4099
- return path.join(useCaseFeatureDir(names.feature.kebab, config), `${names.action.kebab}.ts`);
4100
- }
4101
1916
  function useCaseIndexFile(names, config) {
4102
1917
  const filePath = path.join(useCaseFeatureDir(names.feature.kebab, config), "index.ts");
4103
1918
  return {
@@ -4141,161 +1956,16 @@ async function updateFeatureUiIndex(targetDir, file, options) {
4141
1956
  await writeFile(destination, next);
4142
1957
  return "updated";
4143
1958
  }
4144
- function featureArtifactIndexFile(kind, names, config) {
4145
- const artifactPath = kind === "event"
4146
- ? eventFilePath(names, config)
4147
- : kind === "job"
4148
- ? jobFilePath(names, config)
4149
- : kind === "task"
4150
- ? taskFilePath(names, config)
4151
- : kind === "factory"
4152
- ? factoryFilePath(names, config)
4153
- : kind === "seed"
4154
- ? seedFilePath(names, config)
4155
- : kind === "notification"
4156
- ? notificationFilePath(names, config)
4157
- : kind === "listener"
4158
- ? listenerFilePath(names, config)
4159
- : kind === "schedule"
4160
- ? scheduleFilePath(names, config)
4161
- : uploadFilePath(names, config);
4162
- const registrySuffix = kind === "event"
4163
- ? "Events"
4164
- : kind === "job"
4165
- ? "Jobs"
4166
- : kind === "task"
4167
- ? "Tasks"
4168
- : kind === "factory"
4169
- ? "Factories"
4170
- : kind === "seed"
4171
- ? "Seeds"
4172
- : kind === "notification"
4173
- ? "Notifications"
4174
- : kind === "listener"
4175
- ? "Listeners"
4176
- : kind === "schedule"
4177
- ? "Schedules"
4178
- : "Uploads";
4179
- const importName = kind === "event"
4180
- ? names.eventExportName
4181
- : kind === "job"
4182
- ? names.jobExportName
4183
- : kind === "task"
4184
- ? names.taskExportName
4185
- : kind === "factory"
4186
- ? names.factoryExportName
4187
- : kind === "seed"
4188
- ? names.seedExportName
4189
- : kind === "notification"
4190
- ? names.notificationExportName
4191
- : kind === "listener"
4192
- ? names.listenerExportName
4193
- : kind === "schedule"
4194
- ? names.scheduleExportName
4195
- : names.uploadExportName;
4196
- return {
4197
- path: path.join(path.dirname(artifactPath), "index.ts"),
4198
- kind,
4199
- importName,
4200
- artifactPath,
4201
- registryName: `${names.featureSingularCamel}${registrySuffix}`,
4202
- registryEntryName: names.artifact.camel,
4203
- };
4204
- }
4205
- async function updateFeatureArtifactIndex(targetDir, file, options) {
4206
- const destination = path.join(targetDir, file.path);
4207
- const existing = await readOptionalFile(destination);
4208
- const specifier = relativeModule(file.path, file.artifactPath);
4209
- const importLine = `import { ${file.importName} } from "${specifier}";`;
4210
- const defineUploadsImport = `import { defineUploads } from "@beignet/core/uploads";`;
4211
- const exportLine = `export { ${file.importName} } from "${specifier}";`;
4212
- if (existing === undefined) {
4213
- const content = file.kind === "upload"
4214
- ? `${defineUploadsImport}
4215
- ${importLine}
4216
-
4217
- ${exportLine}
4218
-
4219
- export const ${file.registryName} = defineUploads({
4220
- \t${file.registryEntryName}: ${file.importName},
4221
- });
4222
- `
4223
- : `${importLine}
4224
-
4225
- ${exportLine}
4226
-
4227
- export const ${file.registryName} = [${file.importName}] as const;
4228
- `;
4229
- if (!options.dryRun) {
4230
- await mkdir(path.dirname(destination), { recursive: true });
4231
- await writeFile(destination, content);
4232
- }
4233
- return "created";
4234
- }
4235
- let next = existing;
4236
- if (!next.includes(importLine)) {
4237
- next = insertAfterImports(next, importLine);
4238
- }
4239
- if (!next.includes(exportLine)) {
4240
- const registryExport = new RegExp(`export\\s+const\\s+${file.registryName}\\b`).exec(next);
4241
- next = registryExport
4242
- ? `${next.slice(0, registryExport.index).trimEnd()}\n${exportLine}\n\n${next.slice(registryExport.index)}`
4243
- : `${next.trimEnd()}\n${exportLine}\n`;
4244
- }
4245
- if (file.kind === "upload") {
4246
- const registry = defineUploadsInitializerInfo(next, file.registryName);
4247
- const arrayRegistry = arrayInitializerInfo(next, file.registryName);
4248
- if (registry) {
4249
- if (!next.includes(defineUploadsImport)) {
4250
- next = insertAfterImports(next, defineUploadsImport);
4251
- }
4252
- if (!registry.text.includes(`${file.registryEntryName}:`)) {
4253
- next = `${next.slice(0, registry.end)}\t${file.registryEntryName}: ${file.importName},\n${next.slice(registry.end)}`;
4254
- }
4255
- }
4256
- else if (arrayRegistry) {
4257
- if (!identifiersFromArrayExpression(arrayRegistry.text).has(file.importName)) {
4258
- const nextArray = appendToArrayExpression(arrayRegistry.text, [
4259
- file.importName,
4260
- ]);
4261
- next = `${next.slice(0, arrayRegistry.start)}${nextArray}${next.slice(arrayRegistry.end)}`;
4262
- }
4263
- }
4264
- else {
4265
- if (!next.includes(defineUploadsImport)) {
4266
- next = insertAfterImports(next, defineUploadsImport);
4267
- }
4268
- next = `${next.trimEnd()}\n\nexport const ${file.registryName} = defineUploads({\n\t${file.registryEntryName}: ${file.importName},\n});\n`;
4269
- }
4270
- }
4271
- else {
4272
- const registry = arrayInitializerInfo(next, file.registryName);
4273
- if (!registry) {
4274
- next = `${next.trimEnd()}\n\nexport const ${file.registryName} = [${file.importName}] as const;\n`;
4275
- }
4276
- else if (!identifiersFromArrayExpression(registry.text).has(file.importName)) {
4277
- const nextArray = appendToArrayExpression(registry.text, [
4278
- file.importName,
4279
- ]);
4280
- next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
4281
- }
4282
- }
4283
- if (next === existing)
4284
- return "skipped";
4285
- if (!options.dryRun)
4286
- await writeFile(destination, next);
4287
- return "updated";
4288
- }
4289
1959
  async function updateTaskRegistry(targetDir, names, config, options) {
4290
1960
  const destination = path.join(targetDir, config.paths.tasks);
4291
1961
  const existing = await readOptionalFile(destination);
4292
1962
  const featureTasksPath = path.join(featureArtifactDir(names, "tasks", config), "index.ts");
4293
1963
  const registryName = `${names.featureSingularCamel}Tasks`;
4294
1964
  const importLine = `import { ${registryName} } from "${relativeModule(config.paths.tasks, featureTasksPath)}";`;
4295
- const defineTasksImport = `import { defineTasks } from "@beignet/core/tasks";`;
1965
+ const defineTasksImport = `import { defineTasks, type TaskRunContextArgs } from "@beignet/core/tasks";`;
4296
1966
  const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
4297
1967
  const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
4298
- const serverImport = `import { server } from "${relativeModule(config.paths.tasks, config.paths.server)}";`;
1968
+ const serverImport = `import { getServer } from "${relativeModule(config.paths.tasks, config.paths.server)}";`;
4299
1969
  const registryEntry = `...${registryName}`;
4300
1970
  if (existing === undefined) {
4301
1971
  const content = `${defineTasksImport}
@@ -4308,13 +1978,20 @@ export const tasks = defineTasks([
4308
1978
  \t${registryEntry},
4309
1979
  ] as const);
4310
1980
 
4311
- export async function createTaskContext(): Promise<AppContext> {
1981
+ export async function createTaskContext(
1982
+ \targs?: TaskRunContextArgs,
1983
+ ): Promise<AppContext> {
1984
+ \tconst server = await getServer();
4312
1985
  \treturn server.createServiceContext({
1986
+ \t\t// \`--tenant\` flows through TaskRunContextArgs; the starter has no tenant
1987
+ \t\t// lookup, so the raw value scopes the service context directly.
1988
+ \t\ttenantId: args?.tenant,
4313
1989
  \t\tactor: createServiceActor("beignet-cli"),
4314
1990
  \t});
4315
1991
  }
4316
1992
 
4317
1993
  export async function stopTaskContext(): Promise<void> {
1994
+ \tconst server = await getServer();
4318
1995
  \tawait server.stop();
4319
1996
  }
4320
1997
  `;
@@ -4325,7 +2002,9 @@ export async function stopTaskContext(): Promise<void> {
4325
2002
  return "created";
4326
2003
  }
4327
2004
  let next = existing;
4328
- if (!next.includes(defineTasksImport)) {
2005
+ // Match any `defineTasks` import shape so registries that also import
2006
+ // `TaskRunContextArgs` (or predate it) are not given a duplicate import.
2007
+ if (!(next.includes("defineTasks") && next.includes('"@beignet/core/tasks"'))) {
4329
2008
  next = insertAfterImports(next, defineTasksImport);
4330
2009
  }
4331
2010
  if (!next.includes(importLine)) {
@@ -4351,61 +2030,30 @@ async function updateScheduleRegistry(targetDir, names, config, options) {
4351
2030
  const featureSchedulesPath = path.join(featureArtifactDir(names, "schedules", config), "index.ts");
4352
2031
  const registryName = `${names.featureSingularCamel}Schedules`;
4353
2032
  const importLine = `import { ${registryName} } from "${aliasModule(featureSchedulesPath)}";`;
4354
- const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
4355
- const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
4356
- const serverImport = `import { server } from "${relativeModule(config.paths.schedules, config.paths.server)}";`;
4357
- const registryEntry = `...${registryName}`;
4358
- if (existing === undefined) {
4359
- const content = `${serviceActorImport}
4360
- ${appContextImport}
4361
- ${importLine}
4362
- ${serverImport}
4363
-
4364
- export const schedules = [${registryEntry}] as const;
4365
-
4366
- export async function createScheduleContext(): Promise<AppContext> {
4367
- \treturn server.createServiceContext({
4368
- \t\tactor: createServiceActor("beignet-schedule"),
4369
- \t});
4370
- }
4371
-
4372
- export async function stopScheduleContext(): Promise<void> {
4373
- \tawait server.stop();
4374
- }
4375
- `;
4376
- if (!options.dryRun) {
4377
- await mkdir(path.dirname(destination), { recursive: true });
4378
- await writeFile(destination, content);
4379
- }
4380
- return "created";
4381
- }
4382
- let next = existing;
4383
- if (!next.includes(importLine)) {
4384
- next = insertAfterImports(next, importLine);
4385
- }
4386
- const registry = arrayInitializerInfo(next, "schedules");
4387
- if (!registry) {
4388
- next = `${next.trimEnd()}\n\nexport const schedules = [${registryEntry}] as const;\n`;
4389
- }
4390
- else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
4391
- const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
4392
- next = `${next.slice(0, registry.start)}${nextArray}${next.slice(registry.end)}`;
4393
- }
4394
- if (next === existing)
4395
- return "skipped";
4396
- if (!options.dryRun)
4397
- await writeFile(destination, next);
4398
- return "updated";
4399
- }
4400
- async function updateSeedEntrypoint(targetDir, names, config, options) {
4401
- const file = seedEntrypointPath(config);
4402
- const destination = path.join(targetDir, file);
4403
- const existing = await readOptionalFile(destination);
4404
- const registryName = `${names.featureSingularCamel}Seeds`;
4405
- const importLine = `import { ${registryName} } from "${aliasModule(path.join(featureArtifactDir(names, "seeds", config), "index.ts"))}";`;
2033
+ const serviceActorImport = `import { createServiceActor } from "@beignet/core/ports";`;
2034
+ const appContextImport = `import type { AppContext } from "${aliasModule(config.paths.appContext)}";`;
2035
+ const serverImport = `import { getServer } from "${relativeModule(config.paths.schedules, config.paths.server)}";`;
4406
2036
  const registryEntry = `...${registryName}`;
4407
2037
  if (existing === undefined) {
4408
- const content = seedEntrypointFile(names, config);
2038
+ const content = `${serviceActorImport}
2039
+ ${appContextImport}
2040
+ ${importLine}
2041
+ ${serverImport}
2042
+
2043
+ export const schedules = [${registryEntry}] as const;
2044
+
2045
+ export async function createScheduleContext(): Promise<AppContext> {
2046
+ \tconst server = await getServer();
2047
+ \treturn server.createServiceContext({
2048
+ \t\tactor: createServiceActor("beignet-schedule"),
2049
+ \t});
2050
+ }
2051
+
2052
+ export async function stopScheduleContext(): Promise<void> {
2053
+ \tconst server = await getServer();
2054
+ \tawait server.stop();
2055
+ }
2056
+ `;
4409
2057
  if (!options.dryRun) {
4410
2058
  await mkdir(path.dirname(destination), { recursive: true });
4411
2059
  await writeFile(destination, content);
@@ -4416,9 +2064,9 @@ async function updateSeedEntrypoint(targetDir, names, config, options) {
4416
2064
  if (!next.includes(importLine)) {
4417
2065
  next = insertAfterImports(next, importLine);
4418
2066
  }
4419
- const registry = arrayInitializerInfo(next, "seeds");
2067
+ const registry = arrayInitializerInfo(next, "schedules");
4420
2068
  if (!registry) {
4421
- next = `${next.trimEnd()}\n\nconst seeds = [${registryEntry}] as const;\n`;
2069
+ next = `${next.trimEnd()}\n\nexport const schedules = [${registryEntry}] as const;\n`;
4422
2070
  }
4423
2071
  else if (!identifiersFromArrayExpression(registry.text).has(registryName)) {
4424
2072
  const nextArray = appendToArrayExpression(registry.text, [registryEntry]);
@@ -4506,22 +2154,6 @@ async function applyOutboxRegistryUpdate(result, targetDir, names, kind, config,
4506
2154
  result.files.push(config.paths.outbox);
4507
2155
  }
4508
2156
  }
4509
- function defineUploadsInitializerInfo(source, constName) {
4510
- const match = new RegExp(`\\bconst\\s+${constName}\\s*=\\s*defineUploads\\s*\\(`).exec(source);
4511
- if (!match)
4512
- return undefined;
4513
- const openBrace = source.indexOf("{", match.index);
4514
- if (openBrace === -1)
4515
- return undefined;
4516
- const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
4517
- if (closeBrace === -1)
4518
- return undefined;
4519
- return {
4520
- text: source.slice(openBrace, closeBrace + 1),
4521
- start: openBrace,
4522
- end: closeBrace,
4523
- };
4524
- }
4525
2157
  function schemasFile(names, mode, options) {
4526
2158
  return `import { z } from "zod";
4527
2159
 
@@ -5924,26 +3556,6 @@ export const ${names.uploadExportName} = defineUpload<
5924
3556
  });
5925
3557
  `;
5926
3558
  }
5927
- function seedEntrypointFile(names, config) {
5928
- const registryName = `${names.featureSingularCamel}Seeds`;
5929
- return `import { createServiceActor } from "@beignet/core/ports";
5930
- import { runSeeds } from "@beignet/core/testing";
5931
- import { ${registryName} } from "${aliasModule(path.join(featureArtifactDir(names, "seeds", config), "index.ts"))}";
5932
- import { server } from "${aliasModule(config.paths.server)}";
5933
-
5934
- const seeds = [...${registryName}] as const;
5935
-
5936
- const ctx = await server.createServiceContext({
5937
- \tactor: createServiceActor("beignet-seed"),
5938
- });
5939
-
5940
- try {
5941
- \tawait runSeeds({ ctx, seeds });
5942
- } finally {
5943
- \tawait server.stop();
5944
- }
5945
- `;
5946
- }
5947
3559
  function uploadRouteFile(names, config) {
5948
3560
  const registryName = `${names.featureSingularCamel}Uploads`;
5949
3561
  return `import {
@@ -5953,19 +3565,21 @@ function uploadRouteFile(names, config) {
5953
3565
  } from "@beignet/core/uploads";
5954
3566
  import { createUploadRoute } from "@beignet/next";
5955
3567
  import { ${registryName} } from "${aliasModule(path.join(featureArtifactDir(names, "uploads", config), "index.ts"))}";
5956
- import { server } from "${aliasModule(config.paths.server)}";
3568
+ import { getServer } from "${aliasModule(config.paths.server)}";
5957
3569
 
5958
3570
  const uploadRegistry = defineUploads({
5959
3571
  \t${names.featureSingularCamel}: ${registryName},
5960
3572
  });
5961
3573
 
5962
- const uploadRouter = createUploadRouter({
5963
- \tuploads: uploadsFromRegistry(uploadRegistry),
5964
- \tctx: () => server.createContextFromNext(),
5965
- \tstorage: server.ports.storage,
5966
- });
3574
+ export const { POST } = createUploadRoute(async () => {
3575
+ \tconst server = await getServer();
5967
3576
 
5968
- export const { POST } = createUploadRoute(uploadRouter);
3577
+ \treturn createUploadRouter({
3578
+ \t\tuploads: uploadsFromRegistry(uploadRegistry),
3579
+ \t\tctx: () => server.createContextFromNext(),
3580
+ \t\tstorage: server.ports.storage,
3581
+ \t});
3582
+ });
5969
3583
  `;
5970
3584
  }
5971
3585
  function outboxRegistryFile(config, registration) {
@@ -5980,7 +3594,7 @@ function outboxRegistryFile(config, registration) {
5980
3594
  return `import { defineOutboxRegistry } from "@beignet/core/outbox";
5981
3595
  import { createServiceActor } from "@beignet/core/ports";
5982
3596
  import type { AppContext } from "${aliasModule(config.paths.appContext)}";
5983
- ${featureImport}import { server } from "${relativeModule(config.paths.outbox, config.paths.server)}";
3597
+ ${featureImport}import { getServer } from "${relativeModule(config.paths.outbox, config.paths.server)}";
5984
3598
 
5985
3599
  export const outboxRegistry = defineOutboxRegistry({
5986
3600
  \tevents: [${eventEntry}],
@@ -5988,12 +3602,14 @@ export const outboxRegistry = defineOutboxRegistry({
5988
3602
  });
5989
3603
 
5990
3604
  export async function createOutboxDrainContext(): Promise<AppContext> {
3605
+ \tconst server = await getServer();
5991
3606
  \treturn server.createServiceContext({
5992
3607
  \t\tactor: createServiceActor("beignet-outbox"),
5993
3608
  \t});
5994
3609
  }
5995
3610
 
5996
3611
  export async function stopOutboxDrainContext(): Promise<void> {
3612
+ \tconst server = await getServer();
5997
3613
  \tawait server.stop();
5998
3614
  }
5999
3615
  `;
@@ -6001,13 +3617,13 @@ export async function stopOutboxDrainContext(): Promise<void> {
6001
3617
  function outboxDrainRouteFile(config) {
6002
3618
  return `import { createOutboxDrainRoute } from "@beignet/next";
6003
3619
  import { env } from "@/lib/env";
6004
- import { server } from "${aliasModule(config.paths.server)}";
3620
+ import { getServer } from "${aliasModule(config.paths.server)}";
6005
3621
  import { outboxRegistry } from "${aliasModule(config.paths.outbox)}";
6006
3622
 
6007
3623
  export const runtime = "nodejs";
6008
3624
 
6009
3625
  export const { GET, POST } = createOutboxDrainRoute({
6010
- \tserver,
3626
+ \tserver: getServer,
6011
3627
  \tregistry: outboxRegistry,
6012
3628
  \tsecret: env.CRON_SECRET,
6013
3629
  \tbatchSize: 100,
@@ -6023,1210 +3639,6 @@ function outboxFeatureRegistry(names, kind, config) {
6023
3639
  importLine: `import { ${name} } from "${aliasModule(featureIndexPath)}";`,
6024
3640
  };
6025
3641
  }
6026
- function billingSchemasFile() {
6027
- return `import { z } from "zod";
6028
-
6029
- export const billingPlanSchema = z.enum(["team"]);
6030
-
6031
- export const billingStatusSchema = z.enum([
6032
- \t"inactive",
6033
- \t"trialing",
6034
- \t"active",
6035
- \t"past_due",
6036
- \t"canceled",
6037
- ]);
6038
-
6039
- export const createCheckoutSessionInputSchema = z.object({
6040
- \tplan: billingPlanSchema,
6041
- });
6042
-
6043
- export const createCheckoutSessionOutputSchema = z.object({
6044
- \tsessionId: z.string(),
6045
- \turl: z.string().url(),
6046
- });
6047
-
6048
- export const createBillingPortalSessionOutputSchema = z.object({
6049
- \turl: z.string().url(),
6050
- });
6051
-
6052
- export const billingStatusOutputSchema = z.object({
6053
- \ttenantId: z.string(),
6054
- \tplan: billingPlanSchema,
6055
- \tstatus: billingStatusSchema,
6056
- \tactive: z.boolean(),
6057
- \tprovider: z.string().optional(),
6058
- \tcustomerId: z.string().optional(),
6059
- \tsubscriptionId: z.string().optional(),
6060
- \tcurrentPeriodEnd: z.string().optional(),
6061
- \tcancelAtPeriodEnd: z.boolean().optional(),
6062
- \tcheckoutSessionId: z.string().optional(),
6063
- \tlastEventId: z.string().optional(),
6064
- \tupdatedAt: z.string().optional(),
6065
- });
6066
-
6067
- export type BillingPlan = z.infer<typeof billingPlanSchema>;
6068
- export type BillingStatus = z.infer<typeof billingStatusSchema>;
6069
- export type BillingStatusOutput = z.infer<typeof billingStatusOutputSchema>;
6070
- `;
6071
- }
6072
- function billingPricingFile() {
6073
- return `import type {
6074
- \tPaymentCheckoutLineItem,
6075
- \tPaymentCheckoutMode,
6076
- } from "@beignet/core/payments";
6077
- import { env } from "@/lib/env";
6078
- import type { BillingPlan } from "./schemas";
6079
-
6080
- export type BillingEntitlement = "todos.create";
6081
-
6082
- export type BillingPlanDefinition = {
6083
- \tid: BillingPlan;
6084
- \tlabel: string;
6085
- \tmode: PaymentCheckoutMode;
6086
- \tlineItems: readonly PaymentCheckoutLineItem[];
6087
- \tentitlements: readonly BillingEntitlement[];
6088
- };
6089
-
6090
- export const billingPlans = {
6091
- \tteam: {
6092
- \t\tid: "team",
6093
- \t\tlabel: "Team",
6094
- \t\tmode: "subscription",
6095
- \t\tlineItems: [{ priceId: env.BILLING_TEAM_PRICE_ID, quantity: 1 }],
6096
- \t\tentitlements: ["todos.create"],
6097
- \t},
6098
- } as const satisfies Record<BillingPlan, BillingPlanDefinition>;
6099
-
6100
- export function getBillingPlan(plan: BillingPlan): BillingPlanDefinition {
6101
- \treturn billingPlans[plan];
6102
- }
6103
-
6104
- export function billingPlanIncludesEntitlement(
6105
- \tplan: BillingPlan,
6106
- \tentitlement: string,
6107
- ): entitlement is BillingEntitlement {
6108
- \treturn getBillingPlan(plan).entitlements.includes(
6109
- \t\tentitlement as BillingEntitlement,
6110
- \t);
6111
- }
6112
- `;
6113
- }
6114
- function billingEntitlementsFile() {
6115
- return `import {
6116
- \tcreateEntitlements,
6117
- \tdenyEntitlement,
6118
- } from "@beignet/core/entitlements";
6119
- import { billingPlanIncludesEntitlement } from "./pricing";
6120
- import { isBillingAccountActive, type BillingRepository } from "./ports";
6121
-
6122
- export function createBillingEntitlements(billing: BillingRepository) {
6123
- \treturn createEntitlements({
6124
- \t\tasync inspect(input) {
6125
- \t\t\tif (input.subject.type !== "tenant") {
6126
- \t\t\t\treturn denyEntitlement({
6127
- \t\t\t\t\treason: "Billing entitlements require a tenant subject.",
6128
- \t\t\t\t\tcode: "ENTITLEMENT_SUBJECT_UNSUPPORTED",
6129
- \t\t\t\t\tdetails: { subject: input.subject },
6130
- \t\t\t\t});
6131
- \t\t\t}
6132
-
6133
- \t\t\tconst account = await billing.findByTenantId(input.subject.id);
6134
- \t\t\tif (!isBillingAccountActive(account)) {
6135
- \t\t\t\treturn denyEntitlement({
6136
- \t\t\t\t\treason: "An active billing plan is required for this action.",
6137
- \t\t\t\t\tcode: "BILLING_INACTIVE",
6138
- \t\t\t\t\tdetails: {
6139
- \t\t\t\t\t\tentitlement: input.entitlement,
6140
- \t\t\t\t\t\tstatus: account?.status ?? "inactive",
6141
- \t\t\t\t\t\tsubject: input.subject,
6142
- \t\t\t\t\t},
6143
- \t\t\t\t});
6144
- \t\t\t}
6145
-
6146
- \t\t\tif (!billingPlanIncludesEntitlement(account.plan, input.entitlement)) {
6147
- \t\t\t\treturn denyEntitlement({
6148
- \t\t\t\t\treason:
6149
- \t\t\t\t\t\t"The current billing plan does not include this entitlement.",
6150
- \t\t\t\t\tcode: "ENTITLEMENT_NOT_INCLUDED",
6151
- \t\t\t\t\tdetails: {
6152
- \t\t\t\t\t\tentitlement: input.entitlement,
6153
- \t\t\t\t\t\tplan: account.plan,
6154
- \t\t\t\t\t\tsubject: input.subject,
6155
- \t\t\t\t\t},
6156
- \t\t\t\t});
6157
- \t\t\t}
6158
-
6159
- \t\t\treturn true;
6160
- \t\t},
6161
- \t});
6162
- }
6163
- `;
6164
- }
6165
- function billingPortsFile() {
6166
- return `import type { BillingPlan, BillingStatus } from "./schemas";
6167
-
6168
- export type BillingAccount = {
6169
- \ttenantId: string;
6170
- \tplan: BillingPlan;
6171
- \tstatus: BillingStatus;
6172
- \tprovider: string;
6173
- \tcustomerId?: string;
6174
- \tsubscriptionId?: string;
6175
- \tcurrentPeriodEnd?: string;
6176
- \tcancelAtPeriodEnd?: boolean;
6177
- \tcheckoutSessionId?: string;
6178
- \tlastEventId?: string;
6179
- \tupdatedAt: string;
6180
- };
6181
-
6182
- export type SaveBillingAccountInput = BillingAccount;
6183
-
6184
- export interface BillingRepository {
6185
- \tfindByTenantId(tenantId: string): Promise<BillingAccount | null>;
6186
- \tfindByCustomerId(
6187
- \t\tcustomerId: string,
6188
- \t\tprovider: string,
6189
- \t): Promise<BillingAccount | null>;
6190
- \tsave(input: SaveBillingAccountInput): Promise<BillingAccount>;
6191
- }
6192
-
6193
- export function isBillingAccountActive(
6194
- \taccount: BillingAccount | null,
6195
- ): account is BillingAccount & { status: "active" | "trialing" } {
6196
- \treturn account?.status === "active" || account?.status === "trialing";
6197
- }
6198
- `;
6199
- }
6200
- function billingContractsFile() {
6201
- return `import { defineContractGroup } from "@beignet/core/contracts";
6202
- import { z } from "zod";
6203
- import {
6204
- \tbillingStatusOutputSchema,
6205
- \tcreateBillingPortalSessionOutputSchema,
6206
- \tcreateCheckoutSessionInputSchema,
6207
- \tcreateCheckoutSessionOutputSchema,
6208
- } from "@/features/billing/schemas";
6209
- import { errors } from "@/features/shared/errors";
6210
-
6211
- const billing = defineContractGroup()
6212
- \t.namespace("billing")
6213
- \t.prefix("/api/billing");
6214
- const checkoutHeadersSchema = z.object({
6215
- \t"idempotency-key": z.string().min(1),
6216
- });
6217
- const checkoutBilling = billing.headers(checkoutHeadersSchema);
6218
-
6219
- export const getBillingStatus = billing
6220
- \t.get("/")
6221
- \t.query(z.object({}))
6222
- \t.meta({ auth: "required" })
6223
- \t.errors({ Unauthorized: errors.Unauthorized })
6224
- \t.responses({
6225
- \t\t200: billingStatusOutputSchema,
6226
- \t})
6227
- \t.openapi({ summary: "Get billing status", tags: ["billing"] });
6228
-
6229
- export const createCheckoutSession = checkoutBilling
6230
- \t.post("/checkout")
6231
- \t.body(createCheckoutSessionInputSchema)
6232
- \t.meta({
6233
- \t\tauth: "required",
6234
- \t\tidempotency: {
6235
- \t\t\trequired: true,
6236
- \t\t\theader: "idempotency-key",
6237
- \t\t\tscope: "actor-tenant",
6238
- \t\t\tttlSec: 86_400,
6239
- \t\t},
6240
- \t})
6241
- \t.errors({
6242
- \t\tUnauthorized: errors.Unauthorized,
6243
- \t\tIdempotencyConflict: errors.IdempotencyConflict,
6244
- \t\tIdempotencyInProgress: errors.IdempotencyInProgress,
6245
- \t\tBillingCheckoutUnavailable: errors.BillingCheckoutUnavailable,
6246
- \t})
6247
- \t.responses({
6248
- \t\t201: createCheckoutSessionOutputSchema,
6249
- \t})
6250
- \t.openapi({ summary: "Create a checkout session", tags: ["billing"] });
6251
-
6252
- export const createBillingPortalSession = billing
6253
- \t.post("/portal")
6254
- \t.body(z.object({}))
6255
- \t.meta({ auth: "required" })
6256
- \t.errors({
6257
- \t\tUnauthorized: errors.Unauthorized,
6258
- \t\tBillingAccountNotFound: errors.BillingAccountNotFound,
6259
- \t})
6260
- \t.responses({
6261
- \t\t200: createBillingPortalSessionOutputSchema,
6262
- \t})
6263
- \t.openapi({ summary: "Create a billing portal session", tags: ["billing"] });
6264
-
6265
- export const billingContracts = [
6266
- \tgetBillingStatus,
6267
- \tcreateCheckoutSession,
6268
- \tcreateBillingPortalSession,
6269
- ];
6270
- `;
6271
- }
6272
- function billingUseCasesFile(config) {
6273
- return `import {
6274
- \tcreateIdempotencyFingerprint,
6275
- \trunIdempotently,
6276
- } from "@beignet/core/idempotency";
6277
- import type { PaymentWebhookEvent } from "@beignet/core/payments";
6278
- import { requireUser } from "@beignet/core/ports";
6279
- import { z } from "zod";
6280
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6281
- import { appError } from "@/features/shared/errors";
6282
- import { env } from "@/lib/env";
6283
- import { useCase } from "@/lib/use-case";
6284
- import { getBillingPlan } from "../pricing";
6285
- import type { BillingAccount } from "../ports";
6286
- import { isBillingAccountActive } from "../ports";
6287
- import {
6288
- \ttype BillingPlan,
6289
- \ttype BillingStatus,
6290
- \tbillingStatusOutputSchema,
6291
- \tcreateBillingPortalSessionOutputSchema,
6292
- \tcreateCheckoutSessionInputSchema,
6293
- \tcreateCheckoutSessionOutputSchema,
6294
- } from "../schemas";
6295
-
6296
- type ProviderPayload = Record<string, unknown>;
6297
-
6298
- const paymentWebhookEventSchema = z.custom<PaymentWebhookEvent>(
6299
- \t(value): value is PaymentWebhookEvent =>
6300
- \t\ttypeof value === "object" &&
6301
- \t\tvalue !== null &&
6302
- \t\ttypeof (value as PaymentWebhookEvent).id === "string" &&
6303
- \t\ttypeof (value as PaymentWebhookEvent).type === "string" &&
6304
- \t\ttypeof (value as PaymentWebhookEvent).provider === "string",
6305
- );
6306
- const paymentWebhookOutputSchema = z.object({
6307
- \treceived: z.boolean(),
6308
- \taccountUpdated: z.boolean(),
6309
- });
6310
-
6311
- function nowIso(): string {
6312
- \treturn new Date().toISOString();
6313
- }
6314
-
6315
- function record(value: unknown): ProviderPayload {
6316
- \treturn value && typeof value === "object" && !Array.isArray(value)
6317
- \t\t? (value as ProviderPayload)
6318
- \t\t: {};
6319
- }
6320
-
6321
- function stringValue(value: unknown): string | undefined {
6322
- \treturn typeof value === "string" && value.length > 0 ? value : undefined;
6323
- }
6324
-
6325
- function providerId(value: unknown): string | undefined {
6326
- \tif (typeof value === "string") return stringValue(value);
6327
- \tconst payload = record(value);
6328
- \treturn stringValue(payload.id);
6329
- }
6330
-
6331
- function metadataValue(data: ProviderPayload, key: string): string | undefined {
6332
- \treturn stringValue(record(data.metadata)[key]);
6333
- }
6334
-
6335
- function tenantIdFromPayload(data: ProviderPayload): string | undefined {
6336
- \treturn (
6337
- \t\tstringValue(data.client_reference_id) ??
6338
- \t\tmetadataValue(data, "tenantId") ??
6339
- \t\tmetadataValue(data, "tenant_id")
6340
- \t);
6341
- }
6342
-
6343
- function planFromPayload(data: ProviderPayload): BillingPlan {
6344
- \tconst plan = metadataValue(data, "plan");
6345
- \treturn plan === "team" ? plan : "team";
6346
- }
6347
-
6348
- function statusFromPayload(
6349
- \tdata: ProviderPayload,
6350
- \tfallback: BillingStatus,
6351
- ): BillingStatus {
6352
- \tconst status = stringValue(data.status);
6353
- \tswitch (status) {
6354
- \t\tcase "active":
6355
- \t\tcase "trialing":
6356
- \t\tcase "past_due":
6357
- \t\tcase "canceled":
6358
- \t\tcase "inactive":
6359
- \t\t\treturn status;
6360
- \t\tcase "unpaid":
6361
- \t\t\treturn "past_due";
6362
- \t\tcase "incomplete":
6363
- \t\t\treturn "inactive";
6364
- \t\tcase "incomplete_expired":
6365
- \t\t\treturn "canceled";
6366
- \t\tcase "paused":
6367
- \t\t\treturn "inactive";
6368
- \t\tdefault:
6369
- \t\t\treturn fallback;
6370
- \t}
6371
- }
6372
-
6373
- function periodEndFromPayload(data: ProviderPayload): string | undefined {
6374
- \tconst value = data.current_period_end;
6375
- \treturn typeof value === "number"
6376
- \t\t? new Date(value * 1000).toISOString()
6377
- \t\t: stringValue(value);
6378
- }
6379
-
6380
- function cancelAtPeriodEndFromPayload(
6381
- \tdata: ProviderPayload,
6382
- ): boolean | undefined {
6383
- \treturn typeof data.cancel_at_period_end === "boolean"
6384
- \t\t? data.cancel_at_period_end
6385
- \t\t: undefined;
6386
- }
6387
-
6388
- function billingTenantId(ctx: AppContext): string {
6389
- \tconst user = requireUser(ctx);
6390
- \treturn ctx.tenant?.id ?? user.id;
6391
- }
6392
-
6393
- function billingStatusOutput(tenantId: string, account: BillingAccount | null) {
6394
- \treturn {
6395
- \t\ttenantId,
6396
- \t\tplan: account?.plan ?? "team",
6397
- \t\tstatus: account?.status ?? "inactive",
6398
- \t\tactive: isBillingAccountActive(account),
6399
- \t\t...(account?.provider ? { provider: account.provider } : {}),
6400
- \t\t...(account?.customerId ? { customerId: account.customerId } : {}),
6401
- \t\t...(account?.subscriptionId
6402
- \t\t\t? { subscriptionId: account.subscriptionId }
6403
- \t\t\t: {}),
6404
- \t\t...(account?.currentPeriodEnd
6405
- \t\t\t? { currentPeriodEnd: account.currentPeriodEnd }
6406
- \t\t\t: {}),
6407
- \t\t...(account?.cancelAtPeriodEnd !== undefined
6408
- \t\t\t? { cancelAtPeriodEnd: account.cancelAtPeriodEnd }
6409
- \t\t\t: {}),
6410
- \t\t...(account?.checkoutSessionId
6411
- \t\t\t? { checkoutSessionId: account.checkoutSessionId }
6412
- \t\t\t: {}),
6413
- \t\t...(account?.lastEventId ? { lastEventId: account.lastEventId } : {}),
6414
- \t\t...(account?.updatedAt ? { updatedAt: account.updatedAt } : {}),
6415
- \t};
6416
- }
6417
-
6418
- async function saveBillingAccount(args: {
6419
- \texisting: BillingAccount | null;
6420
- \ttenantId: string;
6421
- \tprovider: string;
6422
- \tplan?: BillingPlan;
6423
- \tstatus?: BillingStatus;
6424
- \tcustomerId?: string;
6425
- \tsubscriptionId?: string;
6426
- \tcurrentPeriodEnd?: string;
6427
- \tcancelAtPeriodEnd?: boolean;
6428
- \tcheckoutSessionId?: string;
6429
- \tlastEventId?: string;
6430
- \tsave: (account: BillingAccount) => Promise<BillingAccount>;
6431
- }) {
6432
- \treturn args.save({
6433
- \t\ttenantId: args.tenantId,
6434
- \t\tprovider: args.provider,
6435
- \t\tplan: args.plan ?? args.existing?.plan ?? "team",
6436
- \t\tstatus: args.status ?? args.existing?.status ?? "inactive",
6437
- \t\tcustomerId: args.customerId ?? args.existing?.customerId,
6438
- \t\tsubscriptionId: args.subscriptionId ?? args.existing?.subscriptionId,
6439
- \t\tcurrentPeriodEnd:
6440
- \t\t\targs.currentPeriodEnd ?? args.existing?.currentPeriodEnd,
6441
- \t\tcancelAtPeriodEnd:
6442
- \t\t\targs.cancelAtPeriodEnd ?? args.existing?.cancelAtPeriodEnd,
6443
- \t\tcheckoutSessionId:
6444
- \t\t\targs.checkoutSessionId ?? args.existing?.checkoutSessionId,
6445
- \t\tlastEventId: args.lastEventId ?? args.existing?.lastEventId,
6446
- \t\tupdatedAt: nowIso(),
6447
- \t});
6448
- }
6449
-
6450
- async function applyCheckoutCompleted(
6451
- \tevent: PaymentWebhookEvent,
6452
- \tsave: (account: BillingAccount) => Promise<BillingAccount>,
6453
- \tfindByTenantId: (tenantId: string) => Promise<BillingAccount | null>,
6454
- ) {
6455
- \tconst data = record(event.data);
6456
- \tconst tenantId = tenantIdFromPayload(data);
6457
- \tif (!tenantId) return null;
6458
-
6459
- \tconst existing = await findByTenantId(tenantId);
6460
- \treturn saveBillingAccount({
6461
- \t\texisting,
6462
- \t\ttenantId,
6463
- \t\tprovider: event.provider,
6464
- \t\tplan: planFromPayload(data),
6465
- \t\tstatus: "active",
6466
- \t\tcustomerId: providerId(data.customer),
6467
- \t\tsubscriptionId: providerId(data.subscription),
6468
- \t\tcheckoutSessionId: stringValue(data.id),
6469
- \t\tlastEventId: event.id,
6470
- \t\tsave,
6471
- \t});
6472
- }
6473
-
6474
- async function applySubscriptionEvent(
6475
- \tevent: PaymentWebhookEvent,
6476
- \tsave: (account: BillingAccount) => Promise<BillingAccount>,
6477
- \tfindByCustomerId: (
6478
- \t\tcustomerId: string,
6479
- \t\tprovider: string,
6480
- \t) => Promise<BillingAccount | null>,
6481
- \tfindByTenantId: (tenantId: string) => Promise<BillingAccount | null>,
6482
- ) {
6483
- \tconst data = record(event.data);
6484
- \tconst customerId = providerId(data.customer);
6485
- \tconst explicitTenantId = tenantIdFromPayload(data);
6486
- \tconst existing = explicitTenantId
6487
- \t\t? await findByTenantId(explicitTenantId)
6488
- \t\t: customerId
6489
- \t\t\t? await findByCustomerId(customerId, event.provider)
6490
- \t\t\t: null;
6491
- \tconst tenantId = explicitTenantId ?? existing?.tenantId;
6492
- \tif (!tenantId) return null;
6493
-
6494
- \treturn saveBillingAccount({
6495
- \t\texisting,
6496
- \t\ttenantId,
6497
- \t\tprovider: event.provider,
6498
- \t\tplan: planFromPayload(data),
6499
- \t\tstatus:
6500
- \t\t\tevent.type === "customer.subscription.deleted"
6501
- \t\t\t\t? "canceled"
6502
- \t\t\t\t: statusFromPayload(data, existing?.status ?? "active"),
6503
- \t\tcustomerId,
6504
- \t\tsubscriptionId: providerId(data.id) ?? existing?.subscriptionId,
6505
- \t\tcurrentPeriodEnd: periodEndFromPayload(data),
6506
- \t\tcancelAtPeriodEnd:
6507
- \t\t\tevent.type === "customer.subscription.deleted"
6508
- \t\t\t\t? false
6509
- \t\t\t\t: cancelAtPeriodEndFromPayload(data),
6510
- \t\tlastEventId: event.id,
6511
- \t\tsave,
6512
- \t});
6513
- }
6514
-
6515
- async function applyInvoiceSucceeded(
6516
- \tevent: PaymentWebhookEvent,
6517
- \tsave: (account: BillingAccount) => Promise<BillingAccount>,
6518
- \tfindByCustomerId: (
6519
- \t\tcustomerId: string,
6520
- \t\tprovider: string,
6521
- \t) => Promise<BillingAccount | null>,
6522
- ) {
6523
- \tconst data = record(event.data);
6524
- \tconst customerId = providerId(data.customer);
6525
- \tif (!customerId) return null;
6526
-
6527
- \tconst existing = await findByCustomerId(customerId, event.provider);
6528
- \tif (!existing) return null;
6529
- \tif (existing.status === "canceled") return null;
6530
-
6531
- \treturn saveBillingAccount({
6532
- \t\texisting,
6533
- \t\ttenantId: existing.tenantId,
6534
- \t\tprovider: event.provider,
6535
- \t\tstatus: "active",
6536
- \t\tcustomerId,
6537
- \t\tsubscriptionId: providerId(data.subscription),
6538
- \t\tcancelAtPeriodEnd: cancelAtPeriodEndFromPayload(data),
6539
- \t\tlastEventId: event.id,
6540
- \t\tsave,
6541
- \t});
6542
- }
6543
-
6544
- async function applyInvoiceFailed(
6545
- \tevent: PaymentWebhookEvent,
6546
- \tsave: (account: BillingAccount) => Promise<BillingAccount>,
6547
- \tfindByCustomerId: (
6548
- \t\tcustomerId: string,
6549
- \t\tprovider: string,
6550
- \t) => Promise<BillingAccount | null>,
6551
- ) {
6552
- \tconst data = record(event.data);
6553
- \tconst customerId = providerId(data.customer);
6554
- \tif (!customerId) return null;
6555
-
6556
- \tconst existing = await findByCustomerId(customerId, event.provider);
6557
- \tif (!existing) return null;
6558
- \tif (existing.status === "canceled") return null;
6559
-
6560
- \treturn saveBillingAccount({
6561
- \t\texisting,
6562
- \t\ttenantId: existing.tenantId,
6563
- \t\tprovider: event.provider,
6564
- \t\tstatus: "past_due",
6565
- \t\tcustomerId,
6566
- \t\tsubscriptionId: providerId(data.subscription),
6567
- \t\tlastEventId: event.id,
6568
- \t\tsave,
6569
- \t});
6570
- }
6571
-
6572
- export const getBillingStatusUseCase = useCase
6573
- \t.query("billing.getStatus")
6574
- \t.input(z.object({}))
6575
- \t.output(billingStatusOutputSchema)
6576
- \t.run(async ({ ctx }) => {
6577
- \t\tconst tenantId = billingTenantId(ctx);
6578
- \t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
6579
- \t\treturn billingStatusOutput(tenantId, account);
6580
- \t});
6581
-
6582
- export const createCheckoutSessionUseCase = useCase
6583
- \t.command("billing.createCheckoutSession")
6584
- \t.input(createCheckoutSessionInputSchema)
6585
- \t.output(createCheckoutSessionOutputSchema)
6586
- \t.run(async ({ ctx, input }) => {
6587
- \t\tconst tenantId = billingTenantId(ctx);
6588
- \t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
6589
- \t\tconst plan = getBillingPlan(input.plan);
6590
- \t\tconst session = await ctx.ports.payments.createCheckoutSession({
6591
- \t\t\tmode: plan.mode,
6592
- \t\t\tlineItems: plan.lineItems,
6593
- \t\t\tsuccessUrl: \`\${env.APP_URL}/billing?checkout=success\`,
6594
- \t\t\tcancelUrl: \`\${env.APP_URL}/billing?checkout=cancelled\`,
6595
- \t\t\tcustomerId: account?.customerId,
6596
- \t\t\tclientReferenceId: tenantId,
6597
- \t\t\tmetadata: {
6598
- \t\t\t\ttenantId,
6599
- \t\t\t\tplan: input.plan,
6600
- \t\t\t},
6601
- \t\t});
6602
-
6603
- \t\tif (!session.url) {
6604
- \t\t\tthrow appError("BillingCheckoutUnavailable", {
6605
- \t\t\t\tdetails: { provider: session.provider, sessionId: session.id },
6606
- \t\t\t});
6607
- \t\t}
6608
-
6609
- \t\tawait ctx.ports.billing.save({
6610
- \t\t\ttenantId,
6611
- \t\t\tprovider: session.provider,
6612
- \t\t\tplan: input.plan,
6613
- \t\t\tstatus: account?.status ?? "inactive",
6614
- \t\t\tcustomerId: session.customerId ?? account?.customerId,
6615
- \t\t\tsubscriptionId: account?.subscriptionId,
6616
- \t\t\tcurrentPeriodEnd: account?.currentPeriodEnd,
6617
- \t\t\tcancelAtPeriodEnd: account?.cancelAtPeriodEnd,
6618
- \t\t\tcheckoutSessionId: session.id,
6619
- \t\t\tlastEventId: account?.lastEventId,
6620
- \t\t\tupdatedAt: nowIso(),
6621
- \t\t});
6622
-
6623
- \t\treturn {
6624
- \t\t\tsessionId: session.id,
6625
- \t\t\turl: session.url,
6626
- \t\t};
6627
- \t});
6628
-
6629
- export const createBillingPortalSessionUseCase = useCase
6630
- \t.command("billing.createPortalSession")
6631
- \t.input(z.object({}))
6632
- \t.output(createBillingPortalSessionOutputSchema)
6633
- \t.run(async ({ ctx }) => {
6634
- \t\tconst tenantId = billingTenantId(ctx);
6635
- \t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
6636
-
6637
- \t\tif (!account?.customerId) {
6638
- \t\t\tthrow appError("BillingAccountNotFound", {
6639
- \t\t\t\tdetails: { tenantId },
6640
- \t\t\t});
6641
- \t\t}
6642
-
6643
- \t\tconst session = await ctx.ports.payments.createBillingPortalSession({
6644
- \t\t\tcustomerId: account.customerId,
6645
- \t\t\treturnUrl: \`\${env.APP_URL}/billing\`,
6646
- \t\t});
6647
-
6648
- \t\treturn { url: session.url };
6649
- \t});
6650
-
6651
- export const handlePaymentWebhookUseCase = useCase
6652
- \t.command("billing.handlePaymentWebhook")
6653
- \t.input(paymentWebhookEventSchema)
6654
- \t.output(paymentWebhookOutputSchema)
6655
- \t.run(async ({ ctx, input }) => {
6656
- \t\tconst fingerprint = await createIdempotencyFingerprint({
6657
- \t\t\ttype: input.type,
6658
- \t\t\tdata: input.data,
6659
- \t\t});
6660
-
6661
- \t\treturn runIdempotently(ctx.ports.idempotency, {
6662
- \t\t\tnamespace: "webhooks.payments",
6663
- \t\t\tkey: input.id,
6664
- \t\t\tscope: { provider: input.provider },
6665
- \t\t\tfingerprint,
6666
- \t\t\tttlSec: 60 * 60 * 24 * 30,
6667
- \t\t\trun: () =>
6668
- \t\t\t\tctx.ports.uow.transaction(async (tx) => {
6669
- \t\t\t\t\tlet account: BillingAccount | null = null;
6670
-
6671
- \t\t\t\t\tif (input.type === "checkout.session.completed") {
6672
- \t\t\t\t\t\taccount = await applyCheckoutCompleted(
6673
- \t\t\t\t\t\t\tinput,
6674
- \t\t\t\t\t\t\ttx.billing.save,
6675
- \t\t\t\t\t\t\ttx.billing.findByTenantId,
6676
- \t\t\t\t\t\t);
6677
- \t\t\t\t\t} else if (input.type.startsWith("customer.subscription.")) {
6678
- \t\t\t\t\t\taccount = await applySubscriptionEvent(
6679
- \t\t\t\t\t\t\tinput,
6680
- \t\t\t\t\t\t\ttx.billing.save,
6681
- \t\t\t\t\t\t\ttx.billing.findByCustomerId,
6682
- \t\t\t\t\t\t\ttx.billing.findByTenantId,
6683
- \t\t\t\t\t\t);
6684
- \t\t\t\t\t} else if (input.type === "invoice.payment_succeeded") {
6685
- \t\t\t\t\t\taccount = await applyInvoiceSucceeded(
6686
- \t\t\t\t\t\t\tinput,
6687
- \t\t\t\t\t\t\ttx.billing.save,
6688
- \t\t\t\t\t\t\ttx.billing.findByCustomerId,
6689
- \t\t\t\t\t\t);
6690
- \t\t\t\t\t} else if (input.type === "invoice.payment_failed") {
6691
- \t\t\t\t\t\taccount = await applyInvoiceFailed(
6692
- \t\t\t\t\t\t\tinput,
6693
- \t\t\t\t\t\t\ttx.billing.save,
6694
- \t\t\t\t\t\t\ttx.billing.findByCustomerId,
6695
- \t\t\t\t\t\t);
6696
- \t\t\t\t\t}
6697
-
6698
- \t\t\t\t\treturn {
6699
- \t\t\t\t\t\treceived: true,
6700
- \t\t\t\t\t\taccountUpdated: Boolean(account),
6701
- \t\t\t\t\t};
6702
- \t\t\t\t}),
6703
- \t\t});
6704
- \t});
6705
- `;
6706
- }
6707
- function billingRoutesFile(config) {
6708
- const routeFilePath = path.join(config.paths.features, "billing/routes.ts");
6709
- return `import { defineRouteGroup } from "@beignet/next";
6710
- import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
6711
- import * as billingContracts from "@/features/billing/contracts";
6712
- import * as billingUseCases from "@/features/billing/use-cases";
6713
-
6714
- export const billingRoutes = defineRouteGroup<AppContext>()({
6715
- \tname: "billing",
6716
- \troutes: [
6717
- \t\t{
6718
- \t\t\tcontract: billingContracts.getBillingStatus,
6719
- \t\t\tuseCase: billingUseCases.getBillingStatusUseCase,
6720
- \t\t},
6721
- \t\t{
6722
- \t\t\tcontract: billingContracts.createCheckoutSession,
6723
- \t\t\tuseCase: billingUseCases.createCheckoutSessionUseCase,
6724
- \t\t},
6725
- \t\t{
6726
- \t\t\tcontract: billingContracts.createBillingPortalSession,
6727
- \t\t\tuseCase: billingUseCases.createBillingPortalSessionUseCase,
6728
- \t\t},
6729
- \t],
6730
- });
6731
- `;
6732
- }
6733
- function billingTestFile(config) {
6734
- return `import { describe, expect, it } from "bun:test";
6735
- import { createUseCaseTester } from "@beignet/core/application";
6736
- import { createAnonymousActor } from "@beignet/core/ports";
6737
- import { createTestUserActor } from "@beignet/core/ports/testing";
6738
- import {
6739
- \tcreateTestContextFactory,
6740
- \tcreateTestPorts,
6741
- } from "@beignet/core/testing";
6742
- import type { AppContext } from "${aliasModule(config.paths.appContext)}";
6743
- import { appPorts } from "@/infra/app-ports";
6744
- import type { AppTransactionPorts } from "@/ports";
6745
- import { createBillingEntitlements } from "../entitlements";
6746
- import type { BillingAccount, BillingRepository } from "../ports";
6747
- import {
6748
- \tcreateBillingPortalSessionUseCase,
6749
- \tcreateCheckoutSessionUseCase,
6750
- \tgetBillingStatusUseCase,
6751
- \thandlePaymentWebhookUseCase,
6752
- } from "../use-cases";
6753
-
6754
- function createMemoryBillingRepository(): BillingRepository {
6755
- \tconst accounts = new Map<string, BillingAccount>();
6756
-
6757
- \treturn {
6758
- \t\tasync findByTenantId(tenantId) {
6759
- \t\t\treturn accounts.get(tenantId) ?? null;
6760
- \t\t},
6761
-
6762
- \t\tasync findByCustomerId(customerId, provider) {
6763
- \t\t\treturn (
6764
- \t\t\t\t[...accounts.values()].find(
6765
- \t\t\t\t\t(account) =>
6766
- \t\t\t\t\t\taccount.customerId === customerId &&
6767
- \t\t\t\t\t\taccount.provider === provider,
6768
- \t\t\t\t) ?? null
6769
- \t\t\t);
6770
- \t\t},
6771
-
6772
- \t\tasync save(input) {
6773
- \t\t\taccounts.set(input.tenantId, { ...input });
6774
- \t\t\treturn input;
6775
- \t\t},
6776
- \t};
6777
- }
6778
-
6779
- function createHarness(options: { actor?: AppContext["actor"] } = {}) {
6780
- \tconst billing = createMemoryBillingRepository();
6781
- \tconst actor = options.actor ?? createTestUserActor("user_test");
6782
- \tconst fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
6783
- \t\tbase: appPorts,
6784
- \t\toverrides: {
6785
- \t\t\tbilling,
6786
- \t\t\tentitlements: createBillingEntitlements(billing),
6787
- \t\t\tgate: appPorts.gate,
6788
- \t\t},
6789
- \t});
6790
- \tconst createContext = createTestContextFactory<
6791
- \t\tAppContext,
6792
- \t\tAppContext["ports"]
6793
- \t>({
6794
- \t\tports: fixture.ports,
6795
- \t\tactor,
6796
- \t\tauth:
6797
- \t\t\tactor.type === "user" && actor.id
6798
- \t\t\t\t? { user: { id: actor.id }, session: { id: "session_test" } }
6799
- \t\t\t\t: null,
6800
- \t\trequestId: "test-request",
6801
- \t\ttraceId: "test-trace",
6802
- \t\ttenant: { id: "tenant_example" },
6803
- \t});
6804
-
6805
- \treturn {
6806
- \t\tbilling,
6807
- \t\tentitlements: fixture.ports.entitlements,
6808
- \t\tpayments: fixture.payments,
6809
- \t\ttester: createUseCaseTester<AppContext>(createContext),
6810
- \t};
6811
- }
6812
-
6813
- describe("billing use cases", () => {
6814
- \tit("requires an authenticated user to read billing status", async () => {
6815
- \t\tconst harness = createHarness({ actor: createAnonymousActor() });
6816
-
6817
- \t\tawait expect(
6818
- \t\t\tharness.tester.run(getBillingStatusUseCase, {}),
6819
- \t\t).rejects.toMatchObject({
6820
- \t\t\tcode: "UNAUTHORIZED",
6821
- \t\t});
6822
- \t});
6823
-
6824
- \tit("creates checkout sessions through the payments port and records billing state", async () => {
6825
- \t\tconst harness = createHarness();
6826
-
6827
- \t\tconst checkout = await harness.tester.run(createCheckoutSessionUseCase, {
6828
- \t\t\tplan: "team",
6829
- \t\t});
6830
-
6831
- \t\texpect(checkout.sessionId).toMatch(/^checkout_/);
6832
- \t\texpect(checkout.url).toContain("/checkout/");
6833
- \t\texpect(harness.payments.checkoutSessions).toHaveLength(1);
6834
- \t\texpect(harness.payments.checkoutSessions[0]?.input).toMatchObject({
6835
- \t\t\tmode: "subscription",
6836
- \t\t\tlineItems: [{ priceId: "price_team_example", quantity: 1 }],
6837
- \t\t\tclientReferenceId: "tenant_example",
6838
- \t\t\tmetadata: {
6839
- \t\t\t\ttenantId: "tenant_example",
6840
- \t\t\t\tplan: "team",
6841
- \t\t\t},
6842
- \t\t});
6843
- \t\texpect(
6844
- \t\t\tharness.payments.checkoutSessions[0]?.input.idempotencyKey,
6845
- \t\t).toBe(undefined);
6846
- \t\tawait expect(
6847
- \t\t\tharness.billing.findByTenantId("tenant_example"),
6848
- \t\t).resolves.toMatchObject({
6849
- \t\t\tcheckoutSessionId: checkout.sessionId,
6850
- \t\t\tplan: "team",
6851
- \t\t\tstatus: "inactive",
6852
- \t\t});
6853
- \t});
6854
-
6855
- \tit("creates billing portal sessions for tenants with provider customers", async () => {
6856
- \t\tconst harness = createHarness();
6857
- \t\tawait harness.billing.save({
6858
- \t\t\ttenantId: "tenant_example",
6859
- \t\t\tprovider: "memory",
6860
- \t\t\tplan: "team",
6861
- \t\t\tstatus: "active",
6862
- \t\t\tcustomerId: "cus_123",
6863
- \t\t\tupdatedAt: new Date(0).toISOString(),
6864
- \t\t});
6865
-
6866
- \t\tconst portal = await harness.tester.run(
6867
- \t\t\tcreateBillingPortalSessionUseCase,
6868
- \t\t\t{},
6869
- \t\t);
6870
-
6871
- \t\texpect(portal.url).toContain("/portal/");
6872
- \t\texpect(harness.payments.billingPortalSessions).toHaveLength(1);
6873
- \t\texpect(harness.payments.billingPortalSessions[0]?.input.customerId).toBe(
6874
- \t\t\t"cus_123",
6875
- \t\t);
6876
- \t});
6877
-
6878
- \tit.each(["active", "trialing"] as const)(
6879
- \t\t"grants billing entitlements for %s accounts",
6880
- \t\tasync (status) => {
6881
- \t\t\tconst harness = createHarness();
6882
- \t\t\tawait harness.billing.save({
6883
- \t\t\t\ttenantId: "tenant_example",
6884
- \t\t\t\tprovider: "stripe",
6885
- \t\t\t\tplan: "team",
6886
- \t\t\t\tstatus,
6887
- \t\t\t\tupdatedAt: new Date(0).toISOString(),
6888
- \t\t\t});
6889
-
6890
- \t\t\tawait expect(
6891
- \t\t\t\tharness.entitlements.can({
6892
- \t\t\t\t\tentitlement: "todos.create",
6893
- \t\t\t\t\tsubject: { type: "tenant", id: "tenant_example" },
6894
- \t\t\t\t}),
6895
- \t\t\t).resolves.toBe(true);
6896
- \t\t},
6897
- \t);
6898
-
6899
- \tit.each(["inactive", "past_due", "canceled"] as const)(
6900
- \t\t"denies billing entitlements for %s accounts",
6901
- \t\tasync (status) => {
6902
- \t\t\tconst harness = createHarness();
6903
- \t\t\tawait harness.billing.save({
6904
- \t\t\t\ttenantId: "tenant_example",
6905
- \t\t\t\tprovider: "stripe",
6906
- \t\t\t\tplan: "team",
6907
- \t\t\t\tstatus,
6908
- \t\t\t\tupdatedAt: new Date(0).toISOString(),
6909
- \t\t\t});
6910
-
6911
- \t\t\tawait expect(
6912
- \t\t\t\tharness.entitlements.inspect({
6913
- \t\t\t\t\tentitlement: "todos.create",
6914
- \t\t\t\t\tsubject: { type: "tenant", id: "tenant_example" },
6915
- \t\t\t\t}),
6916
- \t\t\t).resolves.toMatchObject({
6917
- \t\t\t\tallowed: false,
6918
- \t\t\t\tcode: "BILLING_INACTIVE",
6919
- \t\t\t});
6920
- \t\t},
6921
- \t);
6922
-
6923
- \tit("handles checkout and subscription webhooks idempotently", async () => {
6924
- \t\tconst harness = createHarness();
6925
- \t\tconst checkoutEvent = {
6926
- \t\t\tid: "evt_checkout_completed",
6927
- \t\t\ttype: "checkout.session.completed",
6928
- \t\t\tprovider: "stripe",
6929
- \t\t\tdata: {
6930
- \t\t\t\tid: "cs_123",
6931
- \t\t\t\tclient_reference_id: "tenant_example",
6932
- \t\t\t\tcustomer: "cus_123",
6933
- \t\t\t\tsubscription: "sub_123",
6934
- \t\t\t\tmetadata: {
6935
- \t\t\t\t\ttenantId: "tenant_example",
6936
- \t\t\t\t\tplan: "team",
6937
- \t\t\t\t},
6938
- \t\t\t},
6939
- \t\t};
6940
- \t\tconst subscriptionEvent = {
6941
- \t\t\tid: "evt_subscription_updated",
6942
- \t\t\ttype: "customer.subscription.updated",
6943
- \t\t\tprovider: "stripe",
6944
- \t\t\tdata: {
6945
- \t\t\t\tid: "sub_123",
6946
- \t\t\t\tcustomer: "cus_123",
6947
- \t\t\t\tstatus: "active",
6948
- \t\t\t\tcurrent_period_end: 1_799_798_400,
6949
- \t\t\t\tcancel_at_period_end: true,
6950
- \t\t\t},
6951
- \t\t};
6952
-
6953
- \t\tawait expect(
6954
- \t\t\tharness.tester.run(handlePaymentWebhookUseCase, checkoutEvent),
6955
- \t\t).resolves.toEqual({ received: true, accountUpdated: true });
6956
- \t\tawait expect(
6957
- \t\t\tharness.tester.run(handlePaymentWebhookUseCase, checkoutEvent),
6958
- \t\t).resolves.toEqual({ received: true, accountUpdated: true });
6959
- \t\tawait expect(
6960
- \t\t\tharness.tester.run(handlePaymentWebhookUseCase, subscriptionEvent),
6961
- \t\t).resolves.toEqual({ received: true, accountUpdated: true });
6962
- \t\tawait expect(
6963
- \t\t\tharness.tester.run(getBillingStatusUseCase, {}),
6964
- \t\t).resolves.toMatchObject({
6965
- \t\t\ttenantId: "tenant_example",
6966
- \t\t\tactive: true,
6967
- \t\t\tstatus: "active",
6968
- \t\t\tcustomerId: "cus_123",
6969
- \t\t\tsubscriptionId: "sub_123",
6970
- \t\t\tcurrentPeriodEnd: "2027-01-13T00:00:00.000Z",
6971
- \t\t\tcancelAtPeriodEnd: true,
6972
- \t\t\tcheckoutSessionId: "cs_123",
6973
- \t\t\tlastEventId: "evt_subscription_updated",
6974
- \t\t});
6975
- \t});
6976
-
6977
- \tit("marks active subscriptions as past due when invoice payment fails", async () => {
6978
- \t\tconst harness = createHarness();
6979
- \t\tawait harness.billing.save({
6980
- \t\t\ttenantId: "tenant_example",
6981
- \t\t\tprovider: "stripe",
6982
- \t\t\tplan: "team",
6983
- \t\t\tstatus: "active",
6984
- \t\t\tcustomerId: "cus_123",
6985
- \t\t\tsubscriptionId: "sub_123",
6986
- \t\t\tupdatedAt: new Date(0).toISOString(),
6987
- \t\t});
6988
-
6989
- \t\tawait expect(
6990
- \t\t\tharness.tester.run(handlePaymentWebhookUseCase, {
6991
- \t\t\t\tid: "evt_invoice_failed",
6992
- \t\t\t\ttype: "invoice.payment_failed",
6993
- \t\t\t\tprovider: "stripe",
6994
- \t\t\t\tdata: {
6995
- \t\t\t\t\tcustomer: "cus_123",
6996
- \t\t\t\t\tsubscription: "sub_123",
6997
- \t\t\t\t},
6998
- \t\t\t}),
6999
- \t\t).resolves.toEqual({ received: true, accountUpdated: true });
7000
- \t\tawait expect(
7001
- \t\t\tharness.billing.findByTenantId("tenant_example"),
7002
- \t\t).resolves.toMatchObject({
7003
- \t\t\tstatus: "past_due",
7004
- \t\t\tlastEventId: "evt_invoice_failed",
7005
- \t\t});
7006
- \t});
7007
-
7008
- \tit("preserves pending cancellation when an invoice succeeds", async () => {
7009
- \t\tconst harness = createHarness();
7010
- \t\tawait harness.billing.save({
7011
- \t\t\ttenantId: "tenant_example",
7012
- \t\t\tprovider: "stripe",
7013
- \t\t\tplan: "team",
7014
- \t\t\tstatus: "active",
7015
- \t\t\tcustomerId: "cus_123",
7016
- \t\t\tsubscriptionId: "sub_123",
7017
- \t\t\tcancelAtPeriodEnd: true,
7018
- \t\t\tupdatedAt: new Date(0).toISOString(),
7019
- \t\t});
7020
-
7021
- \t\tawait expect(
7022
- \t\t\tharness.tester.run(handlePaymentWebhookUseCase, {
7023
- \t\t\t\tid: "evt_invoice_succeeded",
7024
- \t\t\t\ttype: "invoice.payment_succeeded",
7025
- \t\t\t\tprovider: "stripe",
7026
- \t\t\t\tdata: {
7027
- \t\t\t\t\tcustomer: "cus_123",
7028
- \t\t\t\t\tsubscription: "sub_123",
7029
- \t\t\t\t},
7030
- \t\t\t}),
7031
- \t\t).resolves.toEqual({ received: true, accountUpdated: true });
7032
- \t\tawait expect(
7033
- \t\t\tharness.billing.findByTenantId("tenant_example"),
7034
- \t\t).resolves.toMatchObject({
7035
- \t\t\tstatus: "active",
7036
- \t\t\tcancelAtPeriodEnd: true,
7037
- \t\t\tlastEventId: "evt_invoice_succeeded",
7038
- \t\t});
7039
- \t});
7040
- });
7041
- `;
7042
- }
7043
- function billingDrizzleSchemaFile(database) {
7044
- const dialect = drizzleDialects[database];
7045
- const idColumn = database === "mysql"
7046
- ? (columnName) => `varchar("${columnName}", { length: 255 })`
7047
- : (columnName) => `text("${columnName}")`;
7048
- const imports = database === "mysql"
7049
- ? "mysqlTable, uniqueIndex, varchar"
7050
- : `${dialect.tableFunction}, text, uniqueIndex`;
7051
- return `import type { BillingPlan, BillingStatus } from "@/features/billing/schemas";
7052
- import { ${imports} } from "${dialect.columnModule}";
7053
-
7054
- export const billingAccounts = ${dialect.tableFunction}(
7055
- \t"billing_accounts",
7056
- \t{
7057
- \t\ttenantId: ${idColumn("tenant_id")}.primaryKey(),
7058
- \t\tplan: ${idColumn("plan")}.$type<BillingPlan>().notNull(),
7059
- \t\tstatus: ${idColumn("status")}.$type<BillingStatus>().notNull(),
7060
- \t\tprovider: ${idColumn("provider")}.notNull(),
7061
- \t\tcustomerId: ${idColumn("customer_id")},
7062
- \t\tsubscriptionId: ${idColumn("subscription_id")},
7063
- \t\tcurrentPeriodEnd: ${idColumn("current_period_end")},
7064
- \t\tcancelAtPeriodEnd: ${idColumn("cancel_at_period_end")}.$type<"true" | "false">(),
7065
- \t\tcheckoutSessionId: ${idColumn("checkout_session_id")},
7066
- \t\tlastEventId: ${idColumn("last_event_id")},
7067
- \t\tupdatedAt: ${idColumn("updated_at")}.notNull(),
7068
- \t},
7069
- \t(table) => ({
7070
- \t\tproviderCustomerUnique: uniqueIndex(
7071
- \t\t\t"billing_accounts_provider_customer_unique",
7072
- \t\t).on(table.provider, table.customerId),
7073
- \t\tproviderSubscriptionUnique: uniqueIndex(
7074
- \t\t\t"billing_accounts_provider_subscription_unique",
7075
- \t\t).on(table.provider, table.subscriptionId),
7076
- \t}),
7077
- );
7078
- `;
7079
- }
7080
- function billingDrizzleRepositoryFile(config, database) {
7081
- const dialect = drizzleDialects[database];
7082
- const billingPortPath = path.join(config.paths.features, "billing/ports.ts");
7083
- const upsert = database === "mysql"
7084
- ? `\t\t\tconst row = toRow(input);
7085
- \t\t\tawait db
7086
- \t\t\t\t.insert(schema.billingAccounts)
7087
- \t\t\t\t.values(row)
7088
- \t\t\t\t.onDuplicateKeyUpdate({ set: row });
7089
-
7090
- \t\t\tconst [saved] = await db
7091
- \t\t\t\t.select()
7092
- \t\t\t\t.from(schema.billingAccounts)
7093
- \t\t\t\t.where(eq(schema.billingAccounts.tenantId, input.tenantId))
7094
- \t\t\t\t.limit(1);
7095
-
7096
- \t\t\tif (!saved) {
7097
- \t\t\t\tthrow new Error("Billing account save did not return a row.");
7098
- \t\t\t}
7099
-
7100
- \t\t\treturn toBillingAccount(saved);`
7101
- : `\t\t\tconst [row] = await db
7102
- \t\t\t\t.insert(schema.billingAccounts)
7103
- \t\t\t\t.values(toRow(input))
7104
- \t\t\t\t.onConflictDoUpdate({
7105
- \t\t\t\t\ttarget: schema.billingAccounts.tenantId,
7106
- \t\t\t\t\tset: toRow(input),
7107
- \t\t\t\t})
7108
- \t\t\t\t.returning();
7109
-
7110
- \t\t\tif (!row) {
7111
- \t\t\t\tthrow new Error("Billing account save did not return a row.");
7112
- \t\t\t}
7113
-
7114
- \t\t\treturn toBillingAccount(row);`;
7115
- return `import type { ${dialect.dbTypeName} } from "@beignet/provider-db-drizzle/${dialect.subpath}";
7116
- import { and, eq } from "drizzle-orm";
7117
- import type {
7118
- \tBillingAccount,
7119
- \tBillingRepository,
7120
- \tSaveBillingAccountInput,
7121
- } from "${aliasModule(billingPortPath)}";
7122
- import * as schema from "@/infra/db/schema";
7123
-
7124
- type BillingAccountRow = typeof schema.billingAccounts.$inferSelect;
7125
-
7126
- function toBillingAccount(row: BillingAccountRow): BillingAccount {
7127
- \treturn {
7128
- \t\ttenantId: row.tenantId,
7129
- \t\tplan: row.plan,
7130
- \t\tstatus: row.status,
7131
- \t\tprovider: row.provider,
7132
- \t\t...(row.customerId ? { customerId: row.customerId } : {}),
7133
- \t\t...(row.subscriptionId ? { subscriptionId: row.subscriptionId } : {}),
7134
- \t\t...(row.currentPeriodEnd ? { currentPeriodEnd: row.currentPeriodEnd } : {}),
7135
- \t\t...(row.cancelAtPeriodEnd
7136
- \t\t\t? { cancelAtPeriodEnd: row.cancelAtPeriodEnd === "true" }
7137
- \t\t\t: {}),
7138
- \t\t...(row.checkoutSessionId
7139
- \t\t\t? { checkoutSessionId: row.checkoutSessionId }
7140
- \t\t\t: {}),
7141
- \t\t...(row.lastEventId ? { lastEventId: row.lastEventId } : {}),
7142
- \t\tupdatedAt: row.updatedAt,
7143
- \t};
7144
- }
7145
-
7146
- function toRow(input: SaveBillingAccountInput) {
7147
- \treturn {
7148
- \t\ttenantId: input.tenantId,
7149
- \t\tplan: input.plan,
7150
- \t\tstatus: input.status,
7151
- \t\tprovider: input.provider,
7152
- \t\tcustomerId: input.customerId ?? null,
7153
- \t\tsubscriptionId: input.subscriptionId ?? null,
7154
- \t\tcurrentPeriodEnd: input.currentPeriodEnd ?? null,
7155
- \t\tcancelAtPeriodEnd:
7156
- \t\t\tinput.cancelAtPeriodEnd === undefined
7157
- \t\t\t\t? null
7158
- \t\t\t\t: input.cancelAtPeriodEnd
7159
- \t\t\t\t\t? ("true" as const)
7160
- \t\t\t\t\t: ("false" as const),
7161
- \t\tcheckoutSessionId: input.checkoutSessionId ?? null,
7162
- \t\tlastEventId: input.lastEventId ?? null,
7163
- \t\tupdatedAt: input.updatedAt,
7164
- \t};
7165
- }
7166
-
7167
- export function createDrizzleBillingRepository(
7168
- \tdb: ${dialect.dbTypeName}<typeof schema>,
7169
- ): BillingRepository {
7170
- \treturn {
7171
- \t\tasync findByTenantId(tenantId) {
7172
- \t\t\tconst [row] = await db
7173
- \t\t\t\t.select()
7174
- \t\t\t\t.from(schema.billingAccounts)
7175
- \t\t\t\t.where(eq(schema.billingAccounts.tenantId, tenantId))
7176
- \t\t\t\t.limit(1);
7177
-
7178
- \t\t\treturn row ? toBillingAccount(row) : null;
7179
- \t\t},
7180
-
7181
- \t\tasync findByCustomerId(customerId, provider) {
7182
- \t\t\tconst rows = await db
7183
- \t\t\t\t.select()
7184
- \t\t\t\t.from(schema.billingAccounts)
7185
- \t\t\t\t.where(
7186
- \t\t\t\t\tand(
7187
- \t\t\t\t\t\teq(schema.billingAccounts.customerId, customerId),
7188
- \t\t\t\t\t\teq(schema.billingAccounts.provider, provider),
7189
- \t\t\t\t\t),
7190
- \t\t\t\t)
7191
- \t\t\t\t.limit(2);
7192
-
7193
- \t\t\tif (rows.length > 1) {
7194
- \t\t\t\tthrow new Error(
7195
- \t\t\t\t\t\`Multiple billing accounts found for provider "\${provider}" customer "\${customerId}".\`,
7196
- \t\t\t\t);
7197
- \t\t\t}
7198
-
7199
- \t\t\treturn rows[0] ? toBillingAccount(rows[0]) : null;
7200
- \t\t},
7201
-
7202
- \t\tasync save(input) {
7203
- ${upsert}
7204
- \t\t},
7205
- \t};
7206
- }
7207
- `;
7208
- }
7209
- function billingWebhookRouteFile() {
7210
- return `import { createPaymentWebhookRoute } from "@beignet/next";
7211
- import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
7212
- import { server } from "@/server";
7213
-
7214
- export const runtime = "nodejs";
7215
-
7216
- export const { POST } = createPaymentWebhookRoute({
7217
- \tserver,
7218
- \thandle: async ({ ctx, event }) => {
7219
- \t\tawait handlePaymentWebhookUseCase.run({ ctx, input: event });
7220
- \t\treturn {
7221
- \t\t\tstatus: 200,
7222
- \t\t\tbody: {
7223
- \t\t\t\treceived: true,
7224
- \t\t\t},
7225
- \t\t};
7226
- \t},
7227
- });
7228
- `;
7229
- }
7230
3642
  function featureUiComponentFile(names, config) {
7231
3643
  const componentPath = featureUiComponentFilePath(names, config);
7232
3644
  const clientQueriesPath = featureUiClientQueriesFilePath(names, config);
@@ -7345,12 +3757,12 @@ function scheduleRouteFile(names, config) {
7345
3757
  return `import { createScheduleRoute } from "@beignet/next";
7346
3758
  import { ${names.scheduleExportName} } from "${aliasModule(scheduleFilePath(names, config))}";
7347
3759
  import { env } from "@/lib/env";
7348
- import { server } from "${aliasModule(config.paths.server)}";
3760
+ import { getServer } from "${aliasModule(config.paths.server)}";
7349
3761
 
7350
3762
  export const runtime = "nodejs";
7351
3763
 
7352
3764
  export const { GET, POST } = createScheduleRoute({
7353
- \tserver,
3765
+ \tserver: getServer,
7354
3766
  \tschedules: [${names.scheduleExportName}],
7355
3767
  \tschedule: ${names.scheduleExportName}.name,
7356
3768
  \tsecret: env.CRON_SECRET,