@drzl/cli 4.35.0 → 4.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -515,6 +515,8 @@ var GeneratorKindSchema = import_zod.z.enum([
515
515
  "next",
516
516
  "tanstack-start",
517
517
  "ts-rest",
518
+ "openapi-fetch",
519
+ "forms",
518
520
  "seed",
519
521
  "fast-check",
520
522
  "service",
@@ -561,6 +563,13 @@ var GeneratorSchema = import_zod.z.object({
561
563
  contractName: import_zod.z.string().optional(),
562
564
  /** The identifier the assembled Elysia app is exported as. `elysia` only, defaults to `'app'`. */
563
565
  appName: import_zod.z.string().optional(),
566
+ /**
567
+ * The factory the emitted client exports. `openapi-fetch` only, defaults to `'createApiClient'`.
568
+ *
569
+ * A factory rather than a constructed client, because a client carries a `baseUrl` and a `fetch`
570
+ * and neither is a fact about a Drizzle schema.
571
+ */
572
+ clientName: import_zod.z.string().optional(),
564
573
  /** How many rows each generated seed function returns by default. `seed` only, defaults to 10. */
565
574
  count: import_zod.z.number().int().positive().optional(),
566
575
  /**
@@ -670,7 +679,24 @@ var GeneratorSchema = import_zod.z.object({
670
679
  */
671
680
  meta: import_zod.z.union([
672
681
  import_zod.z.boolean(),
673
- import_zod.z.object({ enabled: import_zod.z.boolean().optional(), description: import_zod.z.boolean().optional() }).strict()
682
+ import_zod.z.object({
683
+ enabled: import_zod.z.boolean().optional(),
684
+ description: import_zod.z.boolean().optional(),
685
+ /**
686
+ * Also give each schema an `id`, which registers it in zod's registry under that name.
687
+ *
688
+ * `z.toJSONSchema` then emits `$ref: '#/$defs/<id>'` wherever one schema references
689
+ * another instead of inlining a copy, and `z.toJSONSchema(z.globalRegistry)` returns a
690
+ * `{ schemas: { <id>: ... } }` bundle, which is what makes a generated document
691
+ * self-describing.
692
+ *
693
+ * Off by default because it is the one metadata key with a failure mode. Measured on zod
694
+ * 4.4.3, two schemas sharing an id make a registry dump silently drop one of them, with
695
+ * no warning. The id is built from the qualified table name, so an analysis cannot
696
+ * produce two the same.
697
+ */
698
+ registryIds: import_zod.z.boolean().optional()
699
+ }).strict()
674
700
  ]).optional(),
675
701
  /**
676
702
  * TypeBox only. Give every emitted schema a `~standard` key, so it can be handed to a tRPC or
@@ -743,7 +769,28 @@ var GeneratorSchema = import_zod.z.object({
743
769
  * in JSON Schema, it is ignored, so emitting the wrong dialect produces a document that
744
770
  * validates and then accepts the values the constraints exist to reject.
745
771
  */
746
- target: import_zod.z.enum(["draft-2020-12", "openapi-3.1", "openapi-3.0"]).optional(),
772
+ /**
773
+ * The `forms` generator reads the same key with its own values: which form library to emit for,
774
+ * defaulting to `react-hook-form`. One key, two kinds, as `document` already is for
775
+ * `json-schema` and `openapi-fetch`. A value from the wrong set on the wrong kind is reported
776
+ * by that generator rather than accepted, since neither shares a value with the other.
777
+ */
778
+ target: import_zod.z.enum([
779
+ "draft-2020-12",
780
+ "openapi-3.1",
781
+ "openapi-3.0",
782
+ "react-hook-form",
783
+ "tanstack-form",
784
+ "both"
785
+ ]).optional(),
786
+ /**
787
+ * Which operations get a resolver. `forms` only, defaults to insert and update.
788
+ *
789
+ * `select` is offered because a filter form is a form, but it is off by default: a select
790
+ * schema describes a row that came out of the database, so validating user input against it
791
+ * asks for the generated columns a form never supplies.
792
+ */
793
+ modes: import_zod.z.array(import_zod.z.enum(["insert", "update", "select"])).optional(),
747
794
  /** Also emit `components.ts` for the `json-schema` generator, ready for an OpenAPI document. */
748
795
  components: import_zod.z.boolean().optional(),
749
796
  /**
@@ -753,6 +800,11 @@ var GeneratorSchema = import_zod.z.object({
753
800
  * `true` is the short form. The object form carries the three things a Drizzle schema genuinely
754
801
  * cannot say: what the API is called, where it is served, and which status code that particular
755
802
  * server answers a request that fails its schema with.
803
+ *
804
+ * **The `openapi-fetch` generator reads the same key**, and the two have to be given the same
805
+ * value where both are configured. They are separate generators and nothing checks one against
806
+ * the other, so different options produce a client that describes a different API from the
807
+ * document beside it. The one that bites is `validationStatus`, which lands in both outputs.
756
808
  */
757
809
  document: import_zod.z.union([
758
810
  import_zod.z.boolean(),
@@ -987,7 +1039,9 @@ var ROUTER_KINDS = /* @__PURE__ */ new Set([
987
1039
  "h3",
988
1040
  "effect-http",
989
1041
  "ts-rest",
990
- "elysia"
1042
+ "elysia",
1043
+ "openapi-fetch",
1044
+ "forms"
991
1045
  ]);
992
1046
  var INJECTION_KINDS = /* @__PURE__ */ new Set(["orpc", "trpc"]);
993
1047
  function trpcOutDir(g, cfg) {
@@ -1029,6 +1083,12 @@ function effectHttpOutDir(g, cfg) {
1029
1083
  function tsRestOutDir(g, cfg) {
1030
1084
  return g.path ?? cfg.outDir;
1031
1085
  }
1086
+ function openApiFetchOutDir(g, cfg) {
1087
+ return g.path ?? cfg.outDir;
1088
+ }
1089
+ function formsOutDir(g, cfg) {
1090
+ return g.path ?? cfg.outDir;
1091
+ }
1032
1092
  function elysiaOutDir(g, cfg) {
1033
1093
  return g.path ?? cfg.outDir;
1034
1094
  }
@@ -1354,6 +1414,8 @@ function computeGeneratorOutputDirs(cfg, cwd = process.cwd()) {
1354
1414
  if (g.kind === "effect-http") dirs.add(abs(effectHttpOutDir(g, cfg)));
1355
1415
  if (g.kind === "ts-rest") dirs.add(abs(tsRestOutDir(g, cfg)));
1356
1416
  if (g.kind === "elysia") dirs.add(abs(elysiaOutDir(g, cfg)));
1417
+ if (g.kind === "openapi-fetch") dirs.add(abs(openApiFetchOutDir(g, cfg)));
1418
+ if (g.kind === "forms") dirs.add(abs(formsOutDir(g, cfg)));
1357
1419
  if (g.kind === "seed") dirs.add(abs(seedOutDir(g, cfg)));
1358
1420
  if (g.kind === "fast-check") dirs.add(abs(fastCheckOutDir(g, cfg)));
1359
1421
  if (g.kind === "service") dirs.add(abs(g.path ?? "src/services"));
@@ -1608,20 +1670,81 @@ function tsRestOptions(g, cfg) {
1608
1670
  };
1609
1671
  }
1610
1672
 
1611
- // src/elysia-options.ts
1673
+ // src/openapi-fetch-options.ts
1612
1674
  var VALIDATOR_DEFAULT_DIRS3 = {
1675
+ zod: "src/validators/zod",
1676
+ valibot: "src/validators/valibot",
1677
+ arktype: "src/validators/arktype"
1678
+ };
1679
+ function projectRelative3(p) {
1680
+ return p.startsWith("./") ? p.slice(2) : p;
1681
+ }
1682
+ function openApiFetchOptions(g, cfg) {
1683
+ const configured = g.validation?.library ?? "zod";
1684
+ const library = configured === "typebox" ? "zod" : configured;
1685
+ const siblings = cfg.generators.filter((s) => s.kind === library);
1686
+ const derived = siblings.length === 1 ? projectRelative3(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS3[library]) : void 0;
1687
+ return {
1688
+ outputDir: openApiFetchOutDir(g, cfg),
1689
+ clientName: g.clientName,
1690
+ document: g.document,
1691
+ outputHeader: g.outputHeader,
1692
+ format: g.format,
1693
+ importExtension: g.importExtension,
1694
+ validation: {
1695
+ ...g.validation,
1696
+ library,
1697
+ useShared: true,
1698
+ importPath: g.validation?.importPath ?? derived
1699
+ }
1700
+ };
1701
+ }
1702
+
1703
+ // src/forms-options.ts
1704
+ var VALIDATOR_DEFAULT_DIRS4 = {
1705
+ zod: "src/validators/zod",
1706
+ valibot: "src/validators/valibot",
1707
+ arktype: "src/validators/arktype",
1708
+ typebox: "src/validators/typebox",
1709
+ effect: "src/validators/effect"
1710
+ };
1711
+ function projectRelative4(p) {
1712
+ return p.startsWith("./") ? p.slice(2) : p;
1713
+ }
1714
+ function formsOptions(g, cfg) {
1715
+ const library = g.validation?.library ?? "zod";
1716
+ const siblings = cfg.generators.filter((s) => s.kind === library);
1717
+ const derived = siblings.length === 1 ? projectRelative4(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS4[library]) : void 0;
1718
+ return {
1719
+ outputDir: formsOutDir(g, cfg),
1720
+ target: g.target,
1721
+ modes: g.modes,
1722
+ outputHeader: g.outputHeader,
1723
+ format: g.format,
1724
+ importExtension: g.importExtension,
1725
+ validation: {
1726
+ ...g.validation,
1727
+ library,
1728
+ useShared: true,
1729
+ importPath: g.validation?.importPath ?? derived
1730
+ }
1731
+ };
1732
+ }
1733
+
1734
+ // src/elysia-options.ts
1735
+ var VALIDATOR_DEFAULT_DIRS5 = {
1613
1736
  zod: "src/validators/zod",
1614
1737
  valibot: "src/validators/valibot",
1615
1738
  arktype: "src/validators/arktype",
1616
1739
  typebox: "src/validators/typebox"
1617
1740
  };
1618
- function projectRelative3(p) {
1741
+ function projectRelative5(p) {
1619
1742
  return p.startsWith("./") ? p.slice(2) : p;
1620
1743
  }
1621
1744
  function elysiaOptions(g, cfg) {
1622
1745
  const library = g.validation?.library ?? "zod";
1623
1746
  const siblings = cfg.generators.filter((s) => s.kind === library);
1624
- const derived = siblings.length === 1 ? projectRelative3(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS3[library]) : void 0;
1747
+ const derived = siblings.length === 1 ? projectRelative5(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS5[library]) : void 0;
1625
1748
  return {
1626
1749
  outputDir: elysiaOutDir(g, cfg),
1627
1750
  appName: g.appName,
@@ -1663,19 +1786,19 @@ function fastCheckOptions(g, cfg) {
1663
1786
  }
1664
1787
 
1665
1788
  // src/h3-options.ts
1666
- var VALIDATOR_DEFAULT_DIRS4 = {
1789
+ var VALIDATOR_DEFAULT_DIRS6 = {
1667
1790
  zod: "src/validators/zod",
1668
1791
  valibot: "src/validators/valibot",
1669
1792
  arktype: "src/validators/arktype"
1670
1793
  };
1671
- function projectRelative4(p) {
1794
+ function projectRelative6(p) {
1672
1795
  return p.startsWith("./") ? p.slice(2) : p;
1673
1796
  }
1674
1797
  function h3Options(g, cfg) {
1675
1798
  const configured = g.validation?.library ?? "zod";
1676
1799
  const library = configured === "typebox" ? "zod" : configured;
1677
1800
  const siblings = cfg.generators.filter((s) => s.kind === library);
1678
- const derived = siblings.length === 1 ? projectRelative4(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS4[library]) : void 0;
1801
+ const derived = siblings.length === 1 ? projectRelative6(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS6[library]) : void 0;
1679
1802
  return {
1680
1803
  outputDir: h3OutDir(g, cfg),
1681
1804
  h3: g.h3,
@@ -1709,19 +1832,19 @@ function mcpOptions(g, cfg) {
1709
1832
  }
1710
1833
 
1711
1834
  // src/next-options.ts
1712
- var VALIDATOR_DEFAULT_DIRS5 = {
1835
+ var VALIDATOR_DEFAULT_DIRS7 = {
1713
1836
  zod: "src/validators/zod",
1714
1837
  valibot: "src/validators/valibot",
1715
1838
  arktype: "src/validators/arktype"
1716
1839
  };
1717
- function projectRelative5(p) {
1840
+ function projectRelative7(p) {
1718
1841
  return p.startsWith("./") ? p.slice(2) : p;
1719
1842
  }
1720
1843
  function nextOptions(g, cfg) {
1721
1844
  const configured = g.validation?.library ?? "zod";
1722
1845
  const library = configured === "typebox" ? "zod" : configured;
1723
1846
  const siblings = cfg.generators.filter((s) => s.kind === library);
1724
- const derived = siblings.length === 1 ? projectRelative5(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS5[library]) : void 0;
1847
+ const derived = siblings.length === 1 ? projectRelative7(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS7[library]) : void 0;
1725
1848
  return {
1726
1849
  outputDir: nextOutDir(g, cfg),
1727
1850
  naming: g.naming,
@@ -1738,19 +1861,19 @@ function nextOptions(g, cfg) {
1738
1861
  }
1739
1862
 
1740
1863
  // src/tanstack-start-options.ts
1741
- var VALIDATOR_DEFAULT_DIRS6 = {
1864
+ var VALIDATOR_DEFAULT_DIRS8 = {
1742
1865
  zod: "src/validators/zod",
1743
1866
  valibot: "src/validators/valibot",
1744
1867
  arktype: "src/validators/arktype"
1745
1868
  };
1746
- function projectRelative6(p) {
1869
+ function projectRelative8(p) {
1747
1870
  return p.startsWith("./") ? p.slice(2) : p;
1748
1871
  }
1749
1872
  function tanstackStartOptions(g, cfg) {
1750
1873
  const configured = g.validation?.library ?? "zod";
1751
1874
  const library = configured === "typebox" ? "zod" : configured;
1752
1875
  const siblings = cfg.generators.filter((s) => s.kind === library);
1753
- const derived = siblings.length === 1 ? projectRelative6(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS6[library]) : void 0;
1876
+ const derived = siblings.length === 1 ? projectRelative8(siblings[0].path ?? VALIDATOR_DEFAULT_DIRS8[library]) : void 0;
1754
1877
  return {
1755
1878
  outputDir: tanstackStartOutDir(g, cfg),
1756
1879
  naming: g.naming,
@@ -1834,7 +1957,7 @@ function trpcOptions(g, cfg, servicesDir) {
1834
1957
  }
1835
1958
 
1836
1959
  // src/generator-registry.ts
1837
- var VALIDATOR_DEFAULT_DIRS7 = {
1960
+ var VALIDATOR_DEFAULT_DIRS9 = {
1838
1961
  zod: "src/validators/zod",
1839
1962
  valibot: "src/validators/valibot",
1840
1963
  arktype: "src/validators/arktype",
@@ -1867,10 +1990,14 @@ var GENERATORS = [
1867
1990
  //
1868
1991
  // The side effect was invisible and lasted longer than the reason: tsup externalises
1869
1992
  // `dependencies` and `peerDependencies` and bundles everything else, so those eight travelled
1870
- // inside `dist` while the other six were resolved from `node_modules`. All fourteen are on the
1871
- // registry now and all fourteen are `dependencies`, which is what makes every one of them a
1872
- // package that can genuinely be absent, and `loadGenerator` tell absence apart from failure
1873
- // for every kind rather than for six of them.
1993
+ // inside `dist` while the rest were resolved from `node_modules`. Every generator package is on
1994
+ // the registry now and every one is a `dependencies` entry, which is what makes each of them a
1995
+ // package that can genuinely be absent, and `loadGenerator` tell absence apart from failure for
1996
+ // every kind rather than for some of them.
1997
+ //
1998
+ // Counted nowhere on purpose. This paragraph twice carried a number that the next batch of
1999
+ // generators falsified, and `packages/cli/test/generator-registry.spec.ts` asserts the property
2000
+ // against the manifest, which is where a quantity belongs.
1874
2001
  specifier: "@drzl/generator-trpc",
1875
2002
  load: () => import("@drzl/generator-trpc"),
1876
2003
  construct: (m, analysis) => new m.TRPCGenerator(analysis),
@@ -1922,9 +2049,10 @@ var GENERATORS = [
1922
2049
  // This one and the three below spent one release each in `optionalDependencies`, because a
1923
2050
  // package that has never existed cannot publish through npm's trusted-publisher OIDC flow and
1924
2051
  // naming it as a hard dependency in the release that introduces it breaks `npm i @drzl/cli`
1925
- // for everyone until the first publish lands. All four are on the registry now, so all four
1926
- // are ordinary dependencies. `scripts/verify/stages/33-registry-deps.sh` gates both halves of
1927
- // that rule and is what reported the promotion was due.
2052
+ // for everyone until the first publish lands. The six generators added after them went the
2053
+ // same way and were promoted the same way once their first versions were published by hand.
2054
+ // Nothing here is optional any more. `scripts/verify/stages/33-registry-deps.sh` gates both
2055
+ // halves of that rule and is what reported each promotion was due.
1928
2056
  specifier: "@drzl/generator-mcp",
1929
2057
  load: () => import("@drzl/generator-mcp"),
1930
2058
  construct: (m, analysis) => new m.MCPGenerator(analysis),
@@ -1979,6 +2107,22 @@ var GENERATORS = [
1979
2107
  outputDir: (g, cfg) => tsRestOutDir(g, cfg),
1980
2108
  options: (g, cfg) => tsRestOptions(g, cfg)
1981
2109
  },
2110
+ {
2111
+ kind: "openapi-fetch",
2112
+ specifier: "@drzl/generator-openapi-fetch",
2113
+ load: () => import("@drzl/generator-openapi-fetch"),
2114
+ construct: (m, analysis) => new m.OpenApiFetchGenerator(analysis),
2115
+ outputDir: (g, cfg) => openApiFetchOutDir(g, cfg),
2116
+ options: (g, cfg) => openApiFetchOptions(g, cfg)
2117
+ },
2118
+ {
2119
+ kind: "forms",
2120
+ specifier: "@drzl/generator-forms",
2121
+ load: () => import("@drzl/generator-forms"),
2122
+ construct: (m, analysis) => new m.FormsGenerator(analysis),
2123
+ outputDir: (g, cfg) => formsOutDir(g, cfg),
2124
+ options: (g, cfg) => formsOptions(g, cfg)
2125
+ },
1982
2126
  {
1983
2127
  kind: "elysia",
1984
2128
  specifier: "@drzl/generator-elysia",
@@ -2016,7 +2160,7 @@ var GENERATORS = [
2016
2160
  specifier: "@drzl/generator-zod",
2017
2161
  load: () => import("@drzl/generator-zod"),
2018
2162
  construct: (m, analysis) => new m.ZodGenerator(analysis),
2019
- outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS7.zod,
2163
+ outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.zod,
2020
2164
  // `meta` is zod-only; see `GeneratorCapabilities.meta` for why it is not passed to the other
2021
2165
  // four rather than being passed and ignored.
2022
2166
  options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, {
@@ -2030,7 +2174,7 @@ var GENERATORS = [
2030
2174
  specifier: "@drzl/generator-valibot",
2031
2175
  load: () => import("@drzl/generator-valibot"),
2032
2176
  construct: (m, analysis) => new m.ValibotGenerator(analysis),
2033
- outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS7.valibot,
2177
+ outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.valibot,
2034
2178
  options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, { schemaTypes: true, constraints: true })
2035
2179
  },
2036
2180
  {
@@ -2038,7 +2182,7 @@ var GENERATORS = [
2038
2182
  specifier: "@drzl/generator-arktype",
2039
2183
  load: () => import("@drzl/generator-arktype"),
2040
2184
  construct: (m, analysis) => new m.ArkTypeGenerator(analysis),
2041
- outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS7.arktype,
2185
+ outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.arktype,
2042
2186
  options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, { schemaTypes: false })
2043
2187
  },
2044
2188
  {
@@ -2046,7 +2190,7 @@ var GENERATORS = [
2046
2190
  specifier: "@drzl/generator-typebox",
2047
2191
  load: () => import("@drzl/generator-typebox"),
2048
2192
  construct: (m, analysis) => new m.TypeBoxGenerator(analysis),
2049
- outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS7.typebox,
2193
+ outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.typebox,
2050
2194
  options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, { schemaTypes: true, standardSchema: true })
2051
2195
  },
2052
2196
  {
@@ -2054,7 +2198,7 @@ var GENERATORS = [
2054
2198
  specifier: "@drzl/generator-effect",
2055
2199
  load: () => import("@drzl/generator-effect"),
2056
2200
  construct: (m, analysis) => new m.EffectGenerator(analysis),
2057
- outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS7.effect,
2201
+ outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9.effect,
2058
2202
  options: (g, cfg, ctx) => validationOptions(g, cfg, ctx.outDir, { schemaTypes: true })
2059
2203
  },
2060
2204
  {
@@ -2062,7 +2206,7 @@ var GENERATORS = [
2062
2206
  specifier: "@drzl/generator-json-schema",
2063
2207
  load: () => import("@drzl/generator-json-schema"),
2064
2208
  construct: (m, analysis) => new m.JsonSchemaGenerator(analysis),
2065
- outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS7["json-schema"],
2209
+ outputDir: (g) => g.path ?? VALIDATOR_DEFAULT_DIRS9["json-schema"],
2066
2210
  options: (g, cfg, ctx) => jsonSchemaOptions(g, cfg, ctx.outDir)
2067
2211
  }
2068
2212
  ];
@@ -3376,6 +3520,24 @@ function sqlLiterals(values) {
3376
3520
  function derivedName(table, column) {
3377
3521
  return `${table}_${column}_check`;
3378
3522
  }
3523
+ var ADD_CONSTRAINT_DIALECTS = /* @__PURE__ */ new Set([
3524
+ "postgres",
3525
+ "cockroach",
3526
+ "gel",
3527
+ "mysql",
3528
+ "singlestore",
3529
+ "mssql"
3530
+ ]);
3531
+ function fixFor(dialect, table, column, values) {
3532
+ if (!ADD_CONSTRAINT_DIALECTS.has(dialect)) return void 0;
3533
+ return `ALTER TABLE ${table} ADD CONSTRAINT ${derivedName(table, column)} CHECK (${column} IN (${sqlLiterals(values)}));`;
3534
+ }
3535
+ function noFixReason(dialect) {
3536
+ if (dialect === "sqlite") {
3537
+ return "SQLite cannot add a CHECK to an existing column: ALTER TABLE ... ADD CONSTRAINT is a syntax error there. Closing this means rebuilding the table, which is the twelve-step procedure SQLite documents: create the new table with the constraint, copy the rows, drop the old one, rename.";
3538
+ }
3539
+ return `no statement is emitted for the "${dialect}" dialect, which this report cannot name a fix for`;
3540
+ }
3379
3541
  function buildConstraintDriftReport(analysis, schemaPath) {
3380
3542
  const entries = [];
3381
3543
  for (const table of analysis.tables) {
@@ -3411,7 +3573,10 @@ function buildConstraintDriftReport(analysis, schemaPath) {
3411
3573
  columns: [column.name],
3412
3574
  rule: `${column.name} IN (${sqlLiterals(values)})`,
3413
3575
  reason: `the column is declared ${column.sqlType}, which holds any text; the set exists only in the generated schemas`,
3414
- fix: `ALTER TABLE ${table.name} ADD CONSTRAINT ${derivedName(table.name, column.name)} CHECK (${column.name} IN (${sqlLiterals(values)}));`
3576
+ ...(() => {
3577
+ const fix = fixFor(analysis.dialect, table.name, column.name, values);
3578
+ return fix ? { fix } : { noFix: noFixReason(analysis.dialect) };
3579
+ })()
3415
3580
  });
3416
3581
  }
3417
3582
  }
@@ -3486,6 +3651,8 @@ function renderConstraintDriftReport(report, style = PLAIN3) {
3486
3651
  if (e.fix) {
3487
3652
  out.push(chalk.dim(" Close it with:"));
3488
3653
  out.push(` ${e.fix}`);
3654
+ } else if (e.noFix) {
3655
+ out.push(chalk.dim(wrap3(e.noFix, " ", " Not one statement here: ")));
3489
3656
  }
3490
3657
  out.push("");
3491
3658
  }
@@ -3517,6 +3684,271 @@ function renderConstraintDriftReport(report, style = PLAIN3) {
3517
3684
  );
3518
3685
  return out.join("\n");
3519
3686
  }
3687
+ function renderConstraintDriftSql(report) {
3688
+ const gaps = report.entries.filter((e) => e.side === "schema-only");
3689
+ if (!gaps.length) return "";
3690
+ const out = [];
3691
+ const runnable = gaps.filter((g) => g.fix);
3692
+ const unrunnable = gaps.filter((g) => !g.fix);
3693
+ out.push(`-- Generated by drzl doctor --constraints --sql`);
3694
+ out.push(`-- ${report.schema}, ${report.dialect}`);
3695
+ out.push(
3696
+ `-- ${runnable.length} constraint(s) your schemas enforce and the database does not.`
3697
+ );
3698
+ if (unrunnable.length) {
3699
+ out.push(
3700
+ `-- ${unrunnable.length} more cannot be closed by one statement on this dialect; see below.`
3701
+ );
3702
+ }
3703
+ out.push("");
3704
+ const byPlace = (a, b) => a.table.localeCompare(b.table) || a.columns.join().localeCompare(b.columns.join());
3705
+ for (const g of [...runnable].sort(byPlace)) {
3706
+ out.push(`-- ${g.table}.${g.columns.join(", ")}: ${g.reason}`);
3707
+ out.push(g.fix);
3708
+ out.push("");
3709
+ }
3710
+ if (unrunnable.length) {
3711
+ out.push("-- The rest, which this dialect cannot do in one statement:");
3712
+ for (const g of [...unrunnable].sort(byPlace)) {
3713
+ out.push(`--`);
3714
+ out.push(`-- ${g.table}.${g.columns.join(", ")}`);
3715
+ out.push(`-- ${g.rule}`);
3716
+ for (const line of (g.noFix ?? "").match(/.{1,88}(\s|$)/g) ?? []) {
3717
+ out.push(`-- ${line.trim()}`);
3718
+ }
3719
+ }
3720
+ out.push("");
3721
+ }
3722
+ return out.join("\n");
3723
+ }
3724
+
3725
+ // src/policy-report.ts
3726
+ var import_chalk5 = require("chalk");
3727
+ var PLAIN4 = new import_chalk5.Chalk({ level: 0 });
3728
+ var COMMANDS = ["select", "insert", "update", "delete"];
3729
+ var DENIAL = {
3730
+ select: "every read returns zero rows",
3731
+ insert: "every insert is refused",
3732
+ update: "every update is refused",
3733
+ delete: "every delete is refused"
3734
+ };
3735
+ function appliesTo(policy, command) {
3736
+ const forCmd = (policy.for ?? "all").toLowerCase();
3737
+ return forCmd === "all" || forCmd === command;
3738
+ }
3739
+ function grants(policy, command) {
3740
+ if (!appliesTo(policy, command)) return false;
3741
+ if ((policy.as ?? "permissive").toLowerCase() === "restrictive") return false;
3742
+ const hasUsing = !!policy.using;
3743
+ const hasCheck = !!policy.withCheck;
3744
+ const forCmd = (policy.for ?? "all").toLowerCase();
3745
+ if (forCmd === "all") return hasUsing || hasCheck;
3746
+ switch (command) {
3747
+ case "select":
3748
+ case "delete":
3749
+ return hasUsing;
3750
+ case "insert":
3751
+ return hasCheck;
3752
+ case "update":
3753
+ return hasCheck || hasUsing;
3754
+ }
3755
+ }
3756
+ function hasAnyExpression(policy) {
3757
+ return !!policy.using || !!policy.withCheck;
3758
+ }
3759
+ function readTable(table) {
3760
+ if (typeof table.rlsEnabled !== "boolean") return void 0;
3761
+ const policies = table.policies ?? [];
3762
+ const declaredRls = table.rlsEnabled;
3763
+ const effective = declaredRls || policies.length > 0;
3764
+ if (!effective) return void 0;
3765
+ const grantMap = {};
3766
+ for (const c of COMMANDS) grantMap[c] = policies.some((p) => grants(p, c));
3767
+ return { table: table.name, declaredRls, effective, policies, grants: grantMap };
3768
+ }
3769
+ function buildPolicyReport(analysis, schemaPath) {
3770
+ const tables = [];
3771
+ const findings = [];
3772
+ for (const table of analysis.tables) {
3773
+ const read = readTable(table);
3774
+ if (!read) continue;
3775
+ tables.push(read);
3776
+ if (COMMANDS.every((c) => !read.grants[c])) {
3777
+ findings.push({
3778
+ kind: "denied",
3779
+ table: read.table,
3780
+ detail: "row-level security is on and no permissive policy grants anything, so every read returns zero rows and every write is refused, for every role but the table's owner and any role with BYPASSRLS",
3781
+ // Deliberately not "give it a USING": a policy written `FOR INSERT` consults only its
3782
+ // WITH CHECK, so that advice would be wrong for exactly the declaration this report exists
3783
+ // to catch. The per-policy findings below name the right expression for each one.
3784
+ fix: read.policies.length ? `no policy here grants a command: ${read.policies.map((p) => `"${p.name}"`).join(", ")} ${read.policies.length === 1 ? "needs" : "need"} a USING expression, or a WITH CHECK where the command is INSERT` : "declare a policy, or drop the row-level security on this table"
3785
+ });
3786
+ } else {
3787
+ for (const command of COMMANDS) {
3788
+ if (read.grants[command]) continue;
3789
+ const named = read.policies.filter((p) => appliesTo(p, command));
3790
+ findings.push({
3791
+ kind: "denied",
3792
+ table: read.table,
3793
+ command,
3794
+ detail: `row-level security is on and no permissive policy grants ${command.toUpperCase()}, so ${DENIAL[command]} for every role but the table's owner and any role with BYPASSRLS`,
3795
+ fix: named.length ? `${named.length === 1 ? "the policy" : "the policies"} ${named.map((p) => `"${p.name}"`).join(", ")} name${named.length === 1 ? "s" : ""} ${command.toUpperCase()} but grant${named.length === 1 ? "s" : ""} nothing; give ${command === "insert" ? "it a WITH CHECK" : "it a USING"} expression` : `declare a policy for ${command.toUpperCase()}, or drop the row-level security on this table`
3796
+ });
3797
+ }
3798
+ }
3799
+ for (const policy of read.policies) {
3800
+ if (hasAnyExpression(policy)) continue;
3801
+ findings.push({
3802
+ kind: "grants-nothing",
3803
+ table: read.table,
3804
+ policy: policy.name,
3805
+ detail: "the policy carries neither a USING nor a WITH CHECK expression, so it permits nothing and only the policies beside it decide what this table allows",
3806
+ fix: (policy.for ?? "all").toLowerCase() === "insert" ? "give it a WITH CHECK expression, which is the only one INSERT consults" : "give it a USING expression, and a WITH CHECK too where writes need a different rule"
3807
+ });
3808
+ }
3809
+ }
3810
+ const policies = tables.reduce((n, t) => n + t.policies.length, 0);
3811
+ return {
3812
+ schema: schemaPath,
3813
+ dialect: analysis.dialect,
3814
+ ok: findings.length === 0,
3815
+ counts: {
3816
+ tables: analysis.tables.length,
3817
+ withRls: tables.length,
3818
+ policies,
3819
+ findings: findings.length
3820
+ },
3821
+ tables,
3822
+ findings,
3823
+ ignoredByGeneratedCode: tables.map((t) => t.table)
3824
+ };
3825
+ }
3826
+ function wrap4(text, indent, first = indent, width = 96) {
3827
+ const words = text.split(/\s+/).filter(Boolean);
3828
+ const lines = [];
3829
+ let line = first;
3830
+ let started = false;
3831
+ for (const w of words) {
3832
+ if (started && line.length + 1 + w.length > width) {
3833
+ lines.push(line);
3834
+ line = indent + w;
3835
+ } else {
3836
+ line = started ? `${line} ${w}` : line + w;
3837
+ started = true;
3838
+ }
3839
+ }
3840
+ if (started) lines.push(line);
3841
+ return lines.join("\n");
3842
+ }
3843
+ function policyLine(policy) {
3844
+ const bits = [(policy.for ?? "all").toUpperCase()];
3845
+ if ((policy.as ?? "").toLowerCase() === "restrictive") bits.push("restrictive");
3846
+ bits.push(policy.to?.length ? `to ${policy.to.join(", ")}` : "to public");
3847
+ const carries = [policy.using ? "USING" : void 0, policy.withCheck ? "WITH CHECK" : void 0].filter(Boolean).join(" + ");
3848
+ bits.push(carries || "no expression");
3849
+ if (policy.linked) bits.push("linked");
3850
+ return bits.join(", ");
3851
+ }
3852
+ function renderPolicyReport(report, style = PLAIN4) {
3853
+ const chalk = style;
3854
+ const out = [];
3855
+ const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
3856
+ out.push(chalk.bold(`DRZL row-level security ${report.schema}`));
3857
+ out.push(
3858
+ chalk.dim(
3859
+ `${report.dialect}, ${plural(report.counts.withRls, "table")} under RLS, ${plural(report.counts.policies, "policy").replace("policys", "policies")}`
3860
+ )
3861
+ );
3862
+ out.push("");
3863
+ if (!report.counts.withRls) {
3864
+ out.push(chalk.green("No table in this schema uses row-level security."));
3865
+ if (report.dialect !== "postgres" && report.dialect !== "cockroach") {
3866
+ out.push(chalk.dim(` ${report.dialect} has no row-level security to declare.`));
3867
+ } else {
3868
+ out.push(chalk.dim(" No table calls .enableRLS() and none declares a pgPolicy."));
3869
+ }
3870
+ return out.join("\n");
3871
+ }
3872
+ const denied = report.findings.filter((f) => f.kind === "denied");
3873
+ if (denied.length) {
3874
+ out.push(chalk.red("These tables refuse the operation your generated code performs"));
3875
+ out.push(
3876
+ chalk.dim(
3877
+ wrap4(
3878
+ "A table under row-level security permits only what a policy grants. The generated service still compiles and its return type still promises rows.",
3879
+ " "
3880
+ )
3881
+ )
3882
+ );
3883
+ out.push("");
3884
+ for (const f of denied) {
3885
+ out.push(` ${chalk.dim("-")} ${chalk.bold(f.table)} ${chalk.dim(f.command ?? "everything")}`);
3886
+ out.push(wrap4(f.detail, " "));
3887
+ if (f.fix) out.push(chalk.dim(wrap4(f.fix, " ", " Close it: ")));
3888
+ out.push("");
3889
+ }
3890
+ }
3891
+ const dead = report.findings.filter((f) => f.kind === "grants-nothing");
3892
+ if (dead.length) {
3893
+ out.push(chalk.yellow("These policies grant nothing"));
3894
+ out.push(
3895
+ chalk.dim(
3896
+ wrap4(
3897
+ "A policy with neither expression is not a permissive default. Measured against Postgres 18.3: a lone FOR INSERT policy carrying no WITH CHECK refused every insert.",
3898
+ " "
3899
+ )
3900
+ )
3901
+ );
3902
+ out.push("");
3903
+ for (const f of dead) {
3904
+ out.push(` ${chalk.dim("-")} ${chalk.bold(f.table)}.${f.policy}`);
3905
+ out.push(wrap4(f.detail, " "));
3906
+ if (f.fix) out.push(chalk.dim(wrap4(f.fix, " ", " Close it: ")));
3907
+ out.push("");
3908
+ }
3909
+ }
3910
+ out.push(chalk.cyan("The policies each table carries"));
3911
+ out.push(
3912
+ chalk.dim(
3913
+ wrap4(
3914
+ "Whether these apply to the role your application connects as is the one question this report cannot answer for you.",
3915
+ " "
3916
+ )
3917
+ )
3918
+ );
3919
+ out.push("");
3920
+ for (const t of report.tables) {
3921
+ const flag = t.declaredRls ? "enableRLS()" : "RLS on, implied by its policies";
3922
+ out.push(` ${chalk.dim("-")} ${chalk.bold(t.table)} ${chalk.dim(`(${flag})`)}`);
3923
+ if (!t.policies.length) {
3924
+ out.push(chalk.dim(" no policies"));
3925
+ }
3926
+ for (const p of t.policies) {
3927
+ out.push(` ${p.name}: ${chalk.dim(policyLine(p))}`);
3928
+ }
3929
+ const permitted = COMMANDS.filter((c) => t.grants[c]);
3930
+ out.push(
3931
+ chalk.dim(` permits: ${permitted.length ? permitted.join(", ") : "nothing"}`)
3932
+ );
3933
+ out.push("");
3934
+ }
3935
+ out.push(chalk.yellow("What DRZL generates does not know about any of this"));
3936
+ out.push(
3937
+ chalk.dim(
3938
+ wrap4(
3939
+ `No generator emits policy awareness, so a generated read path describes rows the caller may not be allowed to see, and a reader of the emitted types will believe otherwise. That is true of ${plural(report.ignoredByGeneratedCode.length, "table")} here, and it is a fact about DRZL rather than a defect in your schema.`,
3940
+ " "
3941
+ )
3942
+ )
3943
+ );
3944
+ out.push("");
3945
+ out.push(
3946
+ chalk.dim(
3947
+ `${plural(report.counts.findings, "finding")} across ${plural(report.counts.withRls, "table")} under row-level security.`
3948
+ )
3949
+ );
3950
+ return out.join("\n");
3951
+ }
3520
3952
 
3521
3953
  // src/drift.ts
3522
3954
  var import_node_fs = require("fs");
@@ -4443,6 +4875,9 @@ withOutputFlags(
4443
4875
  program.command("doctor").description("Report what DRZL cannot type or enforce in your schema, and why").argument("[schema]", "path to drizzle schema (TS); defaults to the schema in drzl.config").option("-c, --config <path>", "path to drzl.config, read when no schema argument is given").option("--strict", "exit 2 when anything is reported", false).option(
4444
4876
  "--constraints",
4445
4877
  "report what each side enforces that the other does not, instead of the usual findings"
4878
+ ).option("--sql", "with --constraints, emit the statements alone, for redirecting to a migration").option(
4879
+ "--policies",
4880
+ "report the row-level security policies each table carries, and what they refuse"
4446
4881
  )
4447
4882
  ).action(async (schema, opts) => {
4448
4883
  const out = outputFor(opts);
@@ -4466,7 +4901,21 @@ withOutputFlags(
4466
4901
  validateConstraints: true
4467
4902
  });
4468
4903
  const schemaLabel = Array.isArray(target) ? target.join(", ") : target;
4469
- if (opts.constraints) {
4904
+ if (opts.sql && !opts.constraints) {
4905
+ const msg = "--sql reports the constraint drift as statements, so it needs --constraints.";
4906
+ if (opts.json) out.jsonData(jsonFailure("doctor", "DRZL_CLI_DOCTOR", msg));
4907
+ else out.error("Doctor failed (DRZL_CLI_DOCTOR):", msg);
4908
+ process.exit(EXIT_FAILED);
4909
+ return;
4910
+ }
4911
+ if (opts.constraints && opts.policies) {
4912
+ const msg = "--constraints and --policies are separate reports. Run one, then the other.";
4913
+ if (opts.json) out.jsonData(jsonFailure("doctor", "DRZL_CLI_DOCTOR", msg));
4914
+ else out.error("Doctor failed (DRZL_CLI_DOCTOR):", msg);
4915
+ process.exit(EXIT_FAILED);
4916
+ return;
4917
+ }
4918
+ if (opts.constraints || opts.policies) {
4470
4919
  const preflight = buildDoctorReport(analysis, schemaLabel);
4471
4920
  const fatal = preflight.findings.filter((f) => f.level === "error");
4472
4921
  if (fatal.length) {
@@ -4482,8 +4931,15 @@ withOutputFlags(
4482
4931
  process.exit(EXIT_FAILED);
4483
4932
  return;
4484
4933
  }
4934
+ }
4935
+ if (opts.constraints) {
4485
4936
  const drift = buildConstraintDriftReport(analysis, schemaLabel);
4486
4937
  const driftCode = opts.strict && drift.counts.schemaOnly ? EXIT_FINDINGS : EXIT_OK;
4938
+ if (opts.sql) {
4939
+ out.data(renderConstraintDriftSql(drift));
4940
+ process.exit(driftCode);
4941
+ return;
4942
+ }
4487
4943
  if (opts.json)
4488
4944
  out.data(
4489
4945
  JSON.stringify({ command: "doctor", exitCode: driftCode, ...drift }, null, 2)
@@ -4492,6 +4948,17 @@ withOutputFlags(
4492
4948
  process.exit(driftCode);
4493
4949
  return;
4494
4950
  }
4951
+ if (opts.policies) {
4952
+ const policies = buildPolicyReport(analysis, schemaLabel);
4953
+ const policyCode = opts.strict && policies.counts.findings ? EXIT_FINDINGS : EXIT_OK;
4954
+ if (opts.json)
4955
+ out.data(
4956
+ JSON.stringify({ command: "doctor", exitCode: policyCode, ...policies }, null, 2)
4957
+ );
4958
+ else out.data(renderPolicyReport(policies, out.outStyle));
4959
+ process.exit(policyCode);
4960
+ return;
4961
+ }
4495
4962
  const report = buildDoctorReport(analysis, schemaLabel);
4496
4963
  const unreadable = report.findings.some((f) => f.level === "error");
4497
4964
  const code = unreadable ? EXIT_FAILED : opts.strict && report.findings.length ? EXIT_FINDINGS : EXIT_OK;