@workerstack/database 0.21.0 → 0.22.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.
Files changed (82) hide show
  1. package/README.md +197 -33
  2. package/docs/meta-contract.md +55 -1
  3. package/package.json +1 -6
  4. package/src/codegen/analyzer-types.ts +6 -1
  5. package/src/codegen/annotations.ts +66 -9
  6. package/src/codegen/format-constraints.ts +114 -0
  7. package/src/codegen/generators/dtos/entity-file.ts +2 -1
  8. package/src/codegen/generators/dtos/example-meta.ts +22 -2
  9. package/src/codegen/generators/dtos/field-schema.ts +31 -8
  10. package/src/codegen/generators/dtos/ip-patterns.ts +25 -11
  11. package/src/codegen/generators/dtos/number-range.ts +22 -3
  12. package/src/codegen/generators/dtos/schema-aliases.ts +5 -3
  13. package/src/codegen/generators/dtos/text-format.ts +63 -0
  14. package/src/codegen/generators/dtos/text-schema.ts +49 -2
  15. package/src/codegen/generators/manifest.ts +9 -1
  16. package/src/codegen/runtime/read-comb.ts +71 -3
  17. package/src/codegen/runtime/read-tables.ts +31 -19
  18. package/src/codegen/runtime/snapshot-types.ts +3 -1
  19. package/src/codegen/runtime/to-meta.ts +6 -3
  20. package/src/formats/catalog.ts +255 -0
  21. package/src/formats/iana-time-zone.ts +117 -0
  22. package/src/formats/iso3166-1.ts +78 -0
  23. package/src/formats/iso4217.ts +42 -0
  24. package/src/formats/iso639-1.ts +41 -0
  25. package/src/formats/uts35.ts +143 -0
  26. package/src/index.ts +5 -0
  27. package/src/meta/column.ts +22 -9
  28. package/src/pg/columns.ts +6 -4
  29. package/src/sqlite/columns.ts +6 -4
  30. package/src/sqlite/constraints.ts +120 -6
  31. package/types/codegen/analyzer-types.d.ts +6 -1
  32. package/types/codegen/analyzer-types.d.ts.map +1 -1
  33. package/types/codegen/annotations.d.ts +12 -7
  34. package/types/codegen/annotations.d.ts.map +1 -1
  35. package/types/codegen/format-constraints.d.ts +22 -0
  36. package/types/codegen/format-constraints.d.ts.map +1 -0
  37. package/types/codegen/generators/dtos/entity-file.d.ts.map +1 -1
  38. package/types/codegen/generators/dtos/example-meta.d.ts.map +1 -1
  39. package/types/codegen/generators/dtos/field-schema.d.ts.map +1 -1
  40. package/types/codegen/generators/dtos/ip-patterns.d.ts +7 -0
  41. package/types/codegen/generators/dtos/ip-patterns.d.ts.map +1 -1
  42. package/types/codegen/generators/dtos/number-range.d.ts +8 -0
  43. package/types/codegen/generators/dtos/number-range.d.ts.map +1 -1
  44. package/types/codegen/generators/dtos/schema-aliases.d.ts +1 -1
  45. package/types/codegen/generators/dtos/schema-aliases.d.ts.map +1 -1
  46. package/types/codegen/generators/dtos/text-format.d.ts +31 -0
  47. package/types/codegen/generators/dtos/text-format.d.ts.map +1 -0
  48. package/types/codegen/generators/dtos/text-schema.d.ts +8 -1
  49. package/types/codegen/generators/dtos/text-schema.d.ts.map +1 -1
  50. package/types/codegen/generators/manifest.d.ts +5 -0
  51. package/types/codegen/generators/manifest.d.ts.map +1 -1
  52. package/types/codegen/runtime/read-comb.d.ts +17 -3
  53. package/types/codegen/runtime/read-comb.d.ts.map +1 -1
  54. package/types/codegen/runtime/read-tables.d.ts.map +1 -1
  55. package/types/codegen/runtime/snapshot-types.d.ts +3 -1
  56. package/types/codegen/runtime/snapshot-types.d.ts.map +1 -1
  57. package/types/codegen/runtime/to-meta.d.ts.map +1 -1
  58. package/types/formats/catalog.d.ts +51 -0
  59. package/types/formats/catalog.d.ts.map +1 -0
  60. package/types/formats/iana-time-zone.d.ts +7 -0
  61. package/types/formats/iana-time-zone.d.ts.map +1 -0
  62. package/types/formats/iso3166-1.d.ts +12 -0
  63. package/types/formats/iso3166-1.d.ts.map +1 -0
  64. package/types/formats/iso4217.d.ts +7 -0
  65. package/types/formats/iso4217.d.ts.map +1 -0
  66. package/types/formats/iso639-1.d.ts +6 -0
  67. package/types/formats/iso639-1.d.ts.map +1 -0
  68. package/types/formats/uts35.d.ts +13 -0
  69. package/types/formats/uts35.d.ts.map +1 -0
  70. package/types/index.d.ts +5 -0
  71. package/types/index.d.ts.map +1 -1
  72. package/types/meta/column.d.ts +7 -1
  73. package/types/meta/column.d.ts.map +1 -1
  74. package/types/pg/columns.d.ts +3 -3
  75. package/types/pg/columns.d.ts.map +1 -1
  76. package/types/sqlite/columns.d.ts +3 -3
  77. package/types/sqlite/columns.d.ts.map +1 -1
  78. package/types/sqlite/constraints.d.ts +117 -5
  79. package/types/sqlite/constraints.d.ts.map +1 -1
  80. package/src/sqlite/patterns.ts +0 -19
  81. package/types/sqlite/patterns.d.ts +0 -19
  82. package/types/sqlite/patterns.d.ts.map +0 -1
@@ -0,0 +1,114 @@
1
+ /**
2
+ * A column's `format` checked against its helper and its other keys: a format sets a text's
3
+ * pattern, case, trim and length and an integer's range, so a transform or a bound it already
4
+ * holds beside it is a second rule a reader of the tables file believes; an enum's values are
5
+ * checked against it here, at codegen, since the DTO is the enum itself.
6
+ */
7
+ import {
8
+ FORMATS,
9
+ formatTakes,
10
+ INTEGER_FORMATS,
11
+ INTEGER_SPECS,
12
+ type IntegerFormat,
13
+ TEXT_FORMATS,
14
+ type TextFormat,
15
+ } from "../formats/catalog.ts"
16
+ import type { CombColumnKind } from "../meta/column.ts"
17
+ import type { ConstantValue } from "./analyzer-types.ts"
18
+
19
+ /* the transforms a text declares, each of which a format sets itself */
20
+ const TRANSFORMS = ["lowercase", "trim", "uppercase"] as const
21
+
22
+ /** A bound's side, as a format's range names it in an error */
23
+ type Side = "max" | "min"
24
+
25
+ /** Whether `value` is one of `ids` */
26
+ const isOneOf = <F extends string>(ids: readonly F[], value: ConstantValue): value is F =>
27
+ (ids as readonly ConstantValue[]).includes(value)
28
+
29
+ /** A format's id as an error names it, with the article it is read with: "an iso4217", "a bcp47" */
30
+ const anId = (format: TextFormat): string => `${/^(?:[aeio]|rfc|srgb)/.test(format) ? "an" : "a"} ${format}`
31
+
32
+ /** A text format's length on one side, as an error names it: its fixed length, else that side's */
33
+ function lengthOf(format: TextFormat, side: Side): string {
34
+ const { max, min } = FORMATS[format]
35
+ return min === max ? `length ${min}` : `${side} length ${side === "max" ? max : min}`
36
+ }
37
+
38
+ /**
39
+ * Refuses a declared bound the format's range already holds, or one that leaves no value in it;
40
+ * `named` is the range's side as an error reads it
41
+ */
42
+ function refuseBounds(
43
+ constraints: Readonly<Record<string, ConstantValue>>,
44
+ { least, most, named, place }: { least: number; most: number | null; named: (side: Side) => string; place: string },
45
+ ): void {
46
+ const { max, min } = constraints
47
+ if (typeof max === "number" && most !== null && max > most)
48
+ throw new Error(`${place}: max ${max} is past ${named("max")}: the format holds it`)
49
+ if (typeof max === "number" && max < least)
50
+ throw new Error(`${place}: max ${max} is below ${named("min")}: no value is both`)
51
+ if (typeof min === "number" && most !== null && min > most)
52
+ throw new Error(`${place}: min ${min} is past ${named("max")}: no value is both`)
53
+ if (typeof min === "number" && min < least)
54
+ throw new Error(`${place}: min ${min} is below ${named("min")}: the format holds it`)
55
+ }
56
+
57
+ /** Refuses the first of an enum's values its format does not take as written, naming the standard's form where the format's transforms reach it */
58
+ function refuseEnumValues(format: TextFormat, { place, values }: { place: string; values: readonly string[] }): void {
59
+ const { transform } = FORMATS[format]
60
+ const value = values.find((each) => !formatTakes(format, each))
61
+ if (value === undefined) return
62
+ const written = transform.reduce(
63
+ (each, step) => (step === "trim" ? each.trim() : step === "lowercase" ? each.toLowerCase() : each.toUpperCase()),
64
+ value,
65
+ )
66
+ const hint = formatTakes(format, written) ? `; write the standard's form, ${JSON.stringify(written)}` : ""
67
+ throw new Error(`${place}: ${JSON.stringify(value)} is no ${format} value${hint}`)
68
+ }
69
+
70
+ /** Refuses what a text format already sets: a declared transform, or a length bound outside its range */
71
+ function refuseTextKeys(format: TextFormat, constraints: Readonly<Record<string, ConstantValue>>, place: string): void {
72
+ const transform = TRANSFORMS.find((each) => constraints[each] === true)
73
+ if (transform !== undefined)
74
+ throw new Error(`${place}: ${anId(format)} text takes no ${transform}: the format sets its case and trim`)
75
+ const { max, min } = FORMATS[format]
76
+ refuseBounds(constraints, { least: min, most: max, named: (side) => `${format}'s ${lengthOf(format, side)}`, place })
77
+ }
78
+
79
+ /**
80
+ * The format a column declares, checked against its helper: an id the helper's kind knows (an
81
+ * integer format on c.integer, a text format on c.text and c.enum), no transform or bound the
82
+ * format already sets or that leaves it no value, and every c.enum value one the format takes as
83
+ * written. Null when none is declared; an error at `place` otherwise. The helper key check refuses a
84
+ * format on any other helper first, and a text's kind check one beside a kind
85
+ */
86
+ export function formatOf(
87
+ constraints: Readonly<Record<string, ConstantValue>>,
88
+ { helper, place, values }: { helper: CombColumnKind | null; place: string; values: readonly string[] | null },
89
+ ): IntegerFormat | TextFormat | null {
90
+ const format = constraints["format"]
91
+ if (format === undefined) return null
92
+ const integer = helper === "integer"
93
+ if (integer ? !isOneOf(INTEGER_FORMATS, format) : !isOneOf(TEXT_FORMATS, format)) {
94
+ const known = isOneOf(TEXT_FORMATS, format) || isOneOf(INTEGER_FORMATS, format)
95
+ const ids = integer ? `integer formats: ${INTEGER_FORMATS.join(", ")}` : `text formats: ${TEXT_FORMATS.join(", ")}`
96
+ throw new Error(
97
+ `${place}: ${known ? `c.${helper} takes no format` : "comb knows no format"} ${JSON.stringify(format)}; ${ids}`,
98
+ )
99
+ }
100
+ if (isOneOf(INTEGER_FORMATS, format)) {
101
+ const { max, min } = INTEGER_SPECS[format]
102
+ refuseBounds(constraints, {
103
+ least: min,
104
+ most: max,
105
+ named: (side) => `${format}'s ${side === "max" ? max : min}`,
106
+ place,
107
+ })
108
+ return format
109
+ }
110
+ const text = format as TextFormat
111
+ if (helper === "enum") refuseEnumValues(text, { place, values: values ?? [] })
112
+ else refuseTextKeys(text, constraints, place)
113
+ return text
114
+ }
@@ -193,7 +193,8 @@ function importLines(
193
193
  const lines = [`import * as z from "zod"`]
194
194
  if (table.combMeta) lines.push(`import { combMeta } from "${options.metaImportFrom ?? "@workerstack/database/meta"}"`)
195
195
  /* a local schema kept beside a c.json import may name a comb predicate */
196
- if (body.some((line) => COMB_NAMESPACE.test(line))) lines.push(combImport(options.combFrom ?? "@workerstack/database"))
196
+ if (body.some((line) => COMB_NAMESPACE.test(line)))
197
+ lines.push(combImport(options.combFrom ?? "@workerstack/database"))
197
198
  lines.push(...schemaImportLines(options.schemaImports, options.entityDir, options.importStyle))
198
199
  const shared = sharedImports(table, spread, options.sharedSchemas)
199
200
  if (shared.length > 0) {
@@ -3,6 +3,9 @@
3
3
  * objects over inline ones.
4
4
  */
5
5
  import type { FieldMeta } from "../../analyzer-types.ts"
6
+ import { declaredBounds } from "./number-range.ts"
7
+ import { textFormatOf } from "./text-format.ts"
8
+ import { textRules } from "./text-schema.ts"
6
9
 
7
10
  const EXAMPLE_ULID = "01h5a3g8k9m2n4p6q8r0t2v4x6"
8
11
  const ALPHANUMERIC_CYCLE = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
@@ -66,10 +69,12 @@ const NO_EXAMPLE_KINDS: ReadonlySet<string> = /* @__PURE__ */ new Set([
66
69
  ])
67
70
 
68
71
  /* sensitive or structured values get no example: the schema says enough, the value would leak */
69
- function hasNoExample({ combKind, constraints, dataType, dimensions, foreignKey }: FieldMeta): boolean {
72
+ function hasNoExample({ combKind, constraints, dataType, dimensions, foreignKey, schemaJson }: FieldMeta): boolean {
70
73
  /* a pattern decides what the value looks like, which no generic example can know; an array's
71
74
  * example would be its element's, of the wrong shape */
72
75
  if (constraints.private || constraints.password || constraints.pattern !== null || foreignKey !== null) return true
76
+ /* a schema's rule may be a refine its published JSON Schema does not state, so comb knows no value it takes */
77
+ if (schemaJson !== null) return true
73
78
  if ((dimensions ?? 0) > 0) return true
74
79
  if (combKind === null) return dataType === "object json"
75
80
  return NO_EXAMPLE_KINDS.has(combKind)
@@ -83,7 +88,8 @@ const SHARED_NUMBER = { integer: 42, real: 0.5, timestamp: 1_711_468_800_000 }
83
88
  * or the one bound it misses
84
89
  */
85
90
  function midpoint(field: FieldMeta, key: keyof typeof SHARED_NUMBER): ExampleResult {
86
- const { max, min } = field.constraints
91
+ /* an integer format's range is a declared bound, as the DTO checks it */
92
+ const { max, min } = declaredBounds(field.constraints)
87
93
  const fits = (value: number) => (min === null || value >= min) && (max === null || value <= max)
88
94
  if (fits(SHARED_NUMBER[key])) return shared(key)
89
95
  /* an integer's example is an integer within its bounds, a fractional bound rounded inward */
@@ -120,6 +126,19 @@ function textExample({ constraints: { max, min } }: FieldMeta): ExampleResult {
120
126
  return inline(JSON.stringify(cycleOf(max !== null && max < word ? max : (min as number))))
121
127
  }
122
128
 
129
+ /**
130
+ * A text format's example, the catalog's value of its standard as written, which its DTO takes;
131
+ * none where the column's own range, its declared bounds and length, leaves it out
132
+ */
133
+ function formatExample(field: FieldMeta): ExampleResult | null | undefined {
134
+ const format = textFormatOf(field.constraints)
135
+ if (format === null) return undefined
136
+ const { max, min } = textRules(field.constraints, field)
137
+ const { length } = format.spec.example
138
+ if ((max !== null && length > max) || (min !== null && length < min)) return null
139
+ return inline(JSON.stringify(format.spec.example))
140
+ }
141
+
123
142
  /** A rule's answer: an example, `null` for none at all, `undefined` when the field is not its kind */
124
143
  type Rule = (field: FieldMeta) => ExampleResult | null | undefined
125
144
 
@@ -146,6 +165,7 @@ const RULES: Rule[] = [
146
165
  /* a state column's example is the value a row starts at, which its create alone takes */
147
166
  ({ enumValues, states }) =>
148
167
  enumValues !== null && enumValues.length > 0 ? inline(JSON.stringify(states?.initial ?? enumValues[0])) : undefined,
168
+ formatExample,
149
169
  (field) =>
150
170
  field.combKind === "integer" || field.combKind === "real" || field.combKind === "timestamp"
151
171
  ? midpoint(field, field.combKind)
@@ -13,6 +13,7 @@ import { STORABLE_TEXT_RULE } from "../../../storable-text.ts"
13
13
  import {
14
14
  boundCode,
15
15
  clamp,
16
+ declaredBounds,
16
17
  integerRange,
17
18
  isInteger,
18
19
  isNumeric,
@@ -21,7 +22,7 @@ import {
21
22
  type Range,
22
23
  SAFE,
23
24
  } from "./number-range.ts"
24
- import { stated, textBase, textRules } from "./text-schema.ts"
25
+ import { chainAllOf, stated, textBase, textRules } from "./text-schema.ts"
25
26
 
26
27
  /** A builder's answer: its base, or `undefined` when the column is not its kind */
27
28
  type Built = Base | undefined
@@ -30,12 +31,14 @@ type Built = Base | undefined
30
31
  * A c.enum, a pgEnum or an enum text column, its values read at runtime and written out; a state
31
32
  * column's create takes its machine's `initial` alone, the value a row starts at
32
33
  */
33
- function enumBase({ element, field, schemaType }: Build): Built {
34
+ function enumBase({ annotations, element, field, schemaType }: Build): Built {
34
35
  if (field.enumValues === null) return undefined
36
+ /* the values are the rule, each one codegen checked against the format; the format is what they mean */
37
+ const keys = annotations.format === null ? {} : { "x-comb-format": annotations.format }
35
38
  const initial = field.states?.initial ?? null
36
39
  if (schemaType === "create" && initial !== null && element !== true)
37
- return plain(`z.literal(${JSON.stringify(initial)})`)
38
- return plain(`z.enum(${JSON.stringify(field.enumValues)})`)
40
+ return plain(`z.literal(${JSON.stringify(initial)})`, keys)
41
+ return plain(`z.enum(${JSON.stringify(field.enumValues)})`, keys)
39
42
  }
40
43
 
41
44
  /* no engine stores U+0000 or a lone UTF-16 surrogate as sent: jsonb refuses both, Postgres text
@@ -101,8 +104,11 @@ function integerOf(annotations: FieldConstraints, range: Range, schemaType: Sche
101
104
  `z.number().int().min(${boundCode(range.min)}).max(${boundCode(range.max)})`,
102
105
  stated({ maximum: annotations.max, minimum: annotations.min }),
103
106
  )
107
+ /* an integer format's range is a declared bound, and its id what the number means */
108
+ const { max, min } = declaredBounds(annotations)
104
109
  return plain(
105
- `z.number().int().min(${boundCode(clamp(annotations.min, range.min, Math.max))}).max(${boundCode(clamp(annotations.max, range.max, Math.min))})`,
110
+ `z.number().int().min(${boundCode(clamp(min, range.min, Math.max))}).max(${boundCode(clamp(max, range.max, Math.min))})`,
111
+ annotations.format === null ? {} : { "x-comb-format": annotations.format },
106
112
  )
107
113
  }
108
114
 
@@ -217,13 +223,30 @@ export const generateFieldSchema = (
217
223
  options: FieldSchemaOptions,
218
224
  ): string | null => fieldSchemaOf(field, schemaType, options)?.code ?? null
219
225
 
220
- /** The Zod base of a column of one value: the first builder's that takes it, else text */
226
+ /** The Zod base of a column of one value: the first builder's that takes it, else text, piped into its schema */
221
227
  function baseOf(build: Build): Base {
222
228
  for (const builder of BUILDERS) {
223
229
  const base = builder(build)
224
- if (base !== undefined) return base
230
+ if (base !== undefined) return piped(build, base, [])
225
231
  }
226
- return textBase(build)
232
+ return piped(build, textBase(build), chainAllOf(build))
233
+ }
234
+
235
+ /**
236
+ * `base` piped into the schema a text or number column declares, after every rule of comb's, so
237
+ * the schema reads the rewritten value and its issues reach the caller as it yields them; a read
238
+ * never refuses a stored row, so it takes none. zod publishes a pipe's input side, the base, so the
239
+ * schema's own input JSON Schema is published in an `allOf` after the chain's (`own`), which that
240
+ * `allOf` is written over, and its export's name is a key. The `allOf` is on the code, not the
241
+ * keys: a nullable's keys also sit beside its `anyOf`, where the schema's `type` would refuse the
242
+ * null the field takes
243
+ */
244
+ function piped(build: Build, base: Base, own: readonly unknown[]): Base {
245
+ const { field, schemaType } = build
246
+ const [ref] = field.schemaImports
247
+ if (!isMutative(schemaType) || field.schemaJson === null || ref === undefined) return base
248
+ const code = `${base.code}.pipe(${ref.name})${metaOf({ allOf: [...own, field.schemaJson] })}`
249
+ return { ...base, code, keys: { ...base.keys, "x-comb-schema": ref.imported } }
227
250
  }
228
251
 
229
252
  /** What a written value of a column is held to: its bounds and the regexes it must match */
@@ -10,22 +10,27 @@
10
10
  */
11
11
  const GROUP = "[0-9A-Fa-f]{1,4}"
12
12
  const OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
13
+ /** Postgres's IPv4 tail: two groups, or two to four octets */
13
14
  const TAIL = `(?:${GROUP}:${GROUP}|${OCTET}(?:\\.${OCTET}){1,3})`
15
+ /** A dotted quad: four decimal octets, none with a leading zero */
16
+ const QUAD = `${OCTET}(?:\\.${OCTET}){3}`
17
+ /** RFC 4291's IPv4 tail: two groups, or a dotted quad */
18
+ const STRICT_TAIL = `(?:${GROUP}:${GROUP}|${QUAD})`
14
19
  const PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])"
15
20
 
16
21
  /** At most `most` + 1 groups split by `:`, or none: what stands before a `::` */
17
22
  const before = (most: number): string => `(?:(?:${GROUP}:){0,${most}}${GROUP})?`
18
23
 
19
- /** An IPv6 address's source: eight groups, or a `::` with up to seven around it, the last two a tail */
20
- function address(): string {
24
+ /** An IPv6 address's source: eight groups, or a `::` with up to seven around it, the last two `tail` */
25
+ function address(tail: string): string {
21
26
  return [
22
- `(?:${GROUP}:){6}${TAIL}`,
23
- `::(?:${GROUP}:){5}${TAIL}`,
24
- `${before(0)}::(?:${GROUP}:){4}${TAIL}`,
25
- `${before(1)}::(?:${GROUP}:){3}${TAIL}`,
26
- `${before(2)}::(?:${GROUP}:){2}${TAIL}`,
27
- `${before(3)}::${GROUP}:${TAIL}`,
28
- `${before(4)}::${TAIL}`,
27
+ `(?:${GROUP}:){6}${tail}`,
28
+ `::(?:${GROUP}:){5}${tail}`,
29
+ `${before(0)}::(?:${GROUP}:){4}${tail}`,
30
+ `${before(1)}::(?:${GROUP}:){3}${tail}`,
31
+ `${before(2)}::(?:${GROUP}:){2}${tail}`,
32
+ `${before(3)}::${GROUP}:${tail}`,
33
+ `${before(4)}::${tail}`,
29
34
  `${before(5)}::${GROUP}`,
30
35
  `${before(6)}::`,
31
36
  ].join("|")
@@ -39,11 +44,20 @@ function address(): string {
39
44
  const unformatted = (rule: string): string => `${rule}.meta({ format: void 0 })`
40
45
 
41
46
  /** An IPv6 address, as Postgres's inet reads one */
42
- export const ipv6Rule = (): string => unformatted(`z.stringFormat("ipv6", /^(?:${address()})$/u)`)
47
+ export const ipv6Rule = (): string => unformatted(`z.stringFormat("ipv6", /^(?:${address(TAIL)})$/u)`)
43
48
 
44
49
  /** An IPv6 network, an address and its prefix length, as Postgres's inet and cidr read one */
45
- export const cidrv6Rule = (): string => unformatted(`z.stringFormat("cidrv6", /^(?:${address()})\\/${PREFIX}$/u)`)
50
+ export const cidrv6Rule = (): string => unformatted(`z.stringFormat("cidrv6", /^(?:${address(TAIL)})\\/${PREFIX}$/u)`)
46
51
 
47
52
  /** A plain inet's rule: a host address, its netmask optional */
48
53
  export const inetRule = (): string =>
49
54
  `z.union([${unformatted("z.ipv4()")}, ${ipv6Rule()}, ${unformatted("z.cidrv4()")}, ${cidrv6Rule()}])`
55
+
56
+ /**
57
+ * An IPv6 address as RFC 4291 §2.2 writes one, unanchored: Postgres's rule but an IPv4 tail of four
58
+ * octets, never two or three, so `::ffff:1.2` is refused. The `ipv6` and `ip` text formats' source
59
+ */
60
+ export const strictIpv6Source = (): string => address(STRICT_TAIL)
61
+
62
+ /** An IPv4 address as a dotted quad, unanchored: the `ipv4` and `ip` text formats' source */
63
+ export const ipv4Source = (): string => QUAD
@@ -1,6 +1,7 @@
1
1
  /** The range a number column holds and the bounds a written one takes, which the DTO checks and the manifest states. */
2
2
  import type { FieldMeta } from "../../analyzer-types.ts"
3
3
  import type { FieldConstraints } from "../../annotations.ts"
4
+ import { INTEGER_SPECS, type IntegerFormat } from "../../../formats/catalog.ts"
4
5
  import type { Build } from "./build-types.ts"
5
6
 
6
7
  /* Postgres stores c.integer in int4 */
@@ -49,14 +50,32 @@ export function integerRange(field: FieldMeta, dialect: Build["dialect"]): Range
49
50
  return field.dataType === "number int16" ? INT2 : SAFE
50
51
  }
51
52
 
53
+ /** Whether `format` is an integer format's id, which an integer column's format always is: codegen refused any other */
54
+ const isIntegerFormat = (format: FieldConstraints["format"]): format is IntegerFormat =>
55
+ format !== null && Object.hasOwn(INTEGER_SPECS, format)
56
+
57
+ /**
58
+ * The bounds a written number is declared to: its own, joined with its integer format's range, the
59
+ * tighter of each pair winning
60
+ */
61
+ export function declaredBounds({ format, max, min }: FieldConstraints): { max: number | null; min: number | null } {
62
+ if (!isIntegerFormat(format)) return { max, min }
63
+ const spec = INTEGER_SPECS[format]
64
+ return {
65
+ max: max === null ? spec.max : Math.min(max, spec.max),
66
+ min: min === null ? spec.min : Math.max(min, spec.min),
67
+ }
68
+ }
69
+
52
70
  /** A declared bound clamped to the column's, the column's where none is declared */
53
71
  export const clamp = (declared: number | null, bound: number, pick: (a: number, b: number) => number): number =>
54
72
  declared === null ? bound : pick(declared, bound)
55
73
 
56
74
  /** Refuses an integer's bounds, clamped to what the column holds, that leave no integer: every write would be refused */
57
75
  export function refuseNoInteger(name: string, annotations: FieldConstraints, range: Range): void {
58
- const min = clamp(annotations.min, range.min, Math.max)
59
- const max = clamp(annotations.max, range.max, Math.min)
76
+ const declared = declaredBounds(annotations)
77
+ const min = clamp(declared.min, range.min, Math.max)
78
+ const max = clamp(declared.max, range.max, Math.min)
60
79
  if (Math.ceil(min) > Math.floor(max))
61
80
  throw new Error(`${name}: its bounds min ${min} and max ${max} leave no integer the column holds; widen them`)
62
81
  }
@@ -67,7 +86,7 @@ export function refuseNoInteger(name: string, annotations: FieldConstraints, ran
67
86
  * none on a boolean or a timestamp comb fills
68
87
  */
69
88
  export function numberRules(field: FieldMeta, dialect: Build["dialect"]): { max: number | null; min: number | null } {
70
- const { max, min } = field.constraints
89
+ const { max, min } = declaredBounds(field.constraints)
71
90
  const kind = field.combKind
72
91
  if (kind === "boolean" || kind === "createdAt" || kind === "updatedAt" || kind === "deletedAt")
73
92
  return { max: null, min: null }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The local names an entity file imports its c.json schemas under. A
2
+ * The local names an entity file imports its column schemas under, a c.json column's and a text or
3
+ * number column's alike. A
3
4
  * schema is its module's export, so two modules' `profile` are two imports: an export name more than
4
5
  * one module gives, or one a generated file declares itself, is imported under an alias that names
5
6
  * its module, `profileFromWorkSchemas`, numbered on if that is taken too. Which names keep their own
@@ -37,7 +38,7 @@ function moduleWord(ref: ImportRef, root: string): string {
37
38
  }
38
39
 
39
40
  /**
40
- * The analysis with each c.json schema import under a local name of its own in every file it is
41
+ * The analysis with each column schema import under a local name of its own in every file it is
41
42
  * imported into; `root` the project's, which a module's path is taken from
42
43
  */
43
44
  function aliasSchemaImports(analysis: AnalysisResult, root: string): AnalysisResult {
@@ -83,7 +84,8 @@ function aliasSchemaImports(analysis: AnalysisResult, root: string): AnalysisRes
83
84
  if (ref === undefined) return field
84
85
  return {
85
86
  ...field,
86
- jsonSchemaName: names.get(keyed(ref)) as string,
87
+ /* a text or number column's schema is piped into, never a c.json column's type */
88
+ jsonSchemaName: field.jsonSchemaName === null ? null : (names.get(keyed(ref)) as string),
87
89
  schemaImports: field.schemaImports.map(named),
88
90
  }
89
91
  }),
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A text column's standard format in its DTO: one check that pushes zod's `invalid_format` issue
3
+ * with the format's id and no message, so zod's locale or the consumer's error map words it, and
4
+ * the source and length bounds it holds the value to, published as the format's own. Every format
5
+ * is read from the catalog's spec map alone.
6
+ */
7
+ import type { FieldConstraints } from "../../annotations.ts"
8
+ import { FORMATS, type FormatSpec, type TextFormat } from "../../../formats/catalog.ts"
9
+ import { regexExpression } from "../../utils/pattern.ts"
10
+ import { metaOf } from "./build-types.ts"
11
+
12
+ /** A declared text format: its id beside its spec */
13
+ export type DeclaredFormat = { id: TextFormat; spec: FormatSpec }
14
+
15
+ /** Whether `format` is a text format's id, which a text column's format always is: codegen refused any other */
16
+ const isTextFormat = (format: FieldConstraints["format"]): format is TextFormat =>
17
+ format !== null && Object.hasOwn(FORMATS, format)
18
+
19
+ /** The format a text declares; null when it declares none */
20
+ export function textFormatOf({ format }: FieldConstraints): DeclaredFormat | null {
21
+ return isTextFormat(format) ? { id: format, spec: FORMATS[format] } : null
22
+ }
23
+
24
+ /** A format's source as the regex literal comb builds from it under u, as the manifest states a pattern */
25
+ export const sourceLiteral = (source: string): string => `/${new RegExp(source, "u").source}/u`
26
+
27
+ /** The test a value meets to be of `spec`'s format, as written after its transforms: its length, its source, its registry */
28
+ function formatTest(spec: FormatSpec): string {
29
+ const length =
30
+ spec.min === spec.max
31
+ ? [`ctx.value.length === ${spec.min}`]
32
+ : [
33
+ ...(spec.min > 0 ? [`ctx.value.length >= ${spec.min}`] : []),
34
+ ...(spec.max === null ? [] : [`ctx.value.length <= ${spec.max}`]),
35
+ ]
36
+ const member = spec.member === null ? [] : [`comb.${spec.member}(ctx.value)`]
37
+ return [...length, `${regexExpression(sourceLiteral(spec.source))}.test(ctx.value)`, ...member].join(" && ")
38
+ }
39
+
40
+ /** A format's own length bounds as JSON Schema states them; none at a floor of 0 or no cap */
41
+ const lengths = ({ max, min }: FormatSpec): Record<string, number> => ({
42
+ ...(min > 0 ? { minLength: min } : {}),
43
+ ...(max === null ? {} : { maxLength: max }),
44
+ })
45
+
46
+ /**
47
+ * The format's check and what it publishes: its source as `pattern`, or, beside a declared pattern
48
+ * zod publishes as `pattern` itself (`declaresPattern`), in an `allOf`, and its length bounds
49
+ */
50
+ export function formatCheck({ id, spec }: DeclaredFormat, declaresPattern: boolean): string {
51
+ const issue = `{ code: "invalid_format", format: ${JSON.stringify(id)}, input: ctx.value, origin: "string" }`
52
+ const source = declaresPattern ? { allOf: [{ pattern: spec.source }] } : { pattern: spec.source }
53
+ return `.check((ctx) => { if (!(${formatTest(spec)})) ctx.issues.push(${issue}) })${metaOf({ ...source, ...lengths(spec) })}`
54
+ }
55
+
56
+ /** The bounds of `range` the format's own leave to a check of zod's: those tighter than its */
57
+ export const tighterBounds = (
58
+ { max, min }: { max: number | null; min: number | null },
59
+ spec: FormatSpec,
60
+ ): { max: number | null; min: number | null } => ({
61
+ max: max !== null && (spec.max === null || max < spec.max) ? max : null,
62
+ min: min !== null && min > spec.min ? min : null,
63
+ })
@@ -1,7 +1,9 @@
1
1
  /** The Zod expression of a text column: its kind, transforms and bounds, by schema type. */
2
+ import { regexes } from "zod"
2
3
  import type { FieldConstraints } from "../../annotations.ts"
3
4
  import type { FieldMeta } from "../../analyzer-types.ts"
4
5
  import { type Base, type Build, plain } from "./build-types.ts"
6
+ import { formatCheck, sourceLiteral, textFormatOf, tighterBounds } from "./text-format.ts"
5
7
  import { STORABLE_TEXT_RULE } from "../../../storable-text.ts"
6
8
  import { isMutative } from "./schema-type.ts"
7
9
  import { regexExpression } from "../../utils/pattern.ts"
@@ -44,10 +46,18 @@ const ID_LENGTH = { maxLength: 48, minLength: 1 }
44
46
  */
45
47
  function isIdText(annotations: FieldConstraints, field: FieldMeta): boolean {
46
48
  const untargetedRef = field.combKind === "ref" && field.foreignKey === null
47
- const declares = field.length !== null || annotations.min !== null || annotations.max !== null
49
+ const declares =
50
+ field.length !== null ||
51
+ annotations.min !== null ||
52
+ annotations.max !== null ||
53
+ annotations.format !== null ||
54
+ field.schemaJson !== null
48
55
  return (field.combKind === "id" || field.foreignKey?.toId === true || untargetedRef) && !declares
49
56
  }
50
57
 
58
+ /** The lesser of two upper bounds, either of which may be none */
59
+ const lesser = (a: number | null, b: number | null): number | null => (a === null ? b : b === null ? a : Math.min(a, b))
60
+
51
61
  /* the shortest value each format admits, measured from its check in a test: an address
52
62
  such as a@b.cc, and https:// with one character after it, or alone as an imageUri's prefix */
53
63
  export const TEXT_FLOORS: { email: number; imageUri: number; url: number } = { email: 6, imageUri: 8, url: 9 }
@@ -65,6 +75,12 @@ const floorOf = ({ email, imageUri, url }: FieldConstraints): number =>
65
75
  function textRange(annotations: FieldConstraints, field: FieldMeta): TextRange {
66
76
  if (isIdText(annotations, field)) return { max: ID_LENGTH.maxLength, min: ID_LENGTH.minLength }
67
77
  const max = annotations.max ?? field.length
78
+ /* a format's range is its values': the tighter of it and the declared one, no floor of a required text's 1 */
79
+ const format = textFormatOf(annotations)
80
+ if (format !== null) {
81
+ const min = Math.max(annotations.min ?? 0, format.spec.min)
82
+ return { max: lesser(max, format.spec.max), min: min === 0 ? null : min }
83
+ }
68
84
  const floor = floorOf(annotations)
69
85
  if (floor > 0) return { max, min: Math.max(annotations.min ?? 0, floor) }
70
86
  return { max, min: annotations.min ?? (field.isNotNull ? 1 : null) }
@@ -97,6 +113,9 @@ const CHAIN: Record<Transform, string> = {
97
113
  */
98
114
  export function transformsOf(annotations: FieldConstraints, mutative: boolean): Transform[] {
99
115
  if (!mutative) return []
116
+ /* a format sets its own trim and case: codegen refused a declared one beside it */
117
+ const format = textFormatOf(annotations)
118
+ if (format !== null) return [...format.spec.transform]
100
119
  if (annotations.url) return ["trim", "strip-tab-newline"]
101
120
  if (annotations.email) return ["trim", "lowercase"]
102
121
  const casing: Transform[] = annotations.lowercase ? ["lowercase"] : annotations.uppercase ? ["uppercase"] : []
@@ -155,6 +174,8 @@ function textKeys(build: Build): Record<string, unknown> {
155
174
  const transform = transformKey(annotations, mutative)
156
175
  if (isIdText(annotations, field)) return mutative ? { ...transform, ...GUARD_KEY } : stated(ID_LENGTH)
157
176
  if (!mutative) return readKeys(build)
177
+ /* a format's source admits no U+0000 or surrogate, so it takes no text guard */
178
+ if (annotations.format !== null) return { ...transform, "x-comb-format": annotations.format }
158
179
  if (annotations.email) return transform
159
180
  return annotations.url ? { ...transform, ...URL_KEY, ...GUARD_KEY } : { ...transform, ...GUARD_KEY }
160
181
  }
@@ -179,7 +200,11 @@ function textBounds(build: Build, range: TextRange): string {
179
200
  return held === null ? "" : `.max(${held})`
180
201
  }
181
202
  if (annotations.email) return emailBounds(annotations, range)
182
- return rangeChecks(range) + writeChecks(annotations)
203
+ const format = textFormatOf(annotations)
204
+ if (format === null) return rangeChecks(range) + writeChecks(annotations)
205
+ /* the format's check first, then a declared bound tighter than its, as zod's own check, and a declared pattern */
206
+ const pattern = annotations.pattern === null ? "" : `.regex(${regexExpression(annotations.pattern)})`
207
+ return formatCheck(format, annotations.pattern !== null) + rangeChecks(tighterBounds(range, format.spec)) + pattern
183
208
  }
184
209
 
185
210
  /**
@@ -229,6 +254,28 @@ export function textRules(annotations: FieldConstraints, field: FieldMeta): Text
229
254
  const range = textRange(annotations, field)
230
255
  const declared = annotations.pattern === null ? [] : [annotations.pattern]
231
256
  if (isIdText(annotations, field)) return { ...range, patterns: [ID_PATTERN] }
257
+ const format = textFormatOf(annotations)
258
+ /* the format's source first, as its check runs before the declared pattern's */
259
+ if (format !== null) return { ...range, patterns: [sourceLiteral(format.spec.source), ...declared] }
232
260
  const kind = [annotations.url ? HTTPS : null, annotations.name ? NAME : null, annotations.imageUri ? IMAGE : null]
233
261
  return { ...range, patterns: [...declared, ...kind.filter((each) => each !== null)] }
234
262
  }
263
+
264
+ /** A regex literal's source, as zod publishes it */
265
+ const sourceOf = (literal: string): string => literal.slice(1, literal.lastIndexOf("/"))
266
+
267
+ /**
268
+ * The `allOf` a written text's own chain publishes: the pattern of each of two or more regexes, as
269
+ * zod writes them in the order they are checked, z.email()'s first, or a format's source beside a
270
+ * declared pattern. A schema's `allOf`, written over the chain's, carries these first
271
+ */
272
+ export function chainAllOf(build: Build): Array<{ pattern: string }> {
273
+ const { annotations, field } = build
274
+ if (!isMutative(build.schemaType) || isIdText(annotations, field)) return []
275
+ /* a format's source as its check publishes it beside a declared pattern: the catalog's */
276
+ const format = textFormatOf(annotations)
277
+ if (format !== null) return annotations.pattern === null ? [] : [{ pattern: format.spec.source }]
278
+ const { patterns } = textRules(annotations, field)
279
+ const chain = [...(annotations.email ? [regexes.email.source] : []), ...patterns.map(sourceOf)]
280
+ return chain.length > 1 ? chain.map((pattern) => ({ pattern })) : []
281
+ }
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import path from "node:path"
9
9
  import { deriveEntityMeta, publishedPartialUniqueIndexes, publishedUniqueIndexes, writesOf } from "../entity-meta.ts"
10
- import type { FieldMeta, TableMeta } from "../analyzer-types.ts"
10
+ import type { FieldMeta, ImportRef, TableMeta } from "../analyzer-types.ts"
11
+ import type { IntegerFormat, TextFormat } from "../../formats/catalog.ts"
11
12
  import { writeGenJsonFile } from "../utils/fs.ts"
12
13
  import type { GeneratorContext } from "./context-types.ts"
13
14
  import { qualifiedName } from "../utils/table-name.ts"
@@ -57,6 +58,10 @@ type ManifestColumn = {
57
58
  min: number | null
58
59
  /** Every regex a written text must match, its kind's and the declared one, as JavaScript regex literals; a plain Postgres type's rule is its `kind` and `sqlType`'s */
59
60
  patterns: string[]
61
+ /** The standard format a written value is checked against (`iso4217`, `iso8601-weekday`), by its id; null when the column declares none */
62
+ format: TextFormat | IntegerFormat | null
63
+ /** The export name of the zod schema a text or number column declares, which a write runs after the column's own rules; null when none, and on a c.json column, whose schema is its value's shape rather than a rule beside it */
64
+ schema: string | null
60
65
  /** A text column's format, null for any other */
61
66
  textKind: "email" | "imageUri" | "name" | "password" | "url" | null
62
67
  /** The rewrites a written value takes before `min`, `max` and `patterns` bind it, in order: the DTO's own list (meta-contract §2.1) */
@@ -177,6 +182,7 @@ function toColumn(field: FieldMeta, dialect: GeneratorContext["dialect"]): Manif
177
182
  const { max, maxBytes, min, patterns } = writeRules(field, dialect)
178
183
  return {
179
184
  enumValues: field.enumValues,
185
+ format: constraints.format,
180
186
  /* what the write bodies do, read off the rule the DTOs and the stamp read, a private column's too */
181
187
  generated: !writesOf(field).create,
182
188
  immutable: !writesOf(field).update,
@@ -188,6 +194,8 @@ function toColumn(field: FieldMeta, dialect: GeneratorContext["dialect"]): Manif
188
194
  notNull: field.isNotNull,
189
195
  patterns,
190
196
  primaryKey: field.isPrimaryKey,
197
+ /* a text or number column's schema is the field's one import; a c.json column's import is its value's shape, with no schemaJson */
198
+ schema: field.schemaJson === null ? null : (field.schemaImports[0] as ImportRef).imported,
191
199
  textKind: textKindOf(constraints),
192
200
  transform: transformsOf(constraints, true),
193
201
  private: constraints.private,