@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +138 -132
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +23 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/doctor.d.ts +0 -7
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +26 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +15 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/compose-local-server-image.d.ts +11 -0
- package/dist/compose-local-server-image.d.ts.map +1 -1
- package/dist/compose-local-server-image.js +18 -0
- package/dist/compose-local-server-image.js.map +1 -1
- package/dist/dev-compose.d.ts +1 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +79 -10
- package/dist/dev-compose.js.map +1 -1
- package/dist/field-bounds.d.ts +68 -0
- package/dist/field-bounds.d.ts.map +1 -0
- package/dist/field-bounds.js +277 -0
- package/dist/field-bounds.js.map +1 -0
- package/dist/hooks-generator.d.ts +1 -1
- package/dist/hooks-generator.d.ts.map +1 -1
- package/dist/hooks-generator.js +78 -4
- package/dist/hooks-generator.js.map +1 -1
- package/dist/model-hooks.d.ts +44 -2
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +116 -12
- package/dist/model-hooks.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +38 -4
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +87 -4
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +309 -27
- package/dist/type-extractor.js.map +1 -1
- package/package.json +4 -3
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/db.ts +27 -1
- package/src/commands/doctor.ts +30 -0
- package/src/commands/push.ts +26 -6
- package/src/compose-local-server-image.ts +17 -0
- package/src/dev-compose.ts +96 -9
- package/src/field-bounds.ts +359 -0
- package/src/hooks-generator.ts +81 -4
- package/src/model-hooks.ts +158 -12
- package/src/schema-ast-v2.ts +114 -10
- package/src/type-extractor.ts +374 -39
- package/tests/field-bounds-matrix.test.ts +163 -0
- package/tests/field-bounds.test.ts +139 -0
- package/tests/field-validators.test.ts +139 -0
- package/tests/hooks-generator.test.ts +86 -0
- package/tests/local-server-image-env.test.ts +93 -0
- package/tests/model-constraints.test.ts +293 -0
- package/tests/model-hooks.test.ts +56 -0
- package/tests/type-extractor.test.ts +49 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/type-extractor.ts
CHANGED
|
@@ -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 } =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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 =
|
|
511
|
-
const max =
|
|
512
|
-
if (min !== undefined)
|
|
513
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|