@beignet/cli 0.0.47 → 0.0.48

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 (92) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +50 -21
  3. package/dist/check.d.ts +8 -1
  4. package/dist/check.d.ts.map +1 -1
  5. package/dist/check.js +18 -6
  6. package/dist/check.js.map +1 -1
  7. package/dist/db.d.ts +38 -0
  8. package/dist/db.d.ts.map +1 -1
  9. package/dist/db.js +52 -0
  10. package/dist/db.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +73 -6
  13. package/dist/index.js.map +1 -1
  14. package/dist/inspect.d.ts.map +1 -1
  15. package/dist/inspect.js +39 -2
  16. package/dist/inspect.js.map +1 -1
  17. package/dist/lib.d.ts +2 -2
  18. package/dist/lib.d.ts.map +1 -1
  19. package/dist/lib.js +1 -1
  20. package/dist/lib.js.map +1 -1
  21. package/dist/make/inbox.js +1 -1
  22. package/dist/make/inbox.js.map +1 -1
  23. package/dist/make/payments.js +1 -1
  24. package/dist/make/payments.js.map +1 -1
  25. package/dist/make/tenancy.js +1 -1
  26. package/dist/make/tenancy.js.map +1 -1
  27. package/dist/mcp.d.ts.map +1 -1
  28. package/dist/mcp.js +39 -2
  29. package/dist/mcp.js.map +1 -1
  30. package/dist/preflight.d.ts +11 -3
  31. package/dist/preflight.d.ts.map +1 -1
  32. package/dist/preflight.js +139 -16
  33. package/dist/preflight.js.map +1 -1
  34. package/dist/provider-add.d.ts.map +1 -1
  35. package/dist/provider-add.js +7 -2
  36. package/dist/provider-add.js.map +1 -1
  37. package/dist/provider-audit.d.ts +13 -0
  38. package/dist/provider-audit.d.ts.map +1 -1
  39. package/dist/provider-audit.js +122 -8
  40. package/dist/provider-audit.js.map +1 -1
  41. package/dist/templates/agents.d.ts.map +1 -1
  42. package/dist/templates/agents.js +13 -7
  43. package/dist/templates/agents.js.map +1 -1
  44. package/dist/templates/base.d.ts +1 -0
  45. package/dist/templates/base.d.ts.map +1 -1
  46. package/dist/templates/base.js +11 -5
  47. package/dist/templates/base.js.map +1 -1
  48. package/dist/templates/db/index.d.ts +1 -0
  49. package/dist/templates/db/index.d.ts.map +1 -1
  50. package/dist/templates/db/index.js.map +1 -1
  51. package/dist/templates/db/migration-status.d.ts +9 -0
  52. package/dist/templates/db/migration-status.d.ts.map +1 -0
  53. package/dist/templates/db/migration-status.js +117 -0
  54. package/dist/templates/db/migration-status.js.map +1 -0
  55. package/dist/templates/db/mysql.d.ts.map +1 -1
  56. package/dist/templates/db/mysql.js +23 -0
  57. package/dist/templates/db/mysql.js.map +1 -1
  58. package/dist/templates/db/postgres.d.ts.map +1 -1
  59. package/dist/templates/db/postgres.js +21 -0
  60. package/dist/templates/db/postgres.js.map +1 -1
  61. package/dist/templates/db/sqlite.d.ts.map +1 -1
  62. package/dist/templates/db/sqlite.js +53 -0
  63. package/dist/templates/db/sqlite.js.map +1 -1
  64. package/dist/templates/index.d.ts.map +1 -1
  65. package/dist/templates/index.js +5 -0
  66. package/dist/templates/index.js.map +1 -1
  67. package/dist/templates/shared.d.ts.map +1 -1
  68. package/dist/templates/shared.js +1 -0
  69. package/dist/templates/shared.js.map +1 -1
  70. package/package.json +2 -2
  71. package/skills/app-structure/SKILL.md +11 -7
  72. package/src/check.ts +35 -7
  73. package/src/db.ts +97 -0
  74. package/src/index.ts +108 -7
  75. package/src/inspect.ts +76 -1
  76. package/src/lib.ts +9 -1
  77. package/src/make/inbox.ts +1 -1
  78. package/src/make/payments.ts +1 -1
  79. package/src/make/tenancy.ts +1 -1
  80. package/src/mcp.ts +56 -1
  81. package/src/preflight.ts +195 -16
  82. package/src/provider-add.ts +7 -2
  83. package/src/provider-audit.ts +210 -18
  84. package/src/templates/agents.ts +13 -7
  85. package/src/templates/base.ts +12 -3
  86. package/src/templates/db/index.ts +1 -0
  87. package/src/templates/db/migration-status.ts +126 -0
  88. package/src/templates/db/mysql.ts +23 -0
  89. package/src/templates/db/postgres.ts +21 -0
  90. package/src/templates/db/sqlite.ts +53 -0
  91. package/src/templates/index.ts +5 -0
  92. package/src/templates/shared.ts +1 -0
@@ -18,6 +18,7 @@ export type ProviderPackageJson = {
18
18
  dependencies?: Record<string, string>;
19
19
  devDependencies?: Record<string, string>;
20
20
  peerDependencies?: Record<string, string>;
21
+ scripts?: Record<string, string>;
21
22
  };
22
23
 
23
24
  export type ProviderDoctorVariantRule = {
@@ -25,6 +26,7 @@ export type ProviderDoctorVariantRule = {
25
26
  displayName: string;
26
27
  tokens: readonly string[];
27
28
  requiredEnv: readonly string[];
29
+ requiredEnvAlternatives: readonly (readonly string[])[];
28
30
  requiredTables: readonly string[];
29
31
  registrationSeverity?: "warning" | "hint";
30
32
  };
@@ -35,6 +37,7 @@ export type ProviderDoctorRule = {
35
37
  tokens: readonly string[];
36
38
  appPorts?: readonly { name: string; type: string }[];
37
39
  requiredEnv?: readonly string[];
40
+ requiredEnvAlternatives?: readonly (readonly string[])[];
38
41
  requiredTables?: readonly string[];
39
42
  watchers?: readonly string[];
40
43
  registrationSeverity?: "warning" | "hint";
@@ -94,6 +97,14 @@ export type ProviderRequirementAudit = {
94
97
  status: ProviderAuditStatus;
95
98
  required: string[];
96
99
  missing: string[];
100
+ alternatives?: string[][];
101
+ alternativeGroups?: Array<{
102
+ name: string;
103
+ displayName: string;
104
+ status: "present" | "missing";
105
+ alternatives: string[][];
106
+ missing: string[];
107
+ }>;
97
108
  };
98
109
 
99
110
  export type ProviderAppPortsAudit = {
@@ -282,6 +293,36 @@ export async function providerDoctorRulesForInstalledPackages(
282
293
  return rules.filter((rule): rule is ProviderDoctorRule => Boolean(rule));
283
294
  }
284
295
 
296
+ export async function detectedProviderVariantsForRules(
297
+ targetDir: string,
298
+ rules: readonly ProviderDoctorRule[],
299
+ ): Promise<Map<string, ProviderDoctorVariantRule[]>> {
300
+ const variantRules = rules.filter(
301
+ (
302
+ rule,
303
+ ): rule is ProviderDoctorRule & {
304
+ variants: readonly ProviderDoctorVariantRule[];
305
+ } => rule.variants !== undefined,
306
+ );
307
+ if (variantRules.length === 0) return new Map();
308
+
309
+ const files = await listFiles(targetDir);
310
+ const config = await loadBeignetConfig(targetDir, files);
311
+ const providerEntries = await readProviderListEntries(
312
+ targetDir,
313
+ files,
314
+ config,
315
+ new Map(),
316
+ );
317
+
318
+ return new Map(
319
+ variantRules.map((rule) => [
320
+ rule.packageName,
321
+ detectedProviderVariants(rule.variants, providerEntries),
322
+ ]),
323
+ );
324
+ }
325
+
285
326
  export async function readProviderPackageMetadataSource(
286
327
  targetDir: string,
287
328
  packageName: string,
@@ -314,6 +355,7 @@ export function providerDoctorRuleFromMetadata(
314
355
  tokens: registration?.tokens ?? [],
315
356
  appPorts: metadata.appPorts ?? [],
316
357
  requiredEnv: metadata.requiredEnv ?? [],
358
+ requiredEnvAlternatives: metadata.requiredEnvAlternatives ?? [],
317
359
  requiredTables: metadata.requiredTables ?? [],
318
360
  watchers: metadata.watchers ?? [],
319
361
  registrationSeverity: registrationSeverityFromMetadata(registration),
@@ -322,6 +364,7 @@ export function providerDoctorRuleFromMetadata(
322
364
  displayName: variant.displayName ?? variant.name,
323
365
  tokens: variant.registration?.tokens ?? [],
324
366
  requiredEnv: variant.requiredEnv ?? [],
367
+ requiredEnvAlternatives: variant.requiredEnvAlternatives ?? [],
325
368
  requiredTables: variant.requiredTables ?? [],
326
369
  registrationSeverity: registrationSeverityFromMetadata(
327
370
  variant.registration,
@@ -1061,6 +1104,15 @@ function configuredProviderRequiredEnv(
1061
1104
  return envVars.filter((envVar) => !ignored.has(envVar));
1062
1105
  }
1063
1106
 
1107
+ function configuredProviderRequiredEnvAlternatives(
1108
+ alternatives: readonly (readonly string[])[],
1109
+ config: ResolvedBeignetConfig,
1110
+ ): string[][] {
1111
+ return alternatives.map((alternative) =>
1112
+ configuredProviderRequiredEnv(alternative, config),
1113
+ );
1114
+ }
1115
+
1064
1116
  function configuredProviderRequiredTables(
1065
1117
  tableNames: readonly string[],
1066
1118
  config: ResolvedBeignetConfig,
@@ -1187,19 +1239,23 @@ async function providerAuditEntryForRule(options: {
1187
1239
  const requiredEnv =
1188
1240
  options.rule.variants !== undefined
1189
1241
  ? aggregateVariantRequirement(variants, "requiredEnv")
1190
- : await requirementAudit(
1191
- configuredProviderRequiredEnv(
1242
+ : await providerEnvRequirementAudit({
1243
+ required: configuredProviderRequiredEnv(
1192
1244
  options.rule.requiredEnv ?? [],
1193
1245
  options.config,
1194
1246
  ),
1195
- (envVar) =>
1247
+ alternatives: configuredProviderRequiredEnvAlternatives(
1248
+ options.rule.requiredEnvAlternatives ?? [],
1249
+ options.config,
1250
+ ),
1251
+ exists: (envVar) =>
1196
1252
  providerRequiredEnvExists(
1197
1253
  options.targetDir,
1198
1254
  options.configFiles,
1199
1255
  envVar,
1200
1256
  options.sourceCache,
1201
1257
  ),
1202
- );
1258
+ });
1203
1259
 
1204
1260
  const requiredTables =
1205
1261
  options.rule.variants !== undefined
@@ -1288,12 +1344,19 @@ async function providerVariantAudit(options: {
1288
1344
  options.variant.requiredEnv,
1289
1345
  options.config,
1290
1346
  );
1347
+ const requiredEnvAlternatives = configuredProviderRequiredEnvAlternatives(
1348
+ options.variant.requiredEnvAlternatives,
1349
+ options.config,
1350
+ );
1291
1351
  if (!options.registered) {
1292
1352
  return {
1293
1353
  name: options.variant.name,
1294
1354
  displayName: options.variant.displayName,
1295
1355
  registration: "inactive",
1296
- requiredEnv: inactiveRequirement(requiredEnv),
1356
+ requiredEnv:
1357
+ requiredEnvAlternatives.length > 0
1358
+ ? inactiveAlternativeRequirement(requiredEnvAlternatives)
1359
+ : inactiveRequirement(requiredEnv),
1297
1360
  requiredTables: inactiveRequirement(options.variant.requiredTables),
1298
1361
  };
1299
1362
  }
@@ -1306,14 +1369,17 @@ async function providerVariantAudit(options: {
1306
1369
  options.variant.registrationSeverity,
1307
1370
  options.providerEntries,
1308
1371
  ),
1309
- requiredEnv: await requirementAudit(requiredEnv, (envVar) =>
1310
- providerRequiredEnvExists(
1311
- options.targetDir,
1312
- options.configFiles,
1313
- envVar,
1314
- options.sourceCache,
1315
- ),
1316
- ),
1372
+ requiredEnv: await providerEnvRequirementAudit({
1373
+ required: requiredEnv,
1374
+ alternatives: requiredEnvAlternatives,
1375
+ exists: (envVar) =>
1376
+ providerRequiredEnvExists(
1377
+ options.targetDir,
1378
+ options.configFiles,
1379
+ envVar,
1380
+ options.sourceCache,
1381
+ ),
1382
+ }),
1317
1383
  requiredTables: await requirementAudit(
1318
1384
  configuredProviderRequiredTables(
1319
1385
  options.variant.requiredTables,
@@ -1448,6 +1514,34 @@ async function requirementAudit(
1448
1514
  };
1449
1515
  }
1450
1516
 
1517
+ async function providerEnvRequirementAudit(options: {
1518
+ required: readonly string[];
1519
+ alternatives: readonly (readonly string[])[];
1520
+ exists: (requirement: string) => Promise<boolean>;
1521
+ }): Promise<ProviderRequirementAudit> {
1522
+ if (options.alternatives.length === 0) {
1523
+ return requirementAudit(options.required, options.exists);
1524
+ }
1525
+
1526
+ const missingAlternatives: string[][] = [];
1527
+ for (const alternative of options.alternatives) {
1528
+ const missing: string[] = [];
1529
+ for (const requirement of alternative) {
1530
+ if (await options.exists(requirement)) continue;
1531
+ missing.push(requirement);
1532
+ }
1533
+ missingAlternatives.push(missing);
1534
+ }
1535
+
1536
+ const present = missingAlternatives.some((missing) => missing.length === 0);
1537
+ return {
1538
+ status: present ? "present" : "missing",
1539
+ required: unique(options.alternatives.flat()),
1540
+ missing: present ? [] : unique(missingAlternatives.flat()),
1541
+ alternatives: options.alternatives.map((alternative) => [...alternative]),
1542
+ };
1543
+ }
1544
+
1451
1545
  function inactiveRequirement(
1452
1546
  required: readonly string[],
1453
1547
  ): ProviderRequirementAudit {
@@ -1458,6 +1552,17 @@ function inactiveRequirement(
1458
1552
  };
1459
1553
  }
1460
1554
 
1555
+ function inactiveAlternativeRequirement(
1556
+ alternatives: readonly (readonly string[])[],
1557
+ ): ProviderRequirementAudit {
1558
+ return {
1559
+ status: "inactive",
1560
+ required: unique(alternatives.flat()),
1561
+ missing: [],
1562
+ alternatives: alternatives.map((alternative) => [...alternative]),
1563
+ };
1564
+ }
1565
+
1461
1566
  function emptyRequirement(): ProviderRequirementAudit {
1462
1567
  return { status: "none", required: [], missing: [] };
1463
1568
  }
@@ -1467,21 +1572,57 @@ function aggregateVariantRequirement(
1467
1572
  key: "requiredEnv" | "requiredTables",
1468
1573
  ): ProviderRequirementAudit {
1469
1574
  const active = variants
1470
- .map((variant) => variant[key])
1471
- .filter((requirement) => requirement.status !== "inactive");
1575
+ .map((variant) => ({ variant, requirement: variant[key] }))
1576
+ .filter(({ requirement }) => requirement.status !== "inactive");
1472
1577
  if (active.length === 0)
1473
1578
  return { status: "inactive", required: [], missing: [] };
1474
1579
 
1475
1580
  const required = unique(
1476
- active.flatMap((requirement) => requirement.required),
1581
+ active.flatMap(({ requirement }) => requirement.required),
1582
+ );
1583
+ const missing = unique(
1584
+ active.flatMap(({ requirement }) => requirement.missing),
1477
1585
  );
1478
- const missing = unique(active.flatMap((requirement) => requirement.missing));
1479
1586
  if (required.length === 0) return emptyRequirement();
1480
1587
 
1588
+ const hasAlternativeRequirements = active.some(
1589
+ ({ requirement }) =>
1590
+ requirement.alternatives !== undefined ||
1591
+ requirement.alternativeGroups !== undefined,
1592
+ );
1593
+ const alternativeGroups = hasAlternativeRequirements
1594
+ ? active.flatMap(({ variant, requirement }) => {
1595
+ if (requirement.status === "none") return [];
1596
+ if (
1597
+ requirement.status !== "present" &&
1598
+ requirement.status !== "missing"
1599
+ ) {
1600
+ return [];
1601
+ }
1602
+
1603
+ return [
1604
+ {
1605
+ name: variant.name,
1606
+ displayName: variant.displayName,
1607
+ status: requirement.status,
1608
+ alternatives: (
1609
+ requirement.alternatives ?? [requirement.required]
1610
+ ).map((alternative) => [...alternative]),
1611
+ missing: [...requirement.missing],
1612
+ },
1613
+ ];
1614
+ })
1615
+ : [];
1616
+
1481
1617
  return {
1482
1618
  status: missing.length === 0 ? "present" : "missing",
1483
1619
  required,
1484
1620
  missing,
1621
+ ...(alternativeGroups.length === 1
1622
+ ? { alternatives: alternativeGroups[0]?.alternatives ?? [] }
1623
+ : alternativeGroups.length > 0
1624
+ ? { alternativeGroups }
1625
+ : {}),
1485
1626
  };
1486
1627
  }
1487
1628
 
@@ -1520,7 +1661,17 @@ function providerAuditNotes(input: {
1520
1661
  notes.push("optional provider not registered");
1521
1662
  }
1522
1663
  if (input.requiredEnv.missing.length > 0) {
1523
- notes.push(`missing env: ${input.requiredEnv.missing.join(", ")}`);
1664
+ notes.push(
1665
+ input.requiredEnv.alternativeGroups
1666
+ ? `missing env configuration groups: ${formatProviderEnvAlternativeGroups(
1667
+ input.requiredEnv.alternativeGroups,
1668
+ )}`
1669
+ : input.requiredEnv.alternatives
1670
+ ? `missing env alternative: ${formatProviderEnvAlternatives(
1671
+ input.requiredEnv.alternatives,
1672
+ )}`
1673
+ : `missing env: ${input.requiredEnv.missing.join(", ")}`,
1674
+ );
1524
1675
  }
1525
1676
  if (input.requiredTables.missing.length > 0) {
1526
1677
  notes.push(`missing tables: ${input.requiredTables.missing.join(", ")}`);
@@ -1863,6 +2014,21 @@ function escapeRegExp(value: string): string {
1863
2014
  function requirementSummary(requirement: ProviderRequirementAudit): string {
1864
2015
  if (requirement.status === "none") return "none";
1865
2016
  if (requirement.status === "inactive") return "inactive";
2017
+ if (requirement.alternativeGroups) {
2018
+ const count = requirement.alternativeGroups.length;
2019
+ const missing = requirement.alternativeGroups.filter(
2020
+ (group) => group.status === "missing",
2021
+ ).length;
2022
+ return requirement.status === "present"
2023
+ ? `${count} variant groups ok`
2024
+ : `${missing}/${count} variant groups incomplete`;
2025
+ }
2026
+ if (requirement.alternatives) {
2027
+ if (requirement.status === "present") {
2028
+ return "alternative ok";
2029
+ }
2030
+ return `${requirement.alternatives.length} alternatives missing`;
2031
+ }
1866
2032
  if (requirement.status === "present") {
1867
2033
  return `${requirement.required.length} ok`;
1868
2034
  }
@@ -1879,6 +2045,32 @@ function unique(values: readonly string[]): string[] {
1879
2045
  return [...new Set(values)];
1880
2046
  }
1881
2047
 
2048
+ export function formatProviderEnvAlternatives(
2049
+ alternatives: readonly (readonly string[])[],
2050
+ ): string {
2051
+ return alternatives
2052
+ .map((alternative) =>
2053
+ alternative.length === 1
2054
+ ? alternative[0]
2055
+ : `(${alternative.join(" + ")})`,
2056
+ )
2057
+ .join(" or ");
2058
+ }
2059
+
2060
+ function formatProviderEnvAlternativeGroups(
2061
+ groups: NonNullable<ProviderRequirementAudit["alternativeGroups"]>,
2062
+ ): string {
2063
+ return groups
2064
+ .filter((group) => group.status === "missing")
2065
+ .map(
2066
+ (group) =>
2067
+ `${group.displayName}: ${formatProviderEnvAlternatives(
2068
+ group.alternatives,
2069
+ )}`,
2070
+ )
2071
+ .join(" and ");
2072
+ }
2073
+
1882
2074
  function table(rows: string[][]): string {
1883
2075
  const widths = rows[0].map((_, columnIndex) =>
1884
2076
  Math.max(...rows.map((row) => row[columnIndex].length)),
@@ -79,7 +79,8 @@ array anchors are unambiguous. It also syncs missing default Beignet
79
79
  provider-table exports when \`infra/db/schema/beignet.ts\` is missing or still
80
80
  matches \`${cli} db schema sync\` output and the schema index has no custom
81
81
  named re-export for that file. Run \`${cli} db generate\` and
82
- \`${cli} db migrate\` after accepting that source repair. Custom or ambiguous
82
+ \`${cli} db migrate\`, then \`${cli} db status\` after accepting that source
83
+ repair. Custom or ambiguous
83
84
  code stays diagnostic-only.
84
85
 
85
86
  ## Prefer generators
@@ -89,10 +90,12 @@ files — prefer it over hand-writing them. See \`${cli} make --help\` for the
89
90
  artifact list. Use \`${cli} make feature <name> --recipe full-slice\` when you
90
91
  need a richer reference slice with policy, client helpers, workflow artifacts,
91
92
  events, listener registration, jobs, and outbox wiring. After changing the Drizzle schema in
92
- \`infra/db/schema/\`, run \`${cli} db generate\` then \`${cli} db migrate\`.
93
+ \`infra/db/schema/\`, run \`${cli} db generate\`, \`${cli} db migrate\`, then
94
+ \`${cli} db status\`.
93
95
  When MCP is available, use \`db_schema_sync\` for Beignet provider table
94
96
  re-exports and \`db\` for \`generate\`, \`migrate\`, \`seed\`, or \`reset\`
95
- instead of falling back to a shell. Use \`task_run\`, \`schedule_run\`,
97
+ instead of falling back to a shell. Use the read-only \`db_status\` tool before
98
+ deploying. Use \`task_run\`, \`schedule_run\`,
96
99
  \`outbox_inspect\`, and \`outbox_run\` for registered operational workflows
97
100
  and outbox recovery.
98
101
 
@@ -175,7 +178,7 @@ skill-loading block.
175
178
  \`.mcp.json\` registers the app-local \`@beignet/cli\` bin at
176
179
  \`./node_modules/.bin/beignet mcp\`, which exposes app context, validation,
177
180
  generation, and operations as structured tools named exactly: \`app_map\`,
178
- \`explain\`, \`check\`, \`db\`, \`db_schema_sync\`, \`task_run\`,
181
+ \`explain\`, \`check\`, \`db\`, \`db_status\`, \`db_schema_sync\`, \`task_run\`,
179
182
  \`schedule_run\`, \`outbox_inspect\`, \`outbox_run\`, \`routes\`, \`doctor\`,
180
183
  \`doctor_fix_plan\`, \`doctor_fix\`, \`lint\`, \`make\`, \`provider_add\`.
181
184
  It also publishes \`beignet://app/guidance\` and focused
@@ -190,11 +193,14 @@ relationships, conventions, findings, and suggested files for any mapped
190
193
  concept or diagnostic; its kinds match the app map. Use \`check\` after edits
191
194
  to run the complete validation loop; it returns the same versioned result as
192
195
  \`${cli} check --json\`, bounds failure output, and cancels the active package
193
- script when the MCP request is cancelled. It does not apply Beignet fixes, but
194
- app-owned scripts retain their normal side effects. Use \`db_schema_sync\` to
196
+ script when the MCP request is cancelled. Pass \`preflightConnect: true\` to
197
+ include migration status and dependency health checks; \`connectTimeoutMs\`
198
+ sets their per-check timeout. It does not apply Beignet fixes, but app-owned
199
+ scripts retain their normal side effects. Use \`db_schema_sync\` to
195
200
  preview or apply idempotent provider-table schema re-exports, then call \`db\`
196
201
  with \`generate\` and \`migrate\`; database output is bounded, commands time
197
- out, and cancellation stops the active process tree. Treat \`seed\` and
202
+ out, and cancellation stops the active process tree. Use \`db_status\` for a
203
+ read-only current, pending, or failed migration report. Treat \`seed\` and
198
204
  especially \`reset\` as app-owned mutations. Lifecycle \`dryRun\` validates
199
205
  and reports the script without executing it; it does not simulate SQL or data
200
206
  changes. Use \`task_run\` and \`schedule_run\` for registered operational
@@ -103,6 +103,7 @@ export function packageJson(ctx: TemplateContext): string {
103
103
  "db:generate": "drizzle-kit generate",
104
104
  "db:migrate": "drizzle-kit migrate",
105
105
  "db:reset": currentGeneratedPackageScripts["db:reset"],
106
+ "db:status": currentGeneratedPackageScripts["db:status"],
106
107
  };
107
108
 
108
109
  return json({
@@ -254,9 +255,10 @@ ${startDatabase}## Prepare the database
254
255
 
255
256
  \`\`\`bash
256
257
  ${cli} db migrate
258
+ ${cli} db status
257
259
  \`\`\`
258
260
 
259
- The starter ships its initial Drizzle migration in \`drizzle/\`, so the first run only applies it. After you change \`infra/db/schema/\`, run \`${cli} db generate\` and \`${cli} db migrate\` together.
261
+ The starter ships its initial Drizzle migration in \`drizzle/\`, so the first run only applies it. After you change \`infra/db/schema/\`, run \`${cli} db generate\` and \`${cli} db migrate\` together, then verify the target database with \`${cli} db status\`.
260
262
 
261
263
  ## Start the app
262
264
 
@@ -303,10 +305,11 @@ ${start}
303
305
  ${cli} make feature projects
304
306
  ${cli} db generate
305
307
  ${cli} db migrate
308
+ ${cli} db status
306
309
  ${cli} check
307
310
  \`\`\`
308
311
 
309
- \`make feature\` creates a contract-to-test vertical slice with Drizzle schema and repository files, so regenerate and migrate the database before running the app against the new feature.
312
+ \`make feature\` creates a contract-to-test vertical slice with Drizzle schema and repository files, so regenerate, migrate, and verify the database before running the app against the new feature.
310
313
  Use \`${cli} make feature projects --recipe full-slice\` when you want a richer reference slice with policy, feature client helpers, workflow artifacts, events, listener registration, jobs, and outbox wiring.
311
314
 
312
315
  ## App map
@@ -352,7 +355,8 @@ ${optionalAuthRedirects}
352
355
  " db migrate` against it before starting the app."
353
356
  }
354
357
  - Size the process-wide database client for the deployment. Postgres and MySQL use the generated pool-limit environment variable; count web, worker, preview, and task processes against the database connection limit, and keep writable SQLite files on one host.
355
- - Run \`${cli} db generate\` and \`${cli} db migrate\` after changing the Drizzle schema.
358
+ - Run \`${cli} db generate\`, \`${cli} db migrate\`, and \`${cli} db status\` after changing the Drizzle schema.
359
+ - Run \`${cli} db migrate\` in one serialized migration job per environment, then \`${cli} db status\` and \`${cli} preflight --connect\` before shifting traffic. Do not migrate independently from every web replica.
356
360
  - Run \`${cli} db reset\` to rebuild a local ${
357
361
  ctx.database === "sqlite"
358
362
  ? "SQLite"
@@ -429,6 +433,7 @@ export function providersDoc(ctx: TemplateContext): string {
429
433
  "- Peer dependencies: `@upstash/ratelimit` and `@upstash/redis`",
430
434
  "- The starter wires `createUpstashRateLimitProvider()` in `server/providers.ts` and adds `rateLimit: RateLimitPort` to `AppPorts`.",
431
435
  "- `.env.example` ships dev placeholders so the app boots; set real `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` values before rate limiting requests.",
436
+ "- Rate-limit decisions time out after 5000ms and fail closed by default. Opt into `UPSTASH_TIMEOUT_POLICY=fail-open` only when availability outweighs enforcement across this provider during an outage.",
432
437
  "",
433
438
  );
434
439
  }
@@ -510,6 +515,8 @@ export function envExample(ctx: TemplateContext): string {
510
515
  "UPSTASH_REDIS_REST_URL=https://dev-placeholder.upstash.io",
511
516
  "UPSTASH_REDIS_REST_TOKEN=dev-placeholder-change-me",
512
517
  "UPSTASH_PREFIX=beignet:ratelimit",
518
+ "# UPSTASH_TIMEOUT_MS=5000",
519
+ "# UPSTASH_TIMEOUT_POLICY=fail-closed",
513
520
  "",
514
521
  );
515
522
  }
@@ -518,6 +525,8 @@ export function envExample(ctx: TemplateContext): string {
518
525
  }
519
526
 
520
527
  const files = {
528
+ gitattributes: `**/drizzle/*.sql text eol=lf
529
+ `,
521
530
  gitignore: `.next
522
531
  node_modules
523
532
  dist
@@ -15,6 +15,7 @@ export type DbTemplateFiles = {
15
15
  drizzleTodoRepository: string;
16
16
  dbRepositories: string;
17
17
  databaseReady: string;
18
+ dbMigrationStatus: string;
18
19
  dbProvider: string;
19
20
  dbReset: string;
20
21
  dbTestDatabase: string;
@@ -0,0 +1,126 @@
1
+ type MigrationStatusTemplateOptions = {
2
+ imports: string;
3
+ open: string;
4
+ loadApplied: string;
5
+ close: string;
6
+ };
7
+
8
+ export function migrationStatusTemplate(
9
+ options: MigrationStatusTemplateOptions,
10
+ ): string {
11
+ return `${options.imports}
12
+ import { readFile } from "node:fs/promises";
13
+ import { readMigrationFiles } from "drizzle-orm/migrator";
14
+
15
+ type AppliedMigration = {
16
+ hash: string;
17
+ createdAt: number;
18
+ };
19
+
20
+ type MigrationJournal = {
21
+ entries: Array<{ tag: string; when: number }>;
22
+ };
23
+
24
+ ${options.open}
25
+
26
+ try {
27
+ ${indent(options.loadApplied, 1)}
28
+ process.exitCode = await reportMigrationStatus(applied);
29
+ } finally {
30
+ ${indent(options.close, 1)}
31
+ }
32
+
33
+ async function reportMigrationStatus(
34
+ applied: AppliedMigration[],
35
+ ): Promise<number> {
36
+ const migrationsFolder = "drizzle";
37
+ const journal = JSON.parse(
38
+ await readFile(\`\${migrationsFolder}/meta/_journal.json\`, "utf8"),
39
+ ) as MigrationJournal;
40
+ const files = readMigrationFiles({ migrationsFolder });
41
+
42
+ if (journal.entries.length !== files.length) {
43
+ console.error(
44
+ \`Migration history is invalid: journal has \${journal.entries.length} entries but \${files.length} migration files were read.\`,
45
+ );
46
+ return 1;
47
+ }
48
+
49
+ const local = files.map((migration, index) => ({
50
+ tag: journal.entries[index]?.tag ?? \`migration-\${index}\`,
51
+ createdAt: migration.folderMillis,
52
+ hash: migration.hash,
53
+ }));
54
+ const localByCreatedAt = new Map(
55
+ local.map((migration) => [migration.createdAt, migration]),
56
+ );
57
+ const appliedByCreatedAt = new Map<number, AppliedMigration>();
58
+
59
+ for (const migration of applied) {
60
+ if (appliedByCreatedAt.has(migration.createdAt)) {
61
+ console.error(
62
+ \`Migration history has duplicate applied timestamp \${migration.createdAt}.\`,
63
+ );
64
+ return 1;
65
+ }
66
+ appliedByCreatedAt.set(migration.createdAt, migration);
67
+
68
+ const localMigration = localByCreatedAt.get(migration.createdAt);
69
+ if (!localMigration) {
70
+ console.error(
71
+ \`Applied migration \${migration.createdAt} is not present in the checked-in migration journal.\`,
72
+ );
73
+ return 1;
74
+ }
75
+ if (localMigration.hash !== migration.hash) {
76
+ console.error(
77
+ \`Applied migration \${localMigration.tag} no longer matches its checked-in SQL.\`,
78
+ );
79
+ return 1;
80
+ }
81
+ }
82
+
83
+ const latestAppliedAt = applied.reduce(
84
+ (latest, migration) => Math.max(latest, migration.createdAt),
85
+ 0,
86
+ );
87
+ for (const migration of local) {
88
+ if (
89
+ migration.createdAt <= latestAppliedAt &&
90
+ !appliedByCreatedAt.has(migration.createdAt)
91
+ ) {
92
+ console.error(
93
+ \`Checked-in migration \${migration.tag} predates the latest applied migration but is absent from database history.\`,
94
+ );
95
+ return 1;
96
+ }
97
+ }
98
+
99
+ const pending = local.filter(
100
+ (migration) => migration.createdAt > latestAppliedAt,
101
+ );
102
+ if (pending.length > 0) {
103
+ console.error(
104
+ \`\${pending.length} pending database migration(s):\\n\${pending
105
+ .map((migration) => \`- \${migration.tag}\`)
106
+ .join("\\n")}\`,
107
+ );
108
+ return 2;
109
+ }
110
+
111
+ console.log(
112
+ \`Database migrations are current (\${applied.length} applied, 0 pending).\`,
113
+ );
114
+ return 0;
115
+ }
116
+ `;
117
+ }
118
+
119
+ function indent(source: string, depth: number): string {
120
+ const prefix = "\t".repeat(depth);
121
+ return source
122
+ .trim()
123
+ .split("\n")
124
+ .map((line) => `${prefix}${line}`)
125
+ .join("\n");
126
+ }
@@ -25,6 +25,7 @@
25
25
  import { databaseLocalUrl, databaseStartCommand } from "../../choices.js";
26
26
  import type { TemplateContext } from "../shared.js";
27
27
  import type { DbTemplateFiles } from "./index.js";
28
+ import { migrationStatusTemplate } from "./migration-status.js";
28
29
 
29
30
  export function mysqlDbFiles(ctx: TemplateContext): DbTemplateFiles {
30
31
  return {
@@ -271,6 +272,28 @@ async function prepare(pool: Pool): Promise<void> {
271
272
  await migrate(drizzle(pool), { migrationsFolder: "drizzle" });
272
273
  }
273
274
  `,
275
+ dbMigrationStatus: migrationStatusTemplate({
276
+ imports: `import mysql, { type RowDataPacket } from "mysql2/promise";
277
+ import { env } from "@/lib/env";`,
278
+ open: `const pool = mysql.createPool({ uri: env.MYSQL_DB_URL });`,
279
+ loadApplied: `const [tables] = await pool.query<RowDataPacket[]>(
280
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '__drizzle_migrations' LIMIT 1",
281
+ );
282
+ const applied: AppliedMigration[] =
283
+ tables.length === 0
284
+ ? []
285
+ : (
286
+ (
287
+ await pool.query<RowDataPacket[]>(
288
+ "SELECT hash, created_at FROM __drizzle_migrations ORDER BY created_at",
289
+ )
290
+ )[0]
291
+ ).map((row) => ({
292
+ hash: String(row.hash),
293
+ createdAt: Number(row.created_at),
294
+ }));`,
295
+ close: "await pool.end();",
296
+ }),
274
297
  dbProvider: `import "@beignet/core/server-only";
275
298
  import { createProvider } from "@beignet/core/providers";
276
299
  import {
@@ -22,6 +22,7 @@
22
22
  import { databaseLocalUrl } from "../../choices.js";
23
23
  import type { TemplateContext } from "../shared.js";
24
24
  import type { DbTemplateFiles } from "./index.js";
25
+ import { migrationStatusTemplate } from "./migration-status.js";
25
26
 
26
27
  export function postgresDbFiles(ctx: TemplateContext): DbTemplateFiles {
27
28
  return {
@@ -259,6 +260,26 @@ async function prepare(pool: Pool): Promise<void> {
259
260
  await migrate(drizzle(pool), { migrationsFolder: "drizzle" });
260
261
  }
261
262
  `,
263
+ dbMigrationStatus: migrationStatusTemplate({
264
+ imports: `import pg from "pg";
265
+ import { env } from "@/lib/env";`,
266
+ open: `const pool = new pg.Pool({ connectionString: env.POSTGRES_DB_URL });`,
267
+ loadApplied: `const table = await pool.query<{ name: string | null }>(
268
+ "SELECT to_regclass('drizzle.__drizzle_migrations')::text AS name",
269
+ );
270
+ const applied: AppliedMigration[] =
271
+ table.rows[0]?.name == null
272
+ ? []
273
+ : (
274
+ await pool.query<{ hash: string; created_at: string }>(
275
+ 'SELECT hash, created_at FROM "drizzle"."__drizzle_migrations" ORDER BY created_at',
276
+ )
277
+ ).rows.map((row) => ({
278
+ hash: row.hash,
279
+ createdAt: Number(row.created_at),
280
+ }));`,
281
+ close: "await pool.end();",
282
+ }),
262
283
  dbProvider: `import "@beignet/core/server-only";
263
284
  import { createProvider } from "@beignet/core/providers";
264
285
  import {