@supatype/cli 0.1.12 → 0.1.13

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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +138 -132
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/cli-version-embedded.js +1 -1
  5. package/dist/commands/db.d.ts.map +1 -1
  6. package/dist/commands/db.js +23 -1
  7. package/dist/commands/db.js.map +1 -1
  8. package/dist/commands/doctor.d.ts +0 -7
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +26 -0
  11. package/dist/commands/doctor.js.map +1 -1
  12. package/dist/commands/push.d.ts.map +1 -1
  13. package/dist/commands/push.js +15 -7
  14. package/dist/commands/push.js.map +1 -1
  15. package/dist/compose-local-server-image.d.ts +11 -0
  16. package/dist/compose-local-server-image.d.ts.map +1 -1
  17. package/dist/compose-local-server-image.js +18 -0
  18. package/dist/compose-local-server-image.js.map +1 -1
  19. package/dist/dev-compose.d.ts +1 -0
  20. package/dist/dev-compose.d.ts.map +1 -1
  21. package/dist/dev-compose.js +79 -10
  22. package/dist/dev-compose.js.map +1 -1
  23. package/dist/field-bounds.d.ts +68 -0
  24. package/dist/field-bounds.d.ts.map +1 -0
  25. package/dist/field-bounds.js +277 -0
  26. package/dist/field-bounds.js.map +1 -0
  27. package/dist/hooks-generator.d.ts +1 -1
  28. package/dist/hooks-generator.d.ts.map +1 -1
  29. package/dist/hooks-generator.js +78 -4
  30. package/dist/hooks-generator.js.map +1 -1
  31. package/dist/model-hooks.d.ts +44 -2
  32. package/dist/model-hooks.d.ts.map +1 -1
  33. package/dist/model-hooks.js +116 -12
  34. package/dist/model-hooks.js.map +1 -1
  35. package/dist/schema-ast-v2.d.ts +38 -4
  36. package/dist/schema-ast-v2.d.ts.map +1 -1
  37. package/dist/schema-ast-v2.js +87 -4
  38. package/dist/schema-ast-v2.js.map +1 -1
  39. package/dist/type-extractor.d.ts.map +1 -1
  40. package/dist/type-extractor.js +309 -27
  41. package/dist/type-extractor.js.map +1 -1
  42. package/package.json +4 -3
  43. package/src/cli-version-embedded.ts +1 -1
  44. package/src/commands/db.ts +27 -1
  45. package/src/commands/doctor.ts +30 -0
  46. package/src/commands/push.ts +26 -6
  47. package/src/compose-local-server-image.ts +17 -0
  48. package/src/dev-compose.ts +96 -9
  49. package/src/field-bounds.ts +359 -0
  50. package/src/hooks-generator.ts +81 -4
  51. package/src/model-hooks.ts +158 -12
  52. package/src/schema-ast-v2.ts +114 -10
  53. package/src/type-extractor.ts +374 -39
  54. package/tests/field-bounds-matrix.test.ts +163 -0
  55. package/tests/field-bounds.test.ts +139 -0
  56. package/tests/field-validators.test.ts +139 -0
  57. package/tests/hooks-generator.test.ts +86 -0
  58. package/tests/local-server-image-env.test.ts +93 -0
  59. package/tests/model-constraints.test.ts +293 -0
  60. package/tests/model-hooks.test.ts +56 -0
  61. package/tests/type-extractor.test.ts +49 -0
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -23,8 +23,10 @@ import {
23
23
  type ExtractedStorageBucketAst,
24
24
  type FieldAstV2,
25
25
  type KernelFieldFacts,
26
+ type FieldKind,
26
27
  type ParsedField,
27
28
  } from "./schema-ast-v2.js"
29
+ import { compileBounds, measureFormFor, type DeclaredBounds } from "./field-bounds.js"
28
30
 
29
31
  export type { ExtractedSchemaAstV2 as ExtractedSchemaAst, ExtractedStorageBucketAst } from "./schema-ast-v2.js"
30
32
 
@@ -105,7 +107,8 @@ export function extractSchemaAstFromTypes(
105
107
  )
106
108
  }
107
109
 
108
- const { tableName, access, options, indexes, hooks } = parseModelMeta(
110
+ const { tableName, access, options, indexes, constraints, hooks, validators } =
111
+ parseModelMeta(
109
112
  metaArg,
110
113
  sourceFile,
111
114
  stmt.name.text,
@@ -115,7 +118,17 @@ export function extractSchemaAstFromTypes(
115
118
  )
116
119
 
117
120
  models.push(
118
- emitModel(stmt.name.text, fields, options, tableName, access, indexes, hooks),
121
+ emitModel(
122
+ stmt.name.text,
123
+ fields,
124
+ options,
125
+ tableName,
126
+ access,
127
+ indexes,
128
+ hooks,
129
+ constraints,
130
+ validators,
131
+ ),
119
132
  )
120
133
  }
121
134
  }
@@ -387,6 +400,55 @@ function parseDefaultLiteral(
387
400
  return undefined
388
401
  }
389
402
 
403
+ /** Which {@link DeclaredBounds} key each length/item modifier fills. */
404
+ const BOUND_KEY_BY_MODIFIER = {
405
+ MaxLength: "maxLength",
406
+ MinLength: "minLength",
407
+ MaxItems: "maxItems",
408
+ MinItems: "minItems",
409
+ } as const satisfies Record<string, keyof DeclaredBounds>
410
+
411
+ /**
412
+ * A `Between` bound: a number for a numeric column, an ISO-8601 string for a temporal one.
413
+ *
414
+ * Which of the two is legal is decided from the field's kind in `compileBounds`, not here, because
415
+ * the kind is not known until the wrappers are off.
416
+ */
417
+ function parseRangeBound(
418
+ node: ts.TypeNode | undefined,
419
+ sourceFile: ts.SourceFile,
420
+ ): number | string | undefined {
421
+ const asNumber = parseNumericTypeArg(node, sourceFile)
422
+ if (asNumber !== undefined) return asNumber
423
+ return literalStringType(node) ?? undefined
424
+ }
425
+
426
+ /**
427
+ * Whether a `JSON<T>` field holds an array, which is what decides between item bounds and none.
428
+ *
429
+ * Answered from the declared type argument rather than guessed downstream: the engine sees only
430
+ * `JSONB` and cannot tell `JSON<Item[]>` from `JSON<{ a: string }>`, so `jsonb_array_length` would
431
+ * be a coin flip that raises at insert time when it loses.
432
+ */
433
+ function jsonTypeArgIsArray(node: ts.TypeNode, sourceFile: ts.SourceFile): boolean {
434
+ if (!ts.isTypeReferenceNode(node)) return false
435
+ const arg = node.typeArguments?.[0]
436
+ if (!arg) return false
437
+ return isArrayLikeTypeNode(arg, sourceFile)
438
+ }
439
+
440
+ function isArrayLikeTypeNode(node: ts.TypeNode, sourceFile: ts.SourceFile): boolean {
441
+ if (ts.isArrayTypeNode(node)) return true
442
+ // `readonly Item[]`
443
+ if (ts.isTypeOperatorNode(node) && node.operator === ts.SyntaxKind.ReadonlyKeyword) {
444
+ return isArrayLikeTypeNode(node.type, sourceFile)
445
+ }
446
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
447
+ return node.typeName.text === "Array" || node.typeName.text === "ReadonlyArray"
448
+ }
449
+ return false
450
+ }
451
+
390
452
  function parseFieldType(
391
453
  fieldName: string,
392
454
  typeNode: ts.TypeNode,
@@ -412,7 +474,7 @@ function parseFieldType(
412
474
  fieldDefault: undefined as string | number | boolean | null | undefined,
413
475
  localized: false,
414
476
  notLocalized: false,
415
- checkConstraint: undefined as string | undefined,
477
+ bounds: {} as DeclaredBounds,
416
478
  }
417
479
 
418
480
  const resolving = new Set<string>()
@@ -484,43 +546,22 @@ function parseFieldType(
484
546
  current = valueArg ?? current
485
547
  continue
486
548
  }
487
- case "MaxLength": {
488
- const max = parseNumericTypeArg(current.typeArguments?.[1], sourceFile)
489
- if (max !== undefined) {
490
- flags.checkConstraint = mergeCheckConstraint(
491
- flags.checkConstraint,
492
- `char_length("{name}") <= ${max}`,
493
- )
494
- }
495
- current = current.typeArguments?.[0] ?? current
496
- continue
497
- }
498
- case "MinLength": {
499
- const min = parseNumericTypeArg(current.typeArguments?.[1], sourceFile)
500
- if (min !== undefined) {
501
- flags.checkConstraint = mergeCheckConstraint(
502
- flags.checkConstraint,
503
- `char_length("{name}") >= ${min}`,
504
- )
549
+ case "MaxLength":
550
+ case "MinLength":
551
+ case "MaxItems":
552
+ case "MinItems": {
553
+ const amount = parseNumericTypeArg(current.typeArguments?.[1], sourceFile)
554
+ if (amount !== undefined) {
555
+ flags.bounds[BOUND_KEY_BY_MODIFIER[typeName]] = amount
505
556
  }
506
557
  current = current.typeArguments?.[0] ?? current
507
558
  continue
508
559
  }
509
560
  case "Between": {
510
- const min = parseNumericTypeArg(current.typeArguments?.[1], sourceFile)
511
- const max = parseNumericTypeArg(current.typeArguments?.[2], sourceFile)
512
- if (min !== undefined) {
513
- flags.checkConstraint = mergeCheckConstraint(
514
- flags.checkConstraint,
515
- `"{name}"::numeric >= ${min}`,
516
- )
517
- }
518
- if (max !== undefined) {
519
- flags.checkConstraint = mergeCheckConstraint(
520
- flags.checkConstraint,
521
- `"{name}"::numeric <= ${max}`,
522
- )
523
- }
561
+ const min = parseRangeBound(current.typeArguments?.[1], sourceFile)
562
+ const max = parseRangeBound(current.typeArguments?.[2], sourceFile)
563
+ if (min !== undefined) flags.bounds.min = min
564
+ if (max !== undefined) flags.bounds.max = max
524
565
  current = current.typeArguments?.[0] ?? current
525
566
  continue
526
567
  }
@@ -712,10 +753,25 @@ function parseFieldType(
712
753
  parsed = { ...parsed, kernel }
713
754
  }
714
755
 
715
- if (flags.checkConstraint !== undefined) {
756
+ // Bounds compile here, not in the modifier cases above: `MaxLength<string[], 10>` is only knowably
757
+ // an item count once the wrappers are off and the kind is `array`. Compiling early is what made
758
+ // every bound `char_length`, which does not exist for an array and fails `CREATE TABLE`.
759
+ const jsonIsArray = parsed.kind === "json" && jsonTypeArgIsArray(current, sourceFile)
760
+ if (jsonIsArray) {
761
+ parsed = { ...parsed, kernel: { ...parsed.kernel, jsonArray: true } }
762
+ }
763
+
764
+ if (Object.keys(flags.bounds).length > 0) {
765
+ const compiled = compileBounds(fieldName, parsed.kind, flags.bounds, { jsonIsArray })
716
766
  parsed = {
717
767
  ...parsed,
718
- kernel: { ...parsed.kernel, check: flags.checkConstraint },
768
+ kernel: {
769
+ ...parsed.kernel,
770
+ ...(compiled.check !== undefined && {
771
+ check: mergeCheckConstraint(parsed.kernel.check, compiled.check),
772
+ }),
773
+ ...(compiled.validation !== undefined && { validation: compiled.validation }),
774
+ },
719
775
  }
720
776
  }
721
777
 
@@ -1287,7 +1343,9 @@ function parsePartialBucketAccess(
1287
1343
  if (!ts.isPropertySignature(member) || !member.type) continue
1288
1344
  const key = getPropertyName(member.name)
1289
1345
  if (key !== "read" && key !== "create" && key !== "delete") continue
1290
- access[key] = parseAccessRule(member.type, sourceFile, resolveCtx)
1346
+ const bucketRule = parseAccessRule(member.type, sourceFile, resolveCtx)
1347
+ assertAccessRuleIsRenderable(bucketRule, bucketId, key)
1348
+ access[key] = bucketRule
1291
1349
  }
1292
1350
  return access
1293
1351
  }
@@ -1619,7 +1677,9 @@ function parseModelMeta(
1619
1677
  access: Record<string, unknown>
1620
1678
  options: Record<string, unknown>
1621
1679
  indexes: unknown[]
1680
+ constraints: unknown[]
1622
1681
  hooks: Record<string, ParsedModelHook>
1682
+ validators: Record<string, ParsedModelHook>
1623
1683
  } {
1624
1684
  const literal = parseMetaLiteral(metaArg, sourceFile)
1625
1685
  const singleton = literal.singleton === true
@@ -1646,10 +1706,154 @@ function parseModelMeta(
1646
1706
  access: parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx),
1647
1707
  options,
1648
1708
  indexes: parseModelIndexes(metaArg, sourceFile, fields),
1709
+ constraints: parseModelConstraints(metaArg, sourceFile, modelName, fields, resolveCtx),
1649
1710
  hooks: parseModelHooks(metaArg, sourceFile),
1711
+ validators: parseModelValidators(metaArg, sourceFile, modelName, fields),
1650
1712
  }
1651
1713
  }
1652
1714
 
1715
+ /**
1716
+ * Operands a `CHECK` cannot evaluate, and why.
1717
+ *
1718
+ * All of these are legal in an access rule, which is exactly why they need refusing here rather
1719
+ * than left to fail at `CREATE TABLE`: the vocabulary is shared, so the mistake is easy and the
1720
+ * error Postgres gives for it names nothing useful.
1721
+ */
1722
+ const CONSTRAINT_FORBIDDEN_OPERANDS: Record<string, string> = {
1723
+ authUid: "a CHECK constraint cannot see who is writing",
1724
+ authRole: "a CHECK constraint cannot see who is writing",
1725
+ claim: "a CHECK constraint cannot read the caller's JWT",
1726
+ role: "a CHECK constraint cannot see the caller's role",
1727
+ now: "a row valid on insert would become invalid on update, so a constraint cannot read the clock",
1728
+ startOf: "a constraint cannot read the clock",
1729
+ ago: "a constraint cannot read the clock",
1730
+ fromNow: "a constraint cannot read the clock",
1731
+ rows: "a CHECK constraint cannot query another table",
1732
+ exists: "a CHECK constraint cannot query another table",
1733
+ }
1734
+
1735
+ /**
1736
+ * Walk a parsed constraint node and refuse anything the database cannot enforce as a `CHECK`.
1737
+ *
1738
+ * Walks the parsed form rather than the syntax, so a node reached through `Any`, `All` or `Not`
1739
+ * is checked the same as a top-level one.
1740
+ */
1741
+ function assertConstraintIsEnforceable(node: unknown, model: string, index: number): void {
1742
+ if (Array.isArray(node)) {
1743
+ for (const item of node) assertConstraintIsEnforceable(item, model, index)
1744
+ return
1745
+ }
1746
+ if (typeof node !== "object" || node === null) return
1747
+
1748
+ const record = node as Record<string, unknown>
1749
+ for (const key of ["type", "kind"]) {
1750
+ const value = record[key]
1751
+ if (typeof value !== "string") continue
1752
+ const why = CONSTRAINT_FORBIDDEN_OPERANDS[value]
1753
+ if (why !== undefined) {
1754
+ throw new Error(
1755
+ `Model "${model}": constraint ${index + 1} uses \`${value}\`, which is not allowed in a ` +
1756
+ `constraint because ${why}. Move the rule to \`access\` if it depends on the caller.`,
1757
+ )
1758
+ }
1759
+ }
1760
+ for (const value of Object.values(record)) assertConstraintIsEnforceable(value, model, index)
1761
+ }
1762
+
1763
+ /**
1764
+ * Resolve `Length<>` and `ItemCount<>` inside a constraint to the measure their column actually
1765
+ * takes, so the engine renders mechanically and never has to know about field kinds.
1766
+ *
1767
+ * Done here because this is the only layer that has both halves: the field's kind, and the table
1768
+ * the bounds modifiers already resolve against. The engine seeing `JSONB` cannot tell an array from
1769
+ * an object, and a second kind table in Rust is how `char_length(text[])` would come back in a new
1770
+ * file after being fixed once.
1771
+ */
1772
+ function resolveConstraintMeasures(
1773
+ node: unknown,
1774
+ fields: Record<string, FieldAstV2>,
1775
+ model: string,
1776
+ index: number,
1777
+ ): void {
1778
+ if (Array.isArray(node)) {
1779
+ for (const item of node) resolveConstraintMeasures(item, fields, model, index)
1780
+ return
1781
+ }
1782
+ if (typeof node !== "object" || node === null) return
1783
+
1784
+ const record = node as Record<string, unknown>
1785
+ const kind = record["kind"]
1786
+ if (kind === "length" || kind === "itemCount") {
1787
+ const column = String(record["column"])
1788
+ const field = fields[column]
1789
+ if (!field) {
1790
+ throw new Error(
1791
+ `Model "${model}": constraint ${index + 1} measures \`${column}\`, which is not a field on ` +
1792
+ "this model. Measures need a declared field, because the measure depends on its type.",
1793
+ )
1794
+ }
1795
+ const measure = kind === "length" ? "length" : "items"
1796
+ const resolved = measureFormFor(field.kind as FieldKind, measure, {
1797
+ jsonIsArray: field["jsonArray"] === true,
1798
+ })
1799
+ if (resolved.form === undefined) {
1800
+ throw new Error(
1801
+ `Model "${model}": constraint ${index + 1} uses ` +
1802
+ `\`${kind === "length" ? "Length" : "ItemCount"}<"${column}">\`, but ` +
1803
+ `${resolved.instead}.`,
1804
+ )
1805
+ }
1806
+ record["form"] = resolved.form
1807
+ return
1808
+ }
1809
+
1810
+ for (const value of Object.values(record)) {
1811
+ resolveConstraintMeasures(value, fields, model, index)
1812
+ }
1813
+ }
1814
+
1815
+ /**
1816
+ * Read `constraints` from a model's meta.
1817
+ *
1818
+ * Reuses `parseAccessRule`, because a constraint *is* an access rule with a narrower operand set:
1819
+ * a second parser for the same node vocabulary would be two grammars to keep aligned, and they
1820
+ * would drift the first time either gained a node.
1821
+ */
1822
+ function parseModelConstraints(
1823
+ metaArg: ts.TypeNode | undefined,
1824
+ sourceFile: ts.SourceFile,
1825
+ modelName: string,
1826
+ fields: Record<string, FieldAstV2>,
1827
+ resolveCtx: ResolveContext,
1828
+ ): unknown[] {
1829
+ if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return []
1830
+
1831
+ const prop = metaArg.members.find(
1832
+ (member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "constraints",
1833
+ )
1834
+ if (!prop || !ts.isPropertySignature(prop) || !prop.type) return []
1835
+
1836
+ if (!ts.isTupleTypeNode(prop.type)) {
1837
+ throw new Error(
1838
+ `Model "${modelName}": \`constraints\` must be a tuple, as in ` +
1839
+ `\`constraints: [Lte<"starts_at", "ends_at">]\`.`,
1840
+ )
1841
+ }
1842
+
1843
+ return prop.type.elements.map((element, index) => {
1844
+ const parsed = parseAccessRule(element, sourceFile, resolveCtx)
1845
+ if (parsed["type"] === "private") {
1846
+ throw new Error(
1847
+ `Model "${modelName}": constraint ${index + 1}, ` +
1848
+ `\`${ownerText(element, sourceFile)}\`, is not a rule this vocabulary knows.`,
1849
+ )
1850
+ }
1851
+ assertConstraintIsEnforceable(parsed, modelName, index)
1852
+ resolveConstraintMeasures(parsed, fields, modelName, index)
1853
+ return parsed
1854
+ })
1855
+ }
1856
+
1653
1857
  /** One lifecycle hook: `"fn-name"` or `{ function: "fn-name", timeout: 5000 }`. */
1654
1858
  interface ParsedModelHook {
1655
1859
  function: string
@@ -1667,6 +1871,62 @@ const HOOK_EVENTS = ["beforeChange", "afterChange", "beforeDelete", "afterDelete
1667
1871
  * silently not firing is the failure this feature cannot have, so an unreadable declaration must
1668
1872
  * fail the push rather than extract to nothing.
1669
1873
  */
1874
+ /**
1875
+ * Read `validate` from a model's meta: field name to the function that checks it.
1876
+ *
1877
+ * Strict for the same reason `parseModelHooks` is: a validator that silently never fires is the
1878
+ * failure this feature cannot have. An entry that is neither a string nor an object with a
1879
+ * `function` name is dropped here and reported by `validateModelHooks`, which fails the push.
1880
+ *
1881
+ * Keyed by the **field** as written, not the column: the extractor resolves the two, and a validator
1882
+ * naming a field the model does not declare is an error worth catching here where the message can
1883
+ * list what the model does have.
1884
+ */
1885
+ function parseModelValidators(
1886
+ metaArg: ts.TypeNode | undefined,
1887
+ sourceFile: ts.SourceFile,
1888
+ modelName: string,
1889
+ fields: Record<string, FieldAstV2>,
1890
+ ): Record<string, ParsedModelHook> {
1891
+ if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return {}
1892
+
1893
+ const prop = metaArg.members.find(
1894
+ (member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "validate",
1895
+ )
1896
+ if (!prop || !ts.isPropertySignature(prop) || !prop.type) return {}
1897
+ if (!ts.isTypeLiteralNode(prop.type)) {
1898
+ throw new Error(
1899
+ `Model "${modelName}": \`validate\` must be an object mapping a field to a function name, ` +
1900
+ 'as in `validate: { setupItems: "validate-setup-items" }`.',
1901
+ )
1902
+ }
1903
+
1904
+ const out: Record<string, ParsedModelHook> = {}
1905
+ for (const member of prop.type.members) {
1906
+ if (!ts.isPropertySignature(member) || !member.type) continue
1907
+ const field = getPropertyName(member.name)
1908
+ if (!field) continue
1909
+
1910
+ if (!Object.prototype.hasOwnProperty.call(fields, field)) {
1911
+ const known = Object.keys(fields).join(", ")
1912
+ throw new Error(
1913
+ `Model "${modelName}": \`validate\` names "${field}", which is not a field on this model. ` +
1914
+ `Fields are: ${known}.`,
1915
+ )
1916
+ }
1917
+
1918
+ const parsed = parseModelHookValue(member.type, sourceFile)
1919
+ if (parsed === null) {
1920
+ throw new Error(
1921
+ `Model "${modelName}": the validator for "${field}" must be a function name, or an object ` +
1922
+ 'with a `function` name, as in `{ function: "check-it", timeout: 5000 }`.',
1923
+ )
1924
+ }
1925
+ out[field] = parsed
1926
+ }
1927
+ return out
1928
+ }
1929
+
1670
1930
  function parseModelHooks(
1671
1931
  metaArg: ts.TypeNode | undefined,
1672
1932
  sourceFile: ts.SourceFile,
@@ -1798,6 +2058,42 @@ function resolveIndexFieldName(fieldName: string, fields: Record<string, FieldAs
1798
2058
  * unsupported shape silently published a table with no row-level protection at
1799
2059
  * all. Failing the extract is the only safe outcome.
1800
2060
  */
2061
+ /**
2062
+ * Nodes that parse but that the engine's RLS renderer does not know.
2063
+ *
2064
+ * `Length`, `ItemCount` and `Matches` exist for `constraints`, and they share a parser with access
2065
+ * rules because a constraint *is* an access rule with a narrower operand set. That reuse runs both
2066
+ * ways: nothing stops someone writing `Gte<Length<"title">, Literal<5>>` in an `access` block, where
2067
+ * it would parse cleanly, reach an engine that has no case for it, and produce a policy that does
2068
+ * not say what the author wrote. Refusing here keeps the sharing honest until the RLS renderer
2069
+ * learns them.
2070
+ */
2071
+ const CONSTRAINT_ONLY_NODES = new Set(["length", "itemCount", "matches"])
2072
+
2073
+ function assertAccessRuleIsRenderable(node: unknown, model: string, operation: string): void {
2074
+ if (Array.isArray(node)) {
2075
+ for (const item of node) assertAccessRuleIsRenderable(item, model, operation)
2076
+ return
2077
+ }
2078
+ if (typeof node !== "object" || node === null) return
2079
+
2080
+ const record = node as Record<string, unknown>
2081
+ // Operands are tagged `kind`, rules `type`, so both are checked: `Matches` is a rule.
2082
+ const tag = [record["kind"], record["type"]].find(
2083
+ (value) => typeof value === "string" && CONSTRAINT_ONLY_NODES.has(value),
2084
+ )
2085
+ if (typeof tag === "string") {
2086
+ const spelled = { length: "Length", itemCount: "ItemCount", matches: "Matches" }[tag] ?? tag
2087
+ throw new Error(
2088
+ `Model "${model}": \`${spelled}<>\` is not supported in an \`access\` rule (\`${operation}\`). ` +
2089
+ "It belongs in `constraints`, which the database enforces for every writer.",
2090
+ )
2091
+ }
2092
+ for (const value of Object.values(record)) {
2093
+ assertAccessRuleIsRenderable(value, model, operation)
2094
+ }
2095
+ }
2096
+
1801
2097
  function parseModelAccess(
1802
2098
  metaArg: ts.TypeNode | undefined,
1803
2099
  sourceFile: ts.SourceFile,
@@ -1850,7 +2146,9 @@ function parseModelAccess(
1850
2146
  continue
1851
2147
  }
1852
2148
 
1853
- access[key] = parseAccessRule(member.type, sourceFile, resolveCtx)
2149
+ const rule = parseAccessRule(member.type, sourceFile, resolveCtx)
2150
+ assertAccessRuleIsRenderable(rule, modelName, key)
2151
+ access[key] = rule
1854
2152
  }
1855
2153
 
1856
2154
  if (Object.keys(access).length === 0) {
@@ -2177,6 +2475,35 @@ function parseAccessRule(
2177
2475
  right: parseAccessOperand(args[1]!, sourceFile),
2178
2476
  }
2179
2477
  }
2478
+ case "Matches": {
2479
+ const args = typeNode.typeArguments ?? []
2480
+ if (args.length !== 2) {
2481
+ throw new Error(
2482
+ '`Matches<>` takes a column and a pattern, as in `Matches<"sku", "^[A-Z]{3}$">`.',
2483
+ )
2484
+ }
2485
+ const column = stringLiteralArg(args[0], "Matches", "column")
2486
+ const pattern = literalStringType(args[1] as ts.TypeNode)
2487
+ if (pattern === null) {
2488
+ throw new Error(
2489
+ '`Matches<>` needs a string literal pattern, as in `Matches<"sku", "^[A-Z]{3}$">`.',
2490
+ )
2491
+ }
2492
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
2493
+ throw new Error(`\`Matches<"${column}", ...>\` does not name a valid column.`)
2494
+ }
2495
+ // Validated here so an unparseable pattern is a CLI error naming the field, rather than a
2496
+ // Postgres error part way through a migration. Postgres regex is POSIX and JavaScript's is
2497
+ // not, so this catches malformed patterns, not every dialect difference.
2498
+ try {
2499
+ new RegExp(pattern)
2500
+ } catch {
2501
+ throw new Error(
2502
+ `\`Matches<"${column}", "${pattern}">\`: the pattern is not a valid regular expression.`,
2503
+ )
2504
+ }
2505
+ return { type: "matches", column, pattern }
2506
+ }
2180
2507
  case "IsNull":
2181
2508
  case "NotNull": {
2182
2509
  const operand = typeNode.typeArguments?.[0]
@@ -2483,6 +2810,14 @@ function parseAccessOperand(
2483
2810
  const ref = ownerText(typeNode.typeName, sourceFile)
2484
2811
  const operandArgs = typeNode.typeArguments ?? []
2485
2812
  switch (ref) {
2813
+ case "Length":
2814
+ case "ItemCount": {
2815
+ const column = stringLiteralArg(operandArgs[0], ref, "column")
2816
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
2817
+ throw new Error(`\`${ref}<"${column}">\` does not name a valid column.`)
2818
+ }
2819
+ return { kind: ref === "Length" ? "length" : "itemCount", column }
2820
+ }
2486
2821
  case "AuthUid":
2487
2822
  return { kind: "authUid" }
2488
2823
  case "AuthRole":
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { compileBounds } from "../src/field-bounds.js"
3
+ import type { FieldKind } from "../src/schema-ast-v2.js"
4
+
5
+ /**
6
+ * Every field kind, against every bound family, asserted to do exactly one of two things.
7
+ *
8
+ * This is the test whose absence caused the whole defect. Nine of twelve field kinds accepted a
9
+ * declared bound and enforced nothing, and no test noticed, because no test enumerated the kinds:
10
+ * each was handled wherever someone happened to look.
11
+ *
12
+ * `MATRIX` is typed `Record<FieldKind, KindRow>`, so **completeness is the compiler's job**: adding
13
+ * a kind to `FIELD_KINDS` fails the build in three places at once, here, in `BOUNDS_BY_KIND`, and
14
+ * at any `scalar()` call using a name the registry does not know. No assertion can rot into
15
+ * vacuous, because there is nothing to keep in sync by hand.
16
+ *
17
+ * Every cell is either an expression or a refusal. **A cell that is silently empty is the bug**, so
18
+ * there is no way to express one.
19
+ */
20
+
21
+ type Cell = { sql: string } | { refused: RegExp }
22
+
23
+ interface KindRow {
24
+ /** `MaxLength<T, 5>` */
25
+ length: Cell
26
+ /** `MaxItems<T, 5>` */
27
+ items: Cell
28
+ /** `Between<T, ...>`, using whichever bound shape the kind accepts. */
29
+ range: Cell
30
+ /** The literal to bound with, when the kind takes a string rather than a number. */
31
+ rangeBound?: string
32
+ }
33
+
34
+ const COL = '"{name}"'
35
+ const refusedAs = (alternative: string): Cell => ({ refused: new RegExp(alternative) })
36
+
37
+ const textual = (): KindRow => ({
38
+ length: { sql: `char_length(${COL}) <= 5` },
39
+ items: refusedAs("MaxItems is not supported"),
40
+ range: refusedAs("Between is not supported"),
41
+ })
42
+
43
+ const numeric = (): KindRow => ({
44
+ length: refusedAs("MaxLength is not supported"),
45
+ items: refusedAs("MaxItems is not supported"),
46
+ range: { sql: `${COL} <= 5` },
47
+ })
48
+
49
+ // An interval is bounded by a duration, not by a date. The matrix keeps them distinct because the
50
+ // extractor validates the literal shape per cast and would otherwise reject one of them at push time.
51
+ const temporal = (cast: string, bound = "2026-12-31"): KindRow => ({
52
+ length: refusedAs("MaxLength is not supported"),
53
+ items: refusedAs("MaxItems is not supported"),
54
+ range: { sql: `${COL} <= '${bound}'::${cast}` },
55
+ rangeBound: bound,
56
+ })
57
+
58
+ const unbounded = (why: string): KindRow => ({
59
+ length: refusedAs(why),
60
+ items: refusedAs(why),
61
+ range: refusedAs(why),
62
+ })
63
+
64
+ const collection = (measure: string): KindRow => ({
65
+ length: refusedAs("MaxItems"),
66
+ items: { sql: measure },
67
+ range: refusedAs("Between is not supported"),
68
+ })
69
+
70
+ const MATRIX: Record<FieldKind, KindRow> = {
71
+ text: textual(),
72
+ email: textual(),
73
+ url: textual(),
74
+ slug: textual(),
75
+ color: textual(),
76
+ xml: textual(),
77
+ ip: textual(),
78
+ cidr: textual(),
79
+ macaddr: textual(),
80
+ tsQuery: textual(),
81
+ tsVector: textual(),
82
+
83
+ richText: {
84
+ length: { sql: `char_length(_supatype.richtext_text(${COL})) <= 5` },
85
+ items: refusedAs("MaxItems is not supported"),
86
+ range: refusedAs("Between is not supported"),
87
+ },
88
+ bytes: {
89
+ length: { sql: `octet_length(${COL}) <= 5` },
90
+ items: refusedAs("MaxItems is not supported"),
91
+ range: refusedAs("Between is not supported"),
92
+ },
93
+
94
+ integer: numeric(),
95
+ smallInt: numeric(),
96
+ bigInt: numeric(),
97
+ float: numeric(),
98
+ serial: numeric(),
99
+ bigSerial: numeric(),
100
+ decimal: numeric(),
101
+ money: numeric(),
102
+
103
+ datetime: temporal("timestamptz"),
104
+ timestamp: temporal("timestamp"),
105
+ date: temporal("date"),
106
+ interval: temporal("interval", "30 days"),
107
+
108
+ array: collection(`cardinality(${COL}) <= 5`),
109
+ blocks: collection(`jsonb_typeof(${COL}) = 'array' AND jsonb_array_length(${COL}) <= 5`),
110
+ json: unbounded("JSON object has no single measure"),
111
+ button: unbounded("composite value"),
112
+
113
+ enum: unbounded("union already constrains"),
114
+ boolean: unbounded("boolean has two values"),
115
+ uuid: unbounded("fixed width"),
116
+ image: unbounded("fileSizeLimit"),
117
+ file: unbounded("fileSizeLimit"),
118
+ geo: unbounded("not measured this way"),
119
+ vector: unbounded("dimension is already fixed"),
120
+ relation: unbounded("bound the column on the model"),
121
+ custom: unbounded("plugin field declares its own storage"),
122
+ timestamps: unbounded("composite expands into columns"),
123
+ publishable: unbounded("composite expands into columns"),
124
+ softDelete: unbounded("composite expands into columns"),
125
+ }
126
+
127
+ function assertCell(kind: FieldKind, family: string, cell: Cell, run: () => { check?: string }): void {
128
+ if ("refused" in cell) {
129
+ expect(
130
+ run,
131
+ `${kind}.${family} must refuse rather than silently accept a bound it cannot honour`,
132
+ ).toThrow(cell.refused)
133
+ return
134
+ }
135
+ const { check } = run()
136
+ expect(check, `${kind}.${family} must compile to its own expression`).toBe(cell.sql)
137
+ }
138
+
139
+ describe("bounds matrix", () => {
140
+ for (const [kind, row] of Object.entries(MATRIX) as Array<[FieldKind, KindRow]>) {
141
+ describe(kind, () => {
142
+ it("length", () => {
143
+ assertCell(kind, "length", row.length, () => compileBounds("f", kind, { maxLength: 5 }))
144
+ })
145
+
146
+ it("items", () => {
147
+ assertCell(kind, "items", row.items, () => compileBounds("f", kind, { maxItems: 5 }))
148
+ })
149
+
150
+ it("range", () => {
151
+ const max = row.rangeBound ?? 5
152
+ assertCell(kind, "range", row.range, () => compileBounds("f", kind, { max }))
153
+ })
154
+ })
155
+ }
156
+
157
+ it("treats JSON as a collection only when its type argument is an array", () => {
158
+ // The one cell the kind alone cannot decide: the CLI reads it from the declared type.
159
+ expect(compileBounds("f", "json", { maxItems: 5 }, { jsonIsArray: true }).check).toBe(
160
+ `jsonb_typeof(${COL}) = 'array' AND jsonb_array_length(${COL}) <= 5`,
161
+ )
162
+ })
163
+ })