@drzl/validation-core 3.22.5 → 3.23.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/index.cjs CHANGED
@@ -44,6 +44,8 @@ __export(index_exports, {
44
44
  DEFAULT_SCHEMA_SUFFIX: () => DEFAULT_SCHEMA_SUFFIX,
45
45
  DEFAULT_TYPE_SUFFIX: () => DEFAULT_TYPE_SUFFIX,
46
46
  IMPORT_EXTENSIONS: () => IMPORT_EXTENSIONS,
47
+ JSON_VALUE_TS_CONST: () => JSON_VALUE_TS_CONST,
48
+ JSON_VALUE_TS_SOURCE: () => JSON_VALUE_TS_SOURCE,
47
49
  MAX_NESTED_DEPTH: () => MAX_NESTED_DEPTH,
48
50
  NAME_MODES: () => NAME_MODES,
49
51
  NESTED_PREFIX: () => NESTED_PREFIX,
@@ -62,6 +64,7 @@ __export(index_exports, {
62
64
  columnMetaFacts: () => columnMetaFacts,
63
65
  comparisonWire: () => comparisonWire,
64
66
  describeSet: () => describeSet,
67
+ fieldFacts: () => fieldFacts,
65
68
  fileWriter: () => fileWriter,
66
69
  formatCode: () => formatCode,
67
70
  importSpecifier: () => importSpecifier,
@@ -73,6 +76,7 @@ __export(index_exports, {
73
76
  lengthMeasure: () => lengthMeasure,
74
77
  measureCompare: () => measureCompare,
75
78
  measureExpression: () => measureExpression,
79
+ metaSchemaId: () => metaSchemaId,
76
80
  moduleFileName: () => moduleFileName,
77
81
  moduleSpecifier: () => moduleSpecifier,
78
82
  needsNumericCanon: () => needsNumericCanon,
@@ -1459,6 +1463,60 @@ function fileWriter(sink) {
1459
1463
  };
1460
1464
  }
1461
1465
 
1466
+ // src/fields.ts
1467
+ var UUID_PATTERN = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
1468
+ function controlFor(c) {
1469
+ if (c.enumValues?.length) return "select";
1470
+ if (c.tsType === "boolean") return "checkbox";
1471
+ if (c.tsType === "number" || c.tsType === "bigint") return "number";
1472
+ if (c.tsType === "Date") return "datetime-local";
1473
+ return "text";
1474
+ }
1475
+ function boundsFor(c, checks) {
1476
+ let lo = c.min !== void 0 ? { value: c.min, exclusive: false } : void 0;
1477
+ let hi = c.max !== void 0 ? { value: c.max, exclusive: false } : void 0;
1478
+ for (const k of checks) {
1479
+ if (k.column !== c.name || k.kind !== "number") continue;
1480
+ if (k.operator === ">=") lo = { value: k.value, exclusive: false };
1481
+ else if (k.operator === ">") lo = { value: k.value, exclusive: true };
1482
+ else if (k.operator === "<=") hi = { value: k.value, exclusive: false };
1483
+ else if (k.operator === "<") hi = { value: k.value, exclusive: true };
1484
+ }
1485
+ return {
1486
+ ...lo ? { min: lo.value, ...lo.exclusive ? { exclusiveMin: true } : {} } : {},
1487
+ ...hi ? { max: hi.value, ...hi.exclusive ? { exclusiveMax: true } : {} } : {}
1488
+ };
1489
+ }
1490
+ function fieldFacts(table) {
1491
+ const parsed = (table.checks ?? []).map(
1492
+ (k) => parseCheck(k.expression, k.name, table.dialect)
1493
+ );
1494
+ const ok = parsed.filter((p) => !!p && p.ok);
1495
+ const columnChecks = ok.flatMap((p) => p.checks ?? []);
1496
+ const lengthChecks = ok.flatMap((p) => p.lengths ?? []);
1497
+ return table.columns.filter((c) => !c.isGenerated).map((c) => ({
1498
+ name: c.name,
1499
+ control: controlFor(c),
1500
+ // A column the database fills is not one a form has to supply, which is the insert-side
1501
+ // question a form asks. `nullable` is reported beside it for a reader asking the other one.
1502
+ required: !c.nullable && !c.hasDefault,
1503
+ nullable: c.nullable,
1504
+ ...(() => {
1505
+ const declared = c.maxLength;
1506
+ const fromCheck = lengthChecks.filter(
1507
+ (l) => l.column === c.name && (l.unit ?? "characters") === "characters" && (l.operator === "<=" || l.operator === "<")
1508
+ ).map((l) => l.operator === "<" ? Number(l.value) - 1 : Number(l.value)).filter((n) => Number.isFinite(n));
1509
+ const all = [...declared !== void 0 ? [declared] : [], ...fromCheck];
1510
+ return all.length ? { maxLength: Math.min(...all) } : {};
1511
+ })(),
1512
+ ...boundsFor(c, columnChecks),
1513
+ ...c.integer ? { integer: true } : {},
1514
+ ...c.enumValues?.length ? { options: [...c.enumValues] } : {},
1515
+ ...c.format === "uuid" ? { pattern: UUID_PATTERN } : {},
1516
+ ...c.defaultValue !== void 0 ? { defaultValue: c.defaultValue } : {}
1517
+ }));
1518
+ }
1519
+
1462
1520
  // src/files.ts
1463
1521
  var import_node_fs = __toESM(require("fs"), 1);
1464
1522
  var import_node_path = __toESM(require("path"), 1);
@@ -1519,6 +1577,11 @@ function withTsExtension(p) {
1519
1577
  }
1520
1578
 
1521
1579
  // src/meta.ts
1580
+ function metaSchemaId(table, mode) {
1581
+ const qualified = table.schema ? `${table.schema}_${table.name}` : table.name;
1582
+ const safe = qualified.replace(/[^A-Za-z0-9_]/g, "_");
1583
+ return `${safe}${mode.charAt(0).toUpperCase()}${mode.slice(1)}`;
1584
+ }
1522
1585
  function classifyChecks(table) {
1523
1586
  const perColumn = /* @__PURE__ */ new Map();
1524
1587
  const rows = [];
@@ -1577,6 +1640,7 @@ function tableMetaFacts(table, opts) {
1577
1640
  const pk = table.primaryKey?.columns ?? [];
1578
1641
  const unique = (table.unique ?? []).map((k) => k.columns).filter((c) => c.length > 0);
1579
1642
  const facts = {
1643
+ ...opts.id ? { id: opts.id } : {},
1580
1644
  table: table.name,
1581
1645
  ...table.schema ? { schema: table.schema } : {},
1582
1646
  ...opts.dialect ? { dialect: opts.dialect } : {},
@@ -1802,6 +1866,15 @@ function parsesToADate(expr) {
1802
1866
  }
1803
1867
  var CODEPOINT_LENGTH = "[...v].length";
1804
1868
  var BIGINT_DIGITS_PATTERN = String.raw`/^-?\d+$/`;
1869
+ var JSON_VALUE_TS_CONST = "DrzlJsonValue";
1870
+ var JSON_VALUE_TS_SOURCE = `type ${JSON_VALUE_TS_CONST} =
1871
+ | string
1872
+ | number
1873
+ | boolean
1874
+ | null
1875
+ | ${JSON_VALUE_TS_CONST}[]
1876
+ | { [key: string]: ${JSON_VALUE_TS_CONST} };
1877
+ `;
1805
1878
  var VALIBOT_JSON_CONST = "DrzlJsonValue";
1806
1879
  var VALIBOT_JSON_SOURCE = `type ${VALIBOT_JSON_CONST}Type =
1807
1880
  | string
@@ -2005,6 +2078,8 @@ async function formatCode(code, filePath, fmt) {
2005
2078
  DEFAULT_SCHEMA_SUFFIX,
2006
2079
  DEFAULT_TYPE_SUFFIX,
2007
2080
  IMPORT_EXTENSIONS,
2081
+ JSON_VALUE_TS_CONST,
2082
+ JSON_VALUE_TS_SOURCE,
2008
2083
  MAX_NESTED_DEPTH,
2009
2084
  NAME_MODES,
2010
2085
  NESTED_PREFIX,
@@ -2023,6 +2098,7 @@ async function formatCode(code, filePath, fmt) {
2023
2098
  columnMetaFacts,
2024
2099
  comparisonWire,
2025
2100
  describeSet,
2101
+ fieldFacts,
2026
2102
  fileWriter,
2027
2103
  formatCode,
2028
2104
  importSpecifier,
@@ -2034,6 +2110,7 @@ async function formatCode(code, filePath, fmt) {
2034
2110
  lengthMeasure,
2035
2111
  measureCompare,
2036
2112
  measureExpression,
2113
+ metaSchemaId,
2037
2114
  moduleFileName,
2038
2115
  moduleSpecifier,
2039
2116
  needsNumericCanon,
package/dist/index.d.cts CHANGED
@@ -966,6 +966,65 @@ declare const CONSTRAINTS_MODULE = "constraints.ts";
966
966
  */
967
967
  declare function renderConstraintsModule(tables: Table$1[], opts?: ConstraintsModuleOptions): string;
968
968
 
969
+ /**
970
+ * What one column is, in the terms a form control needs.
971
+ *
972
+ * Every validation generator already folds a table's `CHECK` constraints into the bounds it emits,
973
+ * and every one of them does it with a private copy of the same loop. A form generator reading
974
+ * `Column.min` and `Column.max` directly would be a fourth derivation and a wrong one: measured
975
+ * 2026-08-12, a column carrying `check('adult', age >= 18)` still reports
976
+ * `min: '-2147483648'`, the plain int32 range, because the analyzer leaves checks on the table and
977
+ * the generators fold them at emit time. An input rendered from that would carry
978
+ * `min="-2147483648"`, which looks like a bound and is not one.
979
+ *
980
+ * So the fold lives here, beside `classifyTableChecks` and `tableConstraints`, which are already
981
+ * the shared home for the same question. `fieldFacts` is what a form generator reads, and what the
982
+ * emitted schema's own bounds agree with by construction rather than by coincidence.
983
+ */
984
+ interface FieldFacts {
985
+ /** The column, under the TypeScript name a generated schema spells. */
986
+ name: string;
987
+ /** The input type a control should use, derived from the column's TypeScript type. */
988
+ control: 'text' | 'number' | 'checkbox' | 'datetime-local' | 'select';
989
+ /**
990
+ * Whether a value has to be supplied on insert.
991
+ *
992
+ * False for a nullable column and for one carrying a default, since the database fills it. This
993
+ * is the insert-side answer; a select-side reader wants `nullable` instead.
994
+ */
995
+ required: boolean;
996
+ nullable: boolean;
997
+ /** `maxlength`, from a declared width. Absent on an unbounded text column. */
998
+ maxLength?: number;
999
+ /**
1000
+ * `min` and `max`, as text.
1001
+ *
1002
+ * Text because a 64 bit bound is not representable as a JS number, which is the same reason the
1003
+ * analyzer reports them that way. Narrowed by any `CHECK` on the column: a check replaces the end
1004
+ * it constrains rather than sitting beside it, and it can never widen, because the declared range
1005
+ * is the column's type and no check makes an int32 hold more.
1006
+ */
1007
+ min?: string;
1008
+ max?: string;
1009
+ /** Whether either bound is exclusive, which HTML cannot express and a validator can. */
1010
+ exclusiveMin?: boolean;
1011
+ exclusiveMax?: boolean;
1012
+ /** `step="1"` for an integer column. */
1013
+ integer?: boolean;
1014
+ /** The options a `<select>` should carry, from a native enum or a declared set. */
1015
+ options?: string[];
1016
+ /** A `pattern` where the column's format implies one and HTML can state it. */
1017
+ pattern?: string;
1018
+ /** The default the database applies, for a form's initial value. */
1019
+ defaultValue?: unknown;
1020
+ }
1021
+ /**
1022
+ * Every column of a table, as the facts a form control needs.
1023
+ *
1024
+ * Generated columns are left out: the database computes them and no form supplies one.
1025
+ */
1026
+ declare function fieldFacts(table: Table$1): FieldFacts[];
1027
+
969
1028
  /**
970
1029
  * The facts a generated schema can carry beside itself.
971
1030
  *
@@ -1037,6 +1096,15 @@ interface ColumnMetaFacts {
1037
1096
  }
1038
1097
  /** What a table's schema adds beside itself. */
1039
1098
  interface TableMetaFacts {
1099
+ /**
1100
+ * The registry id, present only when asked for.
1101
+ *
1102
+ * zod reads this key out of the same object and registers the schema under it, which is what
1103
+ * makes `z.toJSONSchema` emit a `$ref` instead of inlining a copy, and what gives
1104
+ * `z.toJSONSchema(z.globalRegistry)` a name to file each schema under. See `metaSchemaId` for why
1105
+ * it is built from the qualified table name.
1106
+ */
1107
+ id?: string;
1040
1108
  /** The SQL table name, which is not the Drizzle export name the schema is named after. */
1041
1109
  table: string;
1042
1110
  /**
@@ -1085,7 +1153,33 @@ interface MetaFactOptions {
1085
1153
  interface TableMetaOptions extends MetaFactOptions {
1086
1154
  mode: string;
1087
1155
  dialect?: string;
1156
+ /**
1157
+ * Also give the schema an `id`, which is what puts it in zod's registry under a name.
1158
+ *
1159
+ * Off by default, because it is the one metadata key with a failure mode. Measured against zod
1160
+ * 4.4.3: an `id` makes `z.toJSONSchema` emit `$ref: '#/$defs/<id>'` wherever one schema references
1161
+ * another, and `z.toJSONSchema(z.globalRegistry)` return a `{ schemas: { <id>: ... } }` bundle,
1162
+ * which is what makes a generated document self-describing. But two schemas sharing an id do not
1163
+ * report the collision: measured, `z.toJSONSchema(registry)` keeps the last one and **silently
1164
+ * drops the other**. Two tables, one entry, no warning, and nothing a consumer can check.
1165
+ *
1166
+ * So the id has to be unique across the whole analysis rather than within a file, which is why
1167
+ * `metaSchemaId` builds it from the qualified table name.
1168
+ */
1169
+ id?: string;
1088
1170
  }
1171
+ /**
1172
+ * The registry id for one table's schema, unique across an analysis.
1173
+ *
1174
+ * Built from the qualified name, because two tables in two SQL schemas share a bare one and zod
1175
+ * does not report the collision: a registry dump silently keeps one of them. `reporting.users` becomes
1176
+ * `reporting_usersSelect`; a table in the default schema keeps the short `usersSelect`, so the
1177
+ * common case reads the way anybody would have written it by hand.
1178
+ *
1179
+ * Anything outside `[A-Za-z0-9_]` becomes `_`, because the id lands in a `$ref` as a JSON Pointer
1180
+ * segment and a `/` or a `~` there means something else entirely.
1181
+ */
1182
+ declare function metaSchemaId(table: Table$1, mode: string): string;
1089
1183
  /**
1090
1184
  * The metadata for one column.
1091
1185
  *
@@ -1575,6 +1669,22 @@ declare const CODEPOINT_LENGTH = "[...v].length";
1575
1669
  * it as its decimal digits, and they all spell it the same way from here.
1576
1670
  */
1577
1671
  declare const BIGINT_DIGITS_PATTERN: string;
1672
+ /** The name of the TypeScript alias a module declares for the json value space. */
1673
+ declare const JSON_VALUE_TS_CONST = "DrzlJsonValue";
1674
+ /**
1675
+ * The json value space as a TypeScript type, for the generators that emit types rather than
1676
+ * schemas: the service classes, the Fastify row types, the GraphQL resolver types and the NestJS
1677
+ * DTO fields.
1678
+ *
1679
+ * They all used to say `unknown` for a json column, or `any` in one case, while every validator
1680
+ * generator described the same column properly. A row type that says `unknown` makes the caller
1681
+ * cast to read a value the database guarantees is json, and one that says `any` is worse: it turns
1682
+ * the check off silently.
1683
+ *
1684
+ * Verified mutually assignable with zod's own `z.json()` output type, so a DTO field declared with
1685
+ * this satisfies a `StandardSchema<Dto>` static built from that schema.
1686
+ */
1687
+ declare const JSON_VALUE_TS_SOURCE = "type DrzlJsonValue =\n | string\n | number\n | boolean\n | null\n | DrzlJsonValue[]\n | { [key: string]: DrzlJsonValue };\n";
1578
1688
  /** The name of the recursive JSON schema a valibot module defines when it has a json column. */
1579
1689
  declare const VALIBOT_JSON_CONST = "DrzlJsonValue";
1580
1690
  /**
@@ -1720,4 +1830,4 @@ declare function isProjectInstallPath(manifestPath: string): boolean;
1720
1830
  */
1721
1831
  declare function formatCode(code: string, filePath: string, fmt?: FormatOptions): Promise<any>;
1722
1832
 
1723
- export { AFFIX_PREFIX_PATTERN, AFFIX_PROBE_TABLE, AFFIX_SUFFIX_PATTERN, type AffixIssue, type AffixOptions, type AffixValue, BIGINT_DIGITS_PATTERN, type BrandAlias, type BrandPlan, type BrandingOption, type BrandingOptions, CODEPOINT_LENGTH, COERCIBLE_DATE_STRING, COLUMN_FORMATS, CONSTRAINTS_MODULE, type CardinalityCheck, type CheckPart, type CheckPartPlace, type ClassifiedCheck, type ColumnCheck, type ColumnMetaFacts, type ColumnSet, type ComparisonWire, type ConstraintFacts, type ConstraintKind, type ConstraintsModuleOptions, type ConstraintsOption, DEFAULT_IMPORT_EXTENSION, DEFAULT_MODE_PREFIX, DEFAULT_NESTED_DEPTH, DEFAULT_SCHEMA_SUFFIX, DEFAULT_TYPE_SUFFIX, type FileSink, type FormatOptions, type GeneratorFs, IMPORT_EXTENSIONS, type ImportExtension, type LengthCheck, type LengthMeasure, MAX_NESTED_DEPTH, type MetaFactOptions, NAME_MODES, NESTED_PREFIX, NUMERIC_CANON_NAME, NUMERIC_CANON_SOURCE, type NameMode, type NestedArm, type NestedMode, type NestedNode, type NullCheck, type ParsedCheck, type ResolvedAffix, type ResolvedBranding, type RowCheck, type Table, type TableCase, type TableConstraints, type TableMetaFacts, type TableMetaOptions, type UnenforcedLiteral, type UnenforcedPart, VALIBOT_JSON_CONST, VALIBOT_JSON_SOURCE, type ValidationGenerateOptions, type ValidationLibrary, type ValidationRenderer, type WireLiteralFit, type WireLiteralQuestion, applyTableCase, applyWirePolicy, buildBrandPlan, buildNestedPlan, canonicalMembers, canonicalNumericText, classifyTableChecks, codePointCompare, columnMetaFacts, comparisonWire, describeSet, fileWriter, formatCode, importSpecifier, insertColumns, isGeneratedColumn, isIntegerColumn, isProjectInstallPath, lengthCheckLabel, lengthMeasure, measureCompare, measureExpression, moduleFileName, moduleSpecifier, needsNumericCanon, nestedArmNotes, nestedNodeColumns, nestedSchemaName, nestedTypeName, nonFiniteAccepted, nonFiniteRefused, parseCheck, parsesToADate, pascalCase, renderConstraintsModule, renderDuplicateFinder, resolveAffix, resolveBranding, resolveConfiguredImport, resolveConstraints, resolveNestedDepth, schemaName, selectColumns, tableConstraints, tableMetaFacts, typeName, updateColumns, validateAffix, wireLiteralFit, wireNumberLiteral };
1833
+ export { AFFIX_PREFIX_PATTERN, AFFIX_PROBE_TABLE, AFFIX_SUFFIX_PATTERN, type AffixIssue, type AffixOptions, type AffixValue, BIGINT_DIGITS_PATTERN, type BrandAlias, type BrandPlan, type BrandingOption, type BrandingOptions, CODEPOINT_LENGTH, COERCIBLE_DATE_STRING, COLUMN_FORMATS, CONSTRAINTS_MODULE, type CardinalityCheck, type CheckPart, type CheckPartPlace, type ClassifiedCheck, type ColumnCheck, type ColumnMetaFacts, type ColumnSet, type ComparisonWire, type ConstraintFacts, type ConstraintKind, type ConstraintsModuleOptions, type ConstraintsOption, DEFAULT_IMPORT_EXTENSION, DEFAULT_MODE_PREFIX, DEFAULT_NESTED_DEPTH, DEFAULT_SCHEMA_SUFFIX, DEFAULT_TYPE_SUFFIX, type FieldFacts, type FileSink, type FormatOptions, type GeneratorFs, IMPORT_EXTENSIONS, type ImportExtension, JSON_VALUE_TS_CONST, JSON_VALUE_TS_SOURCE, type LengthCheck, type LengthMeasure, MAX_NESTED_DEPTH, type MetaFactOptions, NAME_MODES, NESTED_PREFIX, NUMERIC_CANON_NAME, NUMERIC_CANON_SOURCE, type NameMode, type NestedArm, type NestedMode, type NestedNode, type NullCheck, type ParsedCheck, type ResolvedAffix, type ResolvedBranding, type RowCheck, type Table, type TableCase, type TableConstraints, type TableMetaFacts, type TableMetaOptions, type UnenforcedLiteral, type UnenforcedPart, VALIBOT_JSON_CONST, VALIBOT_JSON_SOURCE, type ValidationGenerateOptions, type ValidationLibrary, type ValidationRenderer, type WireLiteralFit, type WireLiteralQuestion, applyTableCase, applyWirePolicy, buildBrandPlan, buildNestedPlan, canonicalMembers, canonicalNumericText, classifyTableChecks, codePointCompare, columnMetaFacts, comparisonWire, describeSet, fieldFacts, fileWriter, formatCode, importSpecifier, insertColumns, isGeneratedColumn, isIntegerColumn, isProjectInstallPath, lengthCheckLabel, lengthMeasure, measureCompare, measureExpression, metaSchemaId, moduleFileName, moduleSpecifier, needsNumericCanon, nestedArmNotes, nestedNodeColumns, nestedSchemaName, nestedTypeName, nonFiniteAccepted, nonFiniteRefused, parseCheck, parsesToADate, pascalCase, renderConstraintsModule, renderDuplicateFinder, resolveAffix, resolveBranding, resolveConfiguredImport, resolveConstraints, resolveNestedDepth, schemaName, selectColumns, tableConstraints, tableMetaFacts, typeName, updateColumns, validateAffix, wireLiteralFit, wireNumberLiteral };
package/dist/index.d.ts CHANGED
@@ -966,6 +966,65 @@ declare const CONSTRAINTS_MODULE = "constraints.ts";
966
966
  */
967
967
  declare function renderConstraintsModule(tables: Table$1[], opts?: ConstraintsModuleOptions): string;
968
968
 
969
+ /**
970
+ * What one column is, in the terms a form control needs.
971
+ *
972
+ * Every validation generator already folds a table's `CHECK` constraints into the bounds it emits,
973
+ * and every one of them does it with a private copy of the same loop. A form generator reading
974
+ * `Column.min` and `Column.max` directly would be a fourth derivation and a wrong one: measured
975
+ * 2026-08-12, a column carrying `check('adult', age >= 18)` still reports
976
+ * `min: '-2147483648'`, the plain int32 range, because the analyzer leaves checks on the table and
977
+ * the generators fold them at emit time. An input rendered from that would carry
978
+ * `min="-2147483648"`, which looks like a bound and is not one.
979
+ *
980
+ * So the fold lives here, beside `classifyTableChecks` and `tableConstraints`, which are already
981
+ * the shared home for the same question. `fieldFacts` is what a form generator reads, and what the
982
+ * emitted schema's own bounds agree with by construction rather than by coincidence.
983
+ */
984
+ interface FieldFacts {
985
+ /** The column, under the TypeScript name a generated schema spells. */
986
+ name: string;
987
+ /** The input type a control should use, derived from the column's TypeScript type. */
988
+ control: 'text' | 'number' | 'checkbox' | 'datetime-local' | 'select';
989
+ /**
990
+ * Whether a value has to be supplied on insert.
991
+ *
992
+ * False for a nullable column and for one carrying a default, since the database fills it. This
993
+ * is the insert-side answer; a select-side reader wants `nullable` instead.
994
+ */
995
+ required: boolean;
996
+ nullable: boolean;
997
+ /** `maxlength`, from a declared width. Absent on an unbounded text column. */
998
+ maxLength?: number;
999
+ /**
1000
+ * `min` and `max`, as text.
1001
+ *
1002
+ * Text because a 64 bit bound is not representable as a JS number, which is the same reason the
1003
+ * analyzer reports them that way. Narrowed by any `CHECK` on the column: a check replaces the end
1004
+ * it constrains rather than sitting beside it, and it can never widen, because the declared range
1005
+ * is the column's type and no check makes an int32 hold more.
1006
+ */
1007
+ min?: string;
1008
+ max?: string;
1009
+ /** Whether either bound is exclusive, which HTML cannot express and a validator can. */
1010
+ exclusiveMin?: boolean;
1011
+ exclusiveMax?: boolean;
1012
+ /** `step="1"` for an integer column. */
1013
+ integer?: boolean;
1014
+ /** The options a `<select>` should carry, from a native enum or a declared set. */
1015
+ options?: string[];
1016
+ /** A `pattern` where the column's format implies one and HTML can state it. */
1017
+ pattern?: string;
1018
+ /** The default the database applies, for a form's initial value. */
1019
+ defaultValue?: unknown;
1020
+ }
1021
+ /**
1022
+ * Every column of a table, as the facts a form control needs.
1023
+ *
1024
+ * Generated columns are left out: the database computes them and no form supplies one.
1025
+ */
1026
+ declare function fieldFacts(table: Table$1): FieldFacts[];
1027
+
969
1028
  /**
970
1029
  * The facts a generated schema can carry beside itself.
971
1030
  *
@@ -1037,6 +1096,15 @@ interface ColumnMetaFacts {
1037
1096
  }
1038
1097
  /** What a table's schema adds beside itself. */
1039
1098
  interface TableMetaFacts {
1099
+ /**
1100
+ * The registry id, present only when asked for.
1101
+ *
1102
+ * zod reads this key out of the same object and registers the schema under it, which is what
1103
+ * makes `z.toJSONSchema` emit a `$ref` instead of inlining a copy, and what gives
1104
+ * `z.toJSONSchema(z.globalRegistry)` a name to file each schema under. See `metaSchemaId` for why
1105
+ * it is built from the qualified table name.
1106
+ */
1107
+ id?: string;
1040
1108
  /** The SQL table name, which is not the Drizzle export name the schema is named after. */
1041
1109
  table: string;
1042
1110
  /**
@@ -1085,7 +1153,33 @@ interface MetaFactOptions {
1085
1153
  interface TableMetaOptions extends MetaFactOptions {
1086
1154
  mode: string;
1087
1155
  dialect?: string;
1156
+ /**
1157
+ * Also give the schema an `id`, which is what puts it in zod's registry under a name.
1158
+ *
1159
+ * Off by default, because it is the one metadata key with a failure mode. Measured against zod
1160
+ * 4.4.3: an `id` makes `z.toJSONSchema` emit `$ref: '#/$defs/<id>'` wherever one schema references
1161
+ * another, and `z.toJSONSchema(z.globalRegistry)` return a `{ schemas: { <id>: ... } }` bundle,
1162
+ * which is what makes a generated document self-describing. But two schemas sharing an id do not
1163
+ * report the collision: measured, `z.toJSONSchema(registry)` keeps the last one and **silently
1164
+ * drops the other**. Two tables, one entry, no warning, and nothing a consumer can check.
1165
+ *
1166
+ * So the id has to be unique across the whole analysis rather than within a file, which is why
1167
+ * `metaSchemaId` builds it from the qualified table name.
1168
+ */
1169
+ id?: string;
1088
1170
  }
1171
+ /**
1172
+ * The registry id for one table's schema, unique across an analysis.
1173
+ *
1174
+ * Built from the qualified name, because two tables in two SQL schemas share a bare one and zod
1175
+ * does not report the collision: a registry dump silently keeps one of them. `reporting.users` becomes
1176
+ * `reporting_usersSelect`; a table in the default schema keeps the short `usersSelect`, so the
1177
+ * common case reads the way anybody would have written it by hand.
1178
+ *
1179
+ * Anything outside `[A-Za-z0-9_]` becomes `_`, because the id lands in a `$ref` as a JSON Pointer
1180
+ * segment and a `/` or a `~` there means something else entirely.
1181
+ */
1182
+ declare function metaSchemaId(table: Table$1, mode: string): string;
1089
1183
  /**
1090
1184
  * The metadata for one column.
1091
1185
  *
@@ -1575,6 +1669,22 @@ declare const CODEPOINT_LENGTH = "[...v].length";
1575
1669
  * it as its decimal digits, and they all spell it the same way from here.
1576
1670
  */
1577
1671
  declare const BIGINT_DIGITS_PATTERN: string;
1672
+ /** The name of the TypeScript alias a module declares for the json value space. */
1673
+ declare const JSON_VALUE_TS_CONST = "DrzlJsonValue";
1674
+ /**
1675
+ * The json value space as a TypeScript type, for the generators that emit types rather than
1676
+ * schemas: the service classes, the Fastify row types, the GraphQL resolver types and the NestJS
1677
+ * DTO fields.
1678
+ *
1679
+ * They all used to say `unknown` for a json column, or `any` in one case, while every validator
1680
+ * generator described the same column properly. A row type that says `unknown` makes the caller
1681
+ * cast to read a value the database guarantees is json, and one that says `any` is worse: it turns
1682
+ * the check off silently.
1683
+ *
1684
+ * Verified mutually assignable with zod's own `z.json()` output type, so a DTO field declared with
1685
+ * this satisfies a `StandardSchema<Dto>` static built from that schema.
1686
+ */
1687
+ declare const JSON_VALUE_TS_SOURCE = "type DrzlJsonValue =\n | string\n | number\n | boolean\n | null\n | DrzlJsonValue[]\n | { [key: string]: DrzlJsonValue };\n";
1578
1688
  /** The name of the recursive JSON schema a valibot module defines when it has a json column. */
1579
1689
  declare const VALIBOT_JSON_CONST = "DrzlJsonValue";
1580
1690
  /**
@@ -1720,4 +1830,4 @@ declare function isProjectInstallPath(manifestPath: string): boolean;
1720
1830
  */
1721
1831
  declare function formatCode(code: string, filePath: string, fmt?: FormatOptions): Promise<any>;
1722
1832
 
1723
- export { AFFIX_PREFIX_PATTERN, AFFIX_PROBE_TABLE, AFFIX_SUFFIX_PATTERN, type AffixIssue, type AffixOptions, type AffixValue, BIGINT_DIGITS_PATTERN, type BrandAlias, type BrandPlan, type BrandingOption, type BrandingOptions, CODEPOINT_LENGTH, COERCIBLE_DATE_STRING, COLUMN_FORMATS, CONSTRAINTS_MODULE, type CardinalityCheck, type CheckPart, type CheckPartPlace, type ClassifiedCheck, type ColumnCheck, type ColumnMetaFacts, type ColumnSet, type ComparisonWire, type ConstraintFacts, type ConstraintKind, type ConstraintsModuleOptions, type ConstraintsOption, DEFAULT_IMPORT_EXTENSION, DEFAULT_MODE_PREFIX, DEFAULT_NESTED_DEPTH, DEFAULT_SCHEMA_SUFFIX, DEFAULT_TYPE_SUFFIX, type FileSink, type FormatOptions, type GeneratorFs, IMPORT_EXTENSIONS, type ImportExtension, type LengthCheck, type LengthMeasure, MAX_NESTED_DEPTH, type MetaFactOptions, NAME_MODES, NESTED_PREFIX, NUMERIC_CANON_NAME, NUMERIC_CANON_SOURCE, type NameMode, type NestedArm, type NestedMode, type NestedNode, type NullCheck, type ParsedCheck, type ResolvedAffix, type ResolvedBranding, type RowCheck, type Table, type TableCase, type TableConstraints, type TableMetaFacts, type TableMetaOptions, type UnenforcedLiteral, type UnenforcedPart, VALIBOT_JSON_CONST, VALIBOT_JSON_SOURCE, type ValidationGenerateOptions, type ValidationLibrary, type ValidationRenderer, type WireLiteralFit, type WireLiteralQuestion, applyTableCase, applyWirePolicy, buildBrandPlan, buildNestedPlan, canonicalMembers, canonicalNumericText, classifyTableChecks, codePointCompare, columnMetaFacts, comparisonWire, describeSet, fileWriter, formatCode, importSpecifier, insertColumns, isGeneratedColumn, isIntegerColumn, isProjectInstallPath, lengthCheckLabel, lengthMeasure, measureCompare, measureExpression, moduleFileName, moduleSpecifier, needsNumericCanon, nestedArmNotes, nestedNodeColumns, nestedSchemaName, nestedTypeName, nonFiniteAccepted, nonFiniteRefused, parseCheck, parsesToADate, pascalCase, renderConstraintsModule, renderDuplicateFinder, resolveAffix, resolveBranding, resolveConfiguredImport, resolveConstraints, resolveNestedDepth, schemaName, selectColumns, tableConstraints, tableMetaFacts, typeName, updateColumns, validateAffix, wireLiteralFit, wireNumberLiteral };
1833
+ export { AFFIX_PREFIX_PATTERN, AFFIX_PROBE_TABLE, AFFIX_SUFFIX_PATTERN, type AffixIssue, type AffixOptions, type AffixValue, BIGINT_DIGITS_PATTERN, type BrandAlias, type BrandPlan, type BrandingOption, type BrandingOptions, CODEPOINT_LENGTH, COERCIBLE_DATE_STRING, COLUMN_FORMATS, CONSTRAINTS_MODULE, type CardinalityCheck, type CheckPart, type CheckPartPlace, type ClassifiedCheck, type ColumnCheck, type ColumnMetaFacts, type ColumnSet, type ComparisonWire, type ConstraintFacts, type ConstraintKind, type ConstraintsModuleOptions, type ConstraintsOption, DEFAULT_IMPORT_EXTENSION, DEFAULT_MODE_PREFIX, DEFAULT_NESTED_DEPTH, DEFAULT_SCHEMA_SUFFIX, DEFAULT_TYPE_SUFFIX, type FieldFacts, type FileSink, type FormatOptions, type GeneratorFs, IMPORT_EXTENSIONS, type ImportExtension, JSON_VALUE_TS_CONST, JSON_VALUE_TS_SOURCE, type LengthCheck, type LengthMeasure, MAX_NESTED_DEPTH, type MetaFactOptions, NAME_MODES, NESTED_PREFIX, NUMERIC_CANON_NAME, NUMERIC_CANON_SOURCE, type NameMode, type NestedArm, type NestedMode, type NestedNode, type NullCheck, type ParsedCheck, type ResolvedAffix, type ResolvedBranding, type RowCheck, type Table, type TableCase, type TableConstraints, type TableMetaFacts, type TableMetaOptions, type UnenforcedLiteral, type UnenforcedPart, VALIBOT_JSON_CONST, VALIBOT_JSON_SOURCE, type ValidationGenerateOptions, type ValidationLibrary, type ValidationRenderer, type WireLiteralFit, type WireLiteralQuestion, applyTableCase, applyWirePolicy, buildBrandPlan, buildNestedPlan, canonicalMembers, canonicalNumericText, classifyTableChecks, codePointCompare, columnMetaFacts, comparisonWire, describeSet, fieldFacts, fileWriter, formatCode, importSpecifier, insertColumns, isGeneratedColumn, isIntegerColumn, isProjectInstallPath, lengthCheckLabel, lengthMeasure, measureCompare, measureExpression, metaSchemaId, moduleFileName, moduleSpecifier, needsNumericCanon, nestedArmNotes, nestedNodeColumns, nestedSchemaName, nestedTypeName, nonFiniteAccepted, nonFiniteRefused, parseCheck, parsesToADate, pascalCase, renderConstraintsModule, renderDuplicateFinder, resolveAffix, resolveBranding, resolveConfiguredImport, resolveConstraints, resolveNestedDepth, schemaName, selectColumns, tableConstraints, tableMetaFacts, typeName, updateColumns, validateAffix, wireLiteralFit, wireNumberLiteral };
package/dist/index.js CHANGED
@@ -1355,6 +1355,60 @@ function fileWriter(sink) {
1355
1355
  };
1356
1356
  }
1357
1357
 
1358
+ // src/fields.ts
1359
+ var UUID_PATTERN = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
1360
+ function controlFor(c) {
1361
+ if (c.enumValues?.length) return "select";
1362
+ if (c.tsType === "boolean") return "checkbox";
1363
+ if (c.tsType === "number" || c.tsType === "bigint") return "number";
1364
+ if (c.tsType === "Date") return "datetime-local";
1365
+ return "text";
1366
+ }
1367
+ function boundsFor(c, checks) {
1368
+ let lo = c.min !== void 0 ? { value: c.min, exclusive: false } : void 0;
1369
+ let hi = c.max !== void 0 ? { value: c.max, exclusive: false } : void 0;
1370
+ for (const k of checks) {
1371
+ if (k.column !== c.name || k.kind !== "number") continue;
1372
+ if (k.operator === ">=") lo = { value: k.value, exclusive: false };
1373
+ else if (k.operator === ">") lo = { value: k.value, exclusive: true };
1374
+ else if (k.operator === "<=") hi = { value: k.value, exclusive: false };
1375
+ else if (k.operator === "<") hi = { value: k.value, exclusive: true };
1376
+ }
1377
+ return {
1378
+ ...lo ? { min: lo.value, ...lo.exclusive ? { exclusiveMin: true } : {} } : {},
1379
+ ...hi ? { max: hi.value, ...hi.exclusive ? { exclusiveMax: true } : {} } : {}
1380
+ };
1381
+ }
1382
+ function fieldFacts(table) {
1383
+ const parsed = (table.checks ?? []).map(
1384
+ (k) => parseCheck(k.expression, k.name, table.dialect)
1385
+ );
1386
+ const ok = parsed.filter((p) => !!p && p.ok);
1387
+ const columnChecks = ok.flatMap((p) => p.checks ?? []);
1388
+ const lengthChecks = ok.flatMap((p) => p.lengths ?? []);
1389
+ return table.columns.filter((c) => !c.isGenerated).map((c) => ({
1390
+ name: c.name,
1391
+ control: controlFor(c),
1392
+ // A column the database fills is not one a form has to supply, which is the insert-side
1393
+ // question a form asks. `nullable` is reported beside it for a reader asking the other one.
1394
+ required: !c.nullable && !c.hasDefault,
1395
+ nullable: c.nullable,
1396
+ ...(() => {
1397
+ const declared = c.maxLength;
1398
+ const fromCheck = lengthChecks.filter(
1399
+ (l) => l.column === c.name && (l.unit ?? "characters") === "characters" && (l.operator === "<=" || l.operator === "<")
1400
+ ).map((l) => l.operator === "<" ? Number(l.value) - 1 : Number(l.value)).filter((n) => Number.isFinite(n));
1401
+ const all = [...declared !== void 0 ? [declared] : [], ...fromCheck];
1402
+ return all.length ? { maxLength: Math.min(...all) } : {};
1403
+ })(),
1404
+ ...boundsFor(c, columnChecks),
1405
+ ...c.integer ? { integer: true } : {},
1406
+ ...c.enumValues?.length ? { options: [...c.enumValues] } : {},
1407
+ ...c.format === "uuid" ? { pattern: UUID_PATTERN } : {},
1408
+ ...c.defaultValue !== void 0 ? { defaultValue: c.defaultValue } : {}
1409
+ }));
1410
+ }
1411
+
1358
1412
  // src/files.ts
1359
1413
  import nodeFs2 from "fs";
1360
1414
  import nodePath from "path";
@@ -1415,6 +1469,11 @@ function withTsExtension(p) {
1415
1469
  }
1416
1470
 
1417
1471
  // src/meta.ts
1472
+ function metaSchemaId(table, mode) {
1473
+ const qualified = table.schema ? `${table.schema}_${table.name}` : table.name;
1474
+ const safe = qualified.replace(/[^A-Za-z0-9_]/g, "_");
1475
+ return `${safe}${mode.charAt(0).toUpperCase()}${mode.slice(1)}`;
1476
+ }
1418
1477
  function classifyChecks(table) {
1419
1478
  const perColumn = /* @__PURE__ */ new Map();
1420
1479
  const rows = [];
@@ -1473,6 +1532,7 @@ function tableMetaFacts(table, opts) {
1473
1532
  const pk = table.primaryKey?.columns ?? [];
1474
1533
  const unique = (table.unique ?? []).map((k) => k.columns).filter((c) => c.length > 0);
1475
1534
  const facts = {
1535
+ ...opts.id ? { id: opts.id } : {},
1476
1536
  table: table.name,
1477
1537
  ...table.schema ? { schema: table.schema } : {},
1478
1538
  ...opts.dialect ? { dialect: opts.dialect } : {},
@@ -1698,6 +1758,15 @@ function parsesToADate(expr) {
1698
1758
  }
1699
1759
  var CODEPOINT_LENGTH = "[...v].length";
1700
1760
  var BIGINT_DIGITS_PATTERN = String.raw`/^-?\d+$/`;
1761
+ var JSON_VALUE_TS_CONST = "DrzlJsonValue";
1762
+ var JSON_VALUE_TS_SOURCE = `type ${JSON_VALUE_TS_CONST} =
1763
+ | string
1764
+ | number
1765
+ | boolean
1766
+ | null
1767
+ | ${JSON_VALUE_TS_CONST}[]
1768
+ | { [key: string]: ${JSON_VALUE_TS_CONST} };
1769
+ `;
1701
1770
  var VALIBOT_JSON_CONST = "DrzlJsonValue";
1702
1771
  var VALIBOT_JSON_SOURCE = `type ${VALIBOT_JSON_CONST}Type =
1703
1772
  | string
@@ -1900,6 +1969,8 @@ export {
1900
1969
  DEFAULT_SCHEMA_SUFFIX,
1901
1970
  DEFAULT_TYPE_SUFFIX,
1902
1971
  IMPORT_EXTENSIONS,
1972
+ JSON_VALUE_TS_CONST,
1973
+ JSON_VALUE_TS_SOURCE,
1903
1974
  MAX_NESTED_DEPTH,
1904
1975
  NAME_MODES,
1905
1976
  NESTED_PREFIX,
@@ -1918,6 +1989,7 @@ export {
1918
1989
  columnMetaFacts,
1919
1990
  comparisonWire,
1920
1991
  describeSet,
1992
+ fieldFacts,
1921
1993
  fileWriter,
1922
1994
  formatCode,
1923
1995
  importSpecifier,
@@ -1929,6 +2001,7 @@ export {
1929
2001
  lengthMeasure,
1930
2002
  measureCompare,
1931
2003
  measureExpression,
2004
+ metaSchemaId,
1932
2005
  moduleFileName,
1933
2006
  moduleSpecifier,
1934
2007
  needsNumericCanon,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drzl/validation-core",
3
- "version": "3.22.5",
3
+ "version": "3.23.0",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "sideEffects": false,
26
26
  "dependencies": {
27
- "@drzl/analyzer": "^1.21.5"
27
+ "@drzl/analyzer": "^1.22.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "prettier": ">=3"