@beignet/cli 0.0.22 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/make.ts CHANGED
@@ -1,7 +1,7 @@
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 type { MakeFeatureAddon } from "./choices.js";
4
+ import type { MakeFeatureAddon, MakeFeatureRecipe } from "./choices.js";
5
5
  import {
6
6
  type BeignetConfig,
7
7
  clientIndexPath,
@@ -33,8 +33,11 @@ type MakeResourceOptions = {
33
33
  softDelete?: boolean;
34
34
  };
35
35
 
36
- export type { MakeFeatureAddon } from "./choices.js";
37
- export { makeFeatureAddonChoices } from "./choices.js";
36
+ export type { MakeFeatureAddon, MakeFeatureRecipe } from "./choices.js";
37
+ export {
38
+ makeFeatureAddonChoices,
39
+ makeFeatureRecipeChoices,
40
+ } from "./choices.js";
38
41
 
39
42
  type NormalizedMakeFeatureAddon =
40
43
  | "policy"
@@ -51,6 +54,7 @@ type NormalizedMakeFeatureAddon =
51
54
 
52
55
  type MakeFeatureOptions = MakeResourceOptions & {
53
56
  with?: readonly MakeFeatureAddon[];
57
+ recipe?: MakeFeatureRecipe;
54
58
  };
55
59
 
56
60
  type MakeContractOptions = {
@@ -490,17 +494,26 @@ export async function makeResource(
490
494
  export async function makeFeature(
491
495
  options: MakeFeatureOptions,
492
496
  ): Promise<MakeFeatureResult> {
493
- const addons = normalizeMakeFeatureAddons(options.with ?? []);
497
+ const addons = normalizeMakeFeatureAddons([
498
+ ...makeFeatureRecipeAddons(options.recipe),
499
+ ...(options.with ?? []),
500
+ ...(options.events ? (["events"] as const) : []),
501
+ ]);
502
+ const featureOptions = {
503
+ ...options,
504
+ events: addons.includes("event"),
505
+ };
506
+
494
507
  if (addons.length === 0) {
495
- return makeFeatureResource(options);
508
+ return makeFeatureResource(featureOptions);
496
509
  }
497
510
 
498
511
  if (options.dryRun) {
499
- return makeFeatureSlice(options, addons);
512
+ return makeFeatureSlice(featureOptions, addons);
500
513
  }
501
514
 
502
- await makeFeatureSlice({ ...options, dryRun: true }, addons);
503
- return makeFeatureSlice(options, addons);
515
+ await makeFeatureSlice({ ...featureOptions, dryRun: true }, addons);
516
+ return makeFeatureSlice(featureOptions, addons);
504
517
  }
505
518
 
506
519
  export async function makePayments(
@@ -718,6 +731,15 @@ function resourceGenerationOptions(
718
731
  options: MakeResourceOptions,
719
732
  mode: ResourceGenerationMode,
720
733
  ): ResourceGenerationOptions {
734
+ if (mode === "feature") {
735
+ return {
736
+ auth: false,
737
+ tenant: false,
738
+ events: Boolean(options.events),
739
+ softDelete: false,
740
+ };
741
+ }
742
+
721
743
  if (mode !== "resource") {
722
744
  return { auth: false, tenant: false, events: false, softDelete: false };
723
745
  }
@@ -831,6 +853,28 @@ function normalizeMakeFeatureAddons(
831
853
  return featureAddonOrder.filter((addon) => normalized.has(addon));
832
854
  }
833
855
 
856
+ function makeFeatureRecipeAddons(
857
+ recipe: MakeFeatureRecipe | undefined,
858
+ ): MakeFeatureAddon[] {
859
+ if (recipe === "full-slice") {
860
+ return [
861
+ "policy",
862
+ "factories",
863
+ "seeds",
864
+ "tasks",
865
+ "events",
866
+ "listeners",
867
+ "jobs",
868
+ "notifications",
869
+ "schedules",
870
+ "ui",
871
+ "uploads",
872
+ ];
873
+ }
874
+
875
+ return [];
876
+ }
877
+
834
878
  const featureAddonOrder: readonly NormalizedMakeFeatureAddon[] = [
835
879
  "policy",
836
880
  "factory",
@@ -1462,8 +1506,11 @@ export async function makeListener(
1462
1506
  if (!options.skipEventAssert) {
1463
1507
  await assertListenerEventExists(targetDir, names.event, config);
1464
1508
  }
1509
+ await updateListenerProviderWiring(targetDir, names, config, {
1510
+ dryRun: true,
1511
+ });
1465
1512
 
1466
- return makeFeatureArtifact({
1513
+ const result = await makeFeatureArtifact({
1467
1514
  targetDir,
1468
1515
  config,
1469
1516
  force: Boolean(options.force),
@@ -1478,6 +1525,26 @@ export async function makeListener(
1478
1525
  "@beignet/core": beignetDependencyVersion,
1479
1526
  },
1480
1527
  });
1528
+
1529
+ const providerResult = await updateListenerProviderWiring(
1530
+ targetDir,
1531
+ names,
1532
+ config,
1533
+ {
1534
+ dryRun: Boolean(options.dryRun),
1535
+ },
1536
+ );
1537
+ if (providerResult === "created")
1538
+ result.createdFiles.push(providersFilePath(config));
1539
+ if (providerResult === "updated")
1540
+ result.updatedFiles.push(providersFilePath(config));
1541
+ if (providerResult === "skipped")
1542
+ result.skippedFiles.push(providersFilePath(config));
1543
+ if (!result.files.includes(providersFilePath(config))) {
1544
+ result.files.push(providersFilePath(config));
1545
+ }
1546
+
1547
+ return result;
1481
1548
  }
1482
1549
 
1483
1550
  async function assertListenerEventExists(
@@ -1496,6 +1563,98 @@ async function assertListenerEventExists(
1496
1563
  }
1497
1564
  }
1498
1565
 
1566
+ async function updateListenerProviderWiring(
1567
+ targetDir: string,
1568
+ names: ListenerNames,
1569
+ config: ResolvedBeignetConfig,
1570
+ options: { dryRun: boolean },
1571
+ ): Promise<WriteGeneratedFileResult> {
1572
+ const providersFile = providersFilePath(config);
1573
+ const filePath = path.join(targetDir, providersFile);
1574
+ const original = await readOptionalFile(filePath);
1575
+ if (original === undefined) {
1576
+ throw new Error(
1577
+ `Could not find the generated providers file ${providersFile}. Register listeners manually with registerListeners(...), or restore the generated providers file before running make listener.`,
1578
+ );
1579
+ }
1580
+
1581
+ const listenerIndex = featureArtifactIndexFile("listener", names, config);
1582
+ const providerName = `${names.featureSingularCamel}ListenersProvider`;
1583
+ let next = original;
1584
+ next = addNamedImport(next, "registerListeners", "@beignet/core/events");
1585
+ next = addNamedImport(next, "createServiceActor", "@beignet/core/ports");
1586
+ next = addNamedImport(next, "createProvider", "@beignet/core/providers");
1587
+ next = addNamedTypeImport(
1588
+ next,
1589
+ "AppContext",
1590
+ aliasModule(config.paths.appContext),
1591
+ );
1592
+ next = addNamedTypeImport(
1593
+ next,
1594
+ "AppServiceContextInput",
1595
+ aliasModule(path.join(path.dirname(config.paths.server), "context.ts")),
1596
+ );
1597
+ next = addNamedTypeImport(next, "AppPorts", aliasModule(config.paths.ports));
1598
+
1599
+ const listenerImport = `import { ${listenerIndex.registryName} } from "${aliasModule(listenerIndex.path)}";`;
1600
+ if (!next.includes(listenerImport)) {
1601
+ next = insertAfterImports(next, listenerImport);
1602
+ }
1603
+
1604
+ if (!new RegExp(`\\bconst\\s+${providerName}\\b`).test(next)) {
1605
+ const providerDefinition = `const ${providerName} = createProvider<
1606
+ \tPick<AppPorts, "eventBus" | "logger">,
1607
+ \tAppContext,
1608
+ \tAppServiceContextInput
1609
+ >()({
1610
+ \tname: "${names.feature.kebab}-listeners",
1611
+ \tsetup({ ports, createServiceContext }) {
1612
+ \t\tconst unregister = registerListeners(ports.eventBus, ${listenerIndex.registryName}, {
1613
+ \t\t\tctx: () =>
1614
+ \t\t\t\tcreateServiceContext({
1615
+ \t\t\t\t\tactor: createServiceActor("beignet-listener"),
1616
+ \t\t\t\t}),
1617
+ \t\t\tonError(error, listener) {
1618
+ \t\t\t\tports.logger.error("Event listener failed", {
1619
+ \t\t\t\t\terror,
1620
+ \t\t\t\t\tlistenerName: listener.name,
1621
+ \t\t\t\t});
1622
+ \t\t\t},
1623
+ \t\t});
1624
+
1625
+ \t\treturn {
1626
+ \t\t\tstop() {
1627
+ \t\t\t\tunregister();
1628
+ \t\t\t},
1629
+ \t\t};
1630
+ \t},
1631
+ });
1632
+ `;
1633
+ const withProvider = next.replace(
1634
+ /\nexport const providers = \[/,
1635
+ `\n${providerDefinition}\nexport const providers = [`,
1636
+ );
1637
+ if (withProvider === next) {
1638
+ throw new Error(
1639
+ `Could not find the exported providers array in ${providersFile}. Register ${listenerIndex.registryName} manually with registerListeners(...), or restore the generated providers file before running make listener.`,
1640
+ );
1641
+ }
1642
+ next = withProvider;
1643
+ }
1644
+
1645
+ const appended = appendToNamedArray(next, "providers", providerName);
1646
+ if (appended.kind === "missing") {
1647
+ throw new Error(
1648
+ `Could not find the exported providers array in ${providersFile}. Register ${listenerIndex.registryName} manually with registerListeners(...), or restore the generated providers file before running make listener.`,
1649
+ );
1650
+ }
1651
+ if (appended.kind === "updated") next = appended.source;
1652
+
1653
+ if (next === original) return "skipped";
1654
+ if (!options.dryRun) await writeFile(filePath, next);
1655
+ return "updated";
1656
+ }
1657
+
1499
1658
  export async function makeSchedule(
1500
1659
  options: MakeScheduleOptions,
1501
1660
  ): Promise<MakeScheduleResult> {
@@ -1570,6 +1729,12 @@ const cronSecretEnvEntry = `\t\tCRON_SECRET: z.string().min(1).optional(),`;
1570
1729
  // slots in directly below it, matching the documented env shape.
1571
1730
  const cronSecretEnvAnchor = `\t\tAPP_URL: z.string().url().default("http://localhost:3000"),`;
1572
1731
 
1732
+ type FileChangeSet = {
1733
+ createdFiles: string[];
1734
+ updatedFiles: string[];
1735
+ skippedFiles: string[];
1736
+ };
1737
+
1573
1738
  type CronSecretWiringPlan = {
1574
1739
  files: Array<GeneratedFile & { result: "created" | "updated" }>;
1575
1740
  skippedFiles: string[];
@@ -1703,23 +1868,58 @@ async function updateOutboxPortWiring(
1703
1868
  targetDir: string,
1704
1869
  config: ResolvedBeignetConfig,
1705
1870
  options: { dryRun: boolean },
1706
- ): Promise<string[]> {
1707
- const updated = new Set<string>();
1871
+ ): Promise<FileChangeSet> {
1872
+ const changes: FileChangeSet = {
1873
+ createdFiles: [],
1874
+ updatedFiles: [],
1875
+ skippedFiles: [],
1876
+ };
1708
1877
 
1709
1878
  if (await updateOutboxPorts(targetDir, config, options)) {
1710
- updated.add(config.paths.ports);
1879
+ changes.updatedFiles.push(config.paths.ports);
1711
1880
  }
1712
1881
  if (await updateOutboxInfrastructurePorts(targetDir, config, options)) {
1713
- updated.add(config.paths.infrastructurePorts);
1882
+ changes.updatedFiles.push(config.paths.infrastructurePorts);
1714
1883
  }
1715
1884
  if (await updateOutboxDatabaseProvider(targetDir, config, options)) {
1716
- updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
1885
+ changes.updatedFiles.push(
1886
+ path.join(infrastructureDir(config), "db/provider.ts"),
1887
+ );
1717
1888
  }
1718
1889
  if (await updateOutboxRepositoriesReturnType(targetDir, config, options)) {
1719
- updated.add(drizzleRepositoriesPath(config));
1890
+ changes.updatedFiles.push(drizzleRepositoriesPath(config));
1720
1891
  }
1721
1892
 
1722
- return [...updated];
1893
+ const schemaResult = await updateOutboxDrizzleSchema(
1894
+ targetDir,
1895
+ config,
1896
+ options,
1897
+ );
1898
+ if (schemaResult.result === "created") {
1899
+ changes.createdFiles.push(schemaResult.path);
1900
+ }
1901
+ if (schemaResult.result === "updated") {
1902
+ changes.updatedFiles.push(schemaResult.path);
1903
+ }
1904
+ if (schemaResult.result === "skipped") {
1905
+ changes.skippedFiles.push(schemaResult.path);
1906
+ }
1907
+ if (schemaResult.canExport) {
1908
+ const schemaIndexUpdated = await updateOutboxDrizzleSchemaIndex(
1909
+ targetDir,
1910
+ config,
1911
+ options,
1912
+ );
1913
+ if (schemaIndexUpdated) {
1914
+ changes.updatedFiles.push(drizzleSchemaIndexPath(config));
1915
+ }
1916
+ }
1917
+
1918
+ return {
1919
+ createdFiles: uniqueStrings(changes.createdFiles),
1920
+ updatedFiles: uniqueStrings(changes.updatedFiles),
1921
+ skippedFiles: uniqueStrings(changes.skippedFiles),
1922
+ };
1723
1923
  }
1724
1924
 
1725
1925
  async function applyOutboxPortWiring(
@@ -1728,11 +1928,19 @@ async function applyOutboxPortWiring(
1728
1928
  config: ResolvedBeignetConfig,
1729
1929
  options: { dryRun: boolean },
1730
1930
  ): Promise<void> {
1731
- const updatedFiles = await updateOutboxPortWiring(targetDir, config, options);
1732
- for (const file of updatedFiles) {
1931
+ const changes = await updateOutboxPortWiring(targetDir, config, options);
1932
+ for (const file of changes.createdFiles) {
1933
+ if (!result.createdFiles.includes(file)) result.createdFiles.push(file);
1934
+ if (!result.files.includes(file)) result.files.push(file);
1935
+ }
1936
+ for (const file of changes.updatedFiles) {
1733
1937
  if (!result.updatedFiles.includes(file)) result.updatedFiles.push(file);
1734
1938
  if (!result.files.includes(file)) result.files.push(file);
1735
1939
  }
1940
+ for (const file of changes.skippedFiles) {
1941
+ if (!result.skippedFiles.includes(file)) result.skippedFiles.push(file);
1942
+ if (!result.files.includes(file)) result.files.push(file);
1943
+ }
1736
1944
  }
1737
1945
 
1738
1946
  async function updateOutboxPorts(
@@ -1862,6 +2070,61 @@ async function updateOutboxRepositoriesReturnType(
1862
2070
  return true;
1863
2071
  }
1864
2072
 
2073
+ async function updateOutboxDrizzleSchema(
2074
+ targetDir: string,
2075
+ config: ResolvedBeignetConfig,
2076
+ options: { dryRun: boolean },
2077
+ ): Promise<{
2078
+ path: string;
2079
+ result?: WriteGeneratedFileResult;
2080
+ canExport: boolean;
2081
+ }> {
2082
+ const indexPath = path.join(targetDir, drizzleSchemaIndexPath(config));
2083
+ const schemaPath = outboxDrizzleSchemaFilePath(config);
2084
+ const destination = path.join(targetDir, schemaPath);
2085
+ if (!(await fileExists(indexPath))) {
2086
+ return { path: schemaPath, canExport: false };
2087
+ }
2088
+
2089
+ const database = await detectResourceDatabase(targetDir, config);
2090
+ const content = outboxDrizzleSchemaFile(drizzleDialects[database]);
2091
+ const existing = await readOptionalFile(destination);
2092
+
2093
+ if (existing === undefined) {
2094
+ if (!options.dryRun) {
2095
+ await mkdir(path.dirname(destination), { recursive: true });
2096
+ await writeFile(destination, content);
2097
+ }
2098
+ return { path: schemaPath, result: "created", canExport: true };
2099
+ }
2100
+
2101
+ if (
2102
+ existing === content ||
2103
+ /\bexport\s+const\s+outboxMessages\b/.test(existing)
2104
+ ) {
2105
+ return { path: schemaPath, result: "skipped", canExport: true };
2106
+ }
2107
+
2108
+ return { path: schemaPath, result: "skipped", canExport: false };
2109
+ }
2110
+
2111
+ async function updateOutboxDrizzleSchemaIndex(
2112
+ targetDir: string,
2113
+ config: ResolvedBeignetConfig,
2114
+ options: { dryRun: boolean },
2115
+ ): Promise<boolean> {
2116
+ const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
2117
+ if (!(await fileExists(filePath))) return false;
2118
+
2119
+ const original = await readFile(filePath, "utf8");
2120
+ const exportLine = 'export { outboxMessages } from "./outbox-messages";';
2121
+ if (original.includes(exportLine)) return false;
2122
+
2123
+ const next = `${original.trimEnd()}\n${exportLine}\n`;
2124
+ if (!options.dryRun) await writeFile(filePath, next);
2125
+ return true;
2126
+ }
2127
+
1865
2128
  function drizzleOutboxPortFactoryName(database: DatabaseName): string {
1866
2129
  if (database === "postgres") return "createDrizzlePostgresOutboxPort";
1867
2130
  if (database === "mysql") return "createDrizzleMysqlOutboxPort";
@@ -2267,11 +2530,12 @@ export async function makeOutbox(
2267
2530
  createdFiles.push(...cronSecretResult.createdFiles);
2268
2531
  updatedFiles.push(...cronSecretResult.updatedFiles);
2269
2532
  skippedFiles.push(...cronSecretResult.skippedFiles);
2270
- updatedFiles.push(
2271
- ...(await updateOutboxPortWiring(targetDir, config, {
2272
- dryRun: Boolean(options.dryRun),
2273
- })),
2274
- );
2533
+ const outboxWiringResult = await updateOutboxPortWiring(targetDir, config, {
2534
+ dryRun: Boolean(options.dryRun),
2535
+ });
2536
+ createdFiles.push(...outboxWiringResult.createdFiles);
2537
+ updatedFiles.push(...outboxWiringResult.updatedFiles);
2538
+ skippedFiles.push(...outboxWiringResult.skippedFiles);
2275
2539
 
2276
2540
  return {
2277
2541
  schemaVersion: 1,
@@ -2286,6 +2550,8 @@ export async function makeOutbox(
2286
2550
  config.paths.infrastructurePorts,
2287
2551
  path.join(infrastructureDir(config), "db/provider.ts"),
2288
2552
  drizzleRepositoriesPath(config),
2553
+ outboxDrizzleSchemaFilePath(config),
2554
+ drizzleSchemaIndexPath(config),
2289
2555
  ],
2290
2556
  createdFiles: uniqueStrings(createdFiles),
2291
2557
  updatedFiles: uniqueStrings(updatedFiles),
@@ -5456,9 +5722,14 @@ function featureUiComponentFiles(
5456
5722
  names: FeatureUiNames,
5457
5723
  config: ResolvedBeignetConfig,
5458
5724
  ): GeneratedFile[] {
5725
+ const clientQueriesPath = featureUiClientQueriesFilePath(names, config);
5459
5726
  const componentPath = featureUiComponentFilePath(names, config);
5460
5727
 
5461
5728
  return [
5729
+ {
5730
+ path: clientQueriesPath,
5731
+ content: featureUiClientQueriesFile(names, config),
5732
+ },
5462
5733
  {
5463
5734
  path: componentPath,
5464
5735
  content: featureUiComponentFile(names, config),
@@ -5721,6 +5992,15 @@ function drizzleResourceSchemaFilePath(
5721
5992
  );
5722
5993
  }
5723
5994
 
5995
+ function outboxDrizzleSchemaFilePath(config: ResolvedBeignetConfig): string {
5996
+ return path.join(
5997
+ infrastructureDir(config),
5998
+ "db",
5999
+ "schema",
6000
+ "outbox-messages.ts",
6001
+ );
6002
+ }
6003
+
5724
6004
  function drizzleResourceRepositoryFilePath(
5725
6005
  names: ResourceNames,
5726
6006
  config: ResolvedBeignetConfig,
@@ -5826,6 +6106,20 @@ function featureUiDir(
5826
6106
  return path.join(resourceFeatureDir(names, config), "components");
5827
6107
  }
5828
6108
 
6109
+ function featureUiClientDir(
6110
+ names: FeatureUiNames,
6111
+ config: ResolvedBeignetConfig,
6112
+ ): string {
6113
+ return path.join(resourceFeatureDir(names, config), "client");
6114
+ }
6115
+
6116
+ function featureUiClientQueriesFilePath(
6117
+ names: FeatureUiNames,
6118
+ config: ResolvedBeignetConfig,
6119
+ ): string {
6120
+ return path.join(featureUiClientDir(names, config), "queries.ts");
6121
+ }
6122
+
5829
6123
  function featureUiComponentFilePath(
5830
6124
  names: FeatureUiNames,
5831
6125
  config: ResolvedBeignetConfig,
@@ -7285,6 +7579,78 @@ ${options.softDelete ? `\tdeletedAt: ${dialect.timestampColumn("deleted_at")},\n
7285
7579
  `;
7286
7580
  }
7287
7581
 
7582
+ function outboxDrizzleSchemaFile(dialect: DrizzleDialect): string {
7583
+ if (dialect.subpath === "mysql") {
7584
+ return `import { index, int, longtext, mysqlTable, varchar } from "drizzle-orm/mysql-core";
7585
+
7586
+ export const outboxMessages = mysqlTable(
7587
+ \t"outbox_messages",
7588
+ \t{
7589
+ \t\tid: varchar("id", { length: 255 }).primaryKey(),
7590
+ \t\tkind: varchar("kind", { length: 32 }).notNull(),
7591
+ \t\tname: varchar("name", { length: 255 }).notNull(),
7592
+ \t\tpayloadJson: longtext("payload_json").notNull(),
7593
+ \t\tstatus: varchar("status", { length: 32 }).notNull(),
7594
+ \t\tattempts: int("attempts").notNull().default(0),
7595
+ \t\tmaxAttempts: int("max_attempts").notNull().default(3),
7596
+ \t\tavailableAt: varchar("available_at", { length: 32 }).notNull(),
7597
+ \t\tclaimedAt: varchar("claimed_at", { length: 32 }),
7598
+ \t\tlockedUntil: varchar("locked_until", { length: 32 }),
7599
+ \t\tclaimToken: varchar("claim_token", { length: 64 }),
7600
+ \t\tdeliveredAt: varchar("delivered_at", { length: 32 }),
7601
+ \t\tlastErrorJson: longtext("last_error_json"),
7602
+ \t\tcreatedAt: varchar("created_at", { length: 32 }).notNull(),
7603
+ \t\tupdatedAt: varchar("updated_at", { length: 32 }).notNull(),
7604
+ \t},
7605
+ \t(table) => ({
7606
+ \t\tavailableIdx: index("outbox_messages_available_idx").on(
7607
+ \t\t\ttable.status,
7608
+ \t\t\ttable.availableAt,
7609
+ \t\t),
7610
+ \t\tlockedIdx: index("outbox_messages_locked_idx").on(
7611
+ \t\t\ttable.status,
7612
+ \t\t\ttable.lockedUntil,
7613
+ \t\t),
7614
+ \t}),
7615
+ );
7616
+ `;
7617
+ }
7618
+
7619
+ return `import { index, integer, ${dialect.tableFunction}, text } from "${dialect.columnModule}";
7620
+
7621
+ export const outboxMessages = ${dialect.tableFunction}(
7622
+ \t"outbox_messages",
7623
+ \t{
7624
+ \t\tid: text("id").primaryKey(),
7625
+ \t\tkind: text("kind").notNull(),
7626
+ \t\tname: text("name").notNull(),
7627
+ \t\tpayloadJson: text("payload_json").notNull(),
7628
+ \t\tstatus: text("status").notNull(),
7629
+ \t\tattempts: integer("attempts").notNull().default(0),
7630
+ \t\tmaxAttempts: integer("max_attempts").notNull().default(3),
7631
+ \t\tavailableAt: text("available_at").notNull(),
7632
+ \t\tclaimedAt: text("claimed_at"),
7633
+ \t\tlockedUntil: text("locked_until"),
7634
+ \t\tclaimToken: text("claim_token"),
7635
+ \t\tdeliveredAt: text("delivered_at"),
7636
+ \t\tlastErrorJson: text("last_error_json"),
7637
+ \t\tcreatedAt: text("created_at").notNull(),
7638
+ \t\tupdatedAt: text("updated_at").notNull(),
7639
+ \t},
7640
+ \t(table) => ({
7641
+ \t\tavailableIdx: index("outbox_messages_available_idx").on(
7642
+ \t\t\ttable.status,
7643
+ \t\t\ttable.availableAt,
7644
+ \t\t),
7645
+ \t\tlockedIdx: index("outbox_messages_locked_idx").on(
7646
+ \t\t\ttable.status,
7647
+ \t\t\ttable.lockedUntil,
7648
+ \t\t),
7649
+ \t}),
7650
+ );
7651
+ `;
7652
+ }
7653
+
7288
7654
  function drizzleResourceWhere(
7289
7655
  names: ResourceNames,
7290
7656
  options: ResourceGenerationOptions,
@@ -9569,30 +9935,31 @@ function featureUiComponentFile(
9569
9935
  names: FeatureUiNames,
9570
9936
  config: ResolvedBeignetConfig,
9571
9937
  ): string {
9938
+ const componentPath = featureUiComponentFilePath(names, config);
9939
+ const clientQueriesPath = featureUiClientQueriesFilePath(names, config);
9940
+
9572
9941
  return `"use client";
9573
9942
 
9574
9943
  import { contractErrorMessage } from "@beignet/core/client";
9575
9944
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9576
9945
  import { useState } from "react";
9577
- import { rq } from "${aliasModule(clientIndexPath(config))}";
9578
- import { create${names.singularPascal}, list${names.pluralPascal} } from "${aliasModule(
9579
- resourceContractFilePath(names, config),
9580
- )}";
9946
+ import {
9947
+ \tcreate${names.singularPascal}MutationOptions,
9948
+ \tinvalidate${names.pluralPascal},
9949
+ \tlist${names.pluralPascal}QueryOptions,
9950
+ } from "${relativeModule(componentPath, clientQueriesPath)}";
9581
9951
 
9582
9952
  export function ${names.componentExportName}() {
9583
9953
  \tconst [name, setName] = useState("");
9584
9954
  \tconst queryClient = useQueryClient();
9585
- \tconst ${names.pluralCamel}Query = useQuery(
9586
- \t\trq(list${names.pluralPascal}).queryOptions({ query: {} }),
9587
- \t);
9588
- \tconst create${names.singularPascal}Mutation = useMutation(
9589
- \t\trq(create${names.singularPascal}).mutationOptions({
9590
- \t\t\tonSuccess: async () => {
9591
- \t\t\t\tsetName("");
9592
- \t\t\t\tawait rq(list${names.pluralPascal}).invalidate(queryClient);
9593
- \t\t\t},
9594
- \t\t}),
9595
- \t);
9955
+ \tconst ${names.pluralCamel}Query = useQuery(list${names.pluralPascal}QueryOptions());
9956
+ \tconst create${names.singularPascal}Mutation = useMutation({
9957
+ \t\t...create${names.singularPascal}MutationOptions(),
9958
+ \t\tonSuccess: async () => {
9959
+ \t\t\tsetName("");
9960
+ \t\t\tawait invalidate${names.pluralPascal}(queryClient);
9961
+ \t\t},
9962
+ \t});
9596
9963
 
9597
9964
  \treturn (
9598
9965
  \t\t<section className="${names.pluralKebab}-panel">
@@ -9660,6 +10027,34 @@ export function ${names.componentExportName}() {
9660
10027
  `;
9661
10028
  }
9662
10029
 
10030
+ function featureUiClientQueriesFile(
10031
+ names: FeatureUiNames,
10032
+ config: ResolvedBeignetConfig,
10033
+ ): string {
10034
+ const clientQueriesPath = featureUiClientQueriesFilePath(names, config);
10035
+ const contractsPath = resourceContractFilePath(names, config);
10036
+
10037
+ return `import type { QueryClient } from "@tanstack/react-query";
10038
+ import { rq } from "${aliasModule(clientIndexPath(config))}";
10039
+ import {
10040
+ \tcreate${names.singularPascal},
10041
+ \tlist${names.pluralPascal},
10042
+ } from "${relativeModule(clientQueriesPath, contractsPath)}";
10043
+
10044
+ export function list${names.pluralPascal}QueryOptions() {
10045
+ \treturn rq(list${names.pluralPascal}).queryOptions({ query: {} });
10046
+ }
10047
+
10048
+ export function create${names.singularPascal}MutationOptions() {
10049
+ \treturn rq(create${names.singularPascal}).mutationOptions();
10050
+ }
10051
+
10052
+ export function invalidate${names.pluralPascal}(queryClient: QueryClient) {
10053
+ \treturn rq(list${names.pluralPascal}).invalidate(queryClient);
10054
+ }
10055
+ `;
10056
+ }
10057
+
9663
10058
  function scheduleRouteFile(
9664
10059
  names: ScheduleNames,
9665
10060
  config: ResolvedBeignetConfig,
package/src/mcp.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  makeFactory,
13
13
  makeFeature,
14
14
  makeFeatureAddonChoices,
15
+ makeFeatureRecipeChoices,
15
16
  makeJob,
16
17
  makeListener,
17
18
  makeNotification,
@@ -76,6 +77,10 @@ const makeInputSchema = {
76
77
  .describe(
77
78
  "feature only: add feature-owned artifacts such as policy, factories, seeds, events, listeners, jobs, notifications, schedules, tasks, ui, and uploads.",
78
79
  ),
80
+ recipe: z
81
+ .enum(makeFeatureRecipeChoices)
82
+ .optional()
83
+ .describe("feature only: generate a named recipe such as full-slice."),
79
84
  event: z
80
85
  .string()
81
86
  .optional()
@@ -132,6 +137,7 @@ type MakeToolInput = {
132
137
  artifact: MakeArtifact;
133
138
  name?: string;
134
139
  with?: Array<(typeof makeFeatureAddonChoices)[number]>;
140
+ recipe?: (typeof makeFeatureRecipeChoices)[number];
135
141
  event?: string;
136
142
  cron?: string;
137
143
  timezone?: string;
@@ -216,7 +222,7 @@ async function runMakeTool(
216
222
  case "factory":
217
223
  return makeFactory(base);
218
224
  case "feature":
219
- return makeFeature({ ...base, with: input.with });
225
+ return makeFeature({ ...base, with: input.with, recipe: input.recipe });
220
226
  case "job":
221
227
  return makeJob(base);
222
228
  case "listener":