@supatype/cli 0.1.11 → 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 (68) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +136 -130
  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/db-preflight.d.ts +23 -0
  20. package/dist/db-preflight.d.ts.map +1 -1
  21. package/dist/db-preflight.js +66 -1
  22. package/dist/db-preflight.js.map +1 -1
  23. package/dist/dev-compose.d.ts +1 -0
  24. package/dist/dev-compose.d.ts.map +1 -1
  25. package/dist/dev-compose.js +79 -10
  26. package/dist/dev-compose.js.map +1 -1
  27. package/dist/field-bounds.d.ts +68 -0
  28. package/dist/field-bounds.d.ts.map +1 -0
  29. package/dist/field-bounds.js +277 -0
  30. package/dist/field-bounds.js.map +1 -0
  31. package/dist/hooks-generator.d.ts +1 -1
  32. package/dist/hooks-generator.d.ts.map +1 -1
  33. package/dist/hooks-generator.js +78 -4
  34. package/dist/hooks-generator.js.map +1 -1
  35. package/dist/model-hooks.d.ts +44 -2
  36. package/dist/model-hooks.d.ts.map +1 -1
  37. package/dist/model-hooks.js +116 -12
  38. package/dist/model-hooks.js.map +1 -1
  39. package/dist/schema-ast-v2.d.ts +38 -4
  40. package/dist/schema-ast-v2.d.ts.map +1 -1
  41. package/dist/schema-ast-v2.js +87 -4
  42. package/dist/schema-ast-v2.js.map +1 -1
  43. package/dist/type-extractor.d.ts.map +1 -1
  44. package/dist/type-extractor.js +309 -27
  45. package/dist/type-extractor.js.map +1 -1
  46. package/package.json +4 -3
  47. package/src/cli-version-embedded.ts +1 -1
  48. package/src/commands/db.ts +27 -1
  49. package/src/commands/doctor.ts +30 -0
  50. package/src/commands/push.ts +26 -6
  51. package/src/compose-local-server-image.ts +17 -0
  52. package/src/db-preflight.ts +89 -1
  53. package/src/dev-compose.ts +96 -9
  54. package/src/field-bounds.ts +359 -0
  55. package/src/hooks-generator.ts +81 -4
  56. package/src/model-hooks.ts +158 -12
  57. package/src/schema-ast-v2.ts +114 -10
  58. package/src/type-extractor.ts +374 -39
  59. package/tests/db-preflight.test.ts +80 -0
  60. package/tests/field-bounds-matrix.test.ts +163 -0
  61. package/tests/field-bounds.test.ts +139 -0
  62. package/tests/field-validators.test.ts +139 -0
  63. package/tests/hooks-generator.test.ts +86 -0
  64. package/tests/local-server-image-env.test.ts +93 -0
  65. package/tests/model-constraints.test.ts +293 -0
  66. package/tests/model-hooks.test.ts +56 -0
  67. package/tests/type-extractor.test.ts +49 -0
  68. package/tsconfig.tsbuildinfo +1 -1
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
4
  import ts from "typescript";
5
5
  import { applyImportRename, createResolveContext, needsChecker, resolveTypeNode, tryResolveTypeReference, unknownTypeError, } from "./type-resolver.js";
6
6
  import { emitField, emitModel, emitSchema, defaultPgTypeForKind, scalar, } from "./schema-ast-v2.js";
7
+ import { compileBounds, measureFormFor } from "./field-bounds.js";
7
8
  export function extractSchemaAstFromTypes(schemaPath, cwd = process.cwd()) {
8
9
  const absPath = resolve(cwd, schemaPath);
9
10
  if (!existsSync(absPath)) {
@@ -64,8 +65,8 @@ export function extractSchemaAstFromTypes(schemaPath, cwd = process.cwd()) {
64
65
  continue;
65
66
  fields[name] = parseFieldType(name, member.type, sourceFile, blockAliases, bucketAliases, bucketsById, fieldContext, resolveCtx);
66
67
  }
67
- const { tableName, access, options, indexes, hooks } = parseModelMeta(metaArg, sourceFile, stmt.name.text, fieldsArg, fields, resolveCtx);
68
- models.push(emitModel(stmt.name.text, fields, options, tableName, access, indexes, hooks));
68
+ const { tableName, access, options, indexes, constraints, hooks, validators } = parseModelMeta(metaArg, sourceFile, stmt.name.text, fieldsArg, fields, resolveCtx);
69
+ models.push(emitModel(stmt.name.text, fields, options, tableName, access, indexes, hooks, constraints, validators));
69
70
  }
70
71
  }
71
72
  if (models.length === 0)
@@ -302,6 +303,52 @@ function parseDefaultLiteral(node, sourceFile) {
302
303
  }
303
304
  return undefined;
304
305
  }
306
+ /** Which {@link DeclaredBounds} key each length/item modifier fills. */
307
+ const BOUND_KEY_BY_MODIFIER = {
308
+ MaxLength: "maxLength",
309
+ MinLength: "minLength",
310
+ MaxItems: "maxItems",
311
+ MinItems: "minItems",
312
+ };
313
+ /**
314
+ * A `Between` bound: a number for a numeric column, an ISO-8601 string for a temporal one.
315
+ *
316
+ * Which of the two is legal is decided from the field's kind in `compileBounds`, not here, because
317
+ * the kind is not known until the wrappers are off.
318
+ */
319
+ function parseRangeBound(node, sourceFile) {
320
+ const asNumber = parseNumericTypeArg(node, sourceFile);
321
+ if (asNumber !== undefined)
322
+ return asNumber;
323
+ return literalStringType(node) ?? undefined;
324
+ }
325
+ /**
326
+ * Whether a `JSON<T>` field holds an array, which is what decides between item bounds and none.
327
+ *
328
+ * Answered from the declared type argument rather than guessed downstream: the engine sees only
329
+ * `JSONB` and cannot tell `JSON<Item[]>` from `JSON<{ a: string }>`, so `jsonb_array_length` would
330
+ * be a coin flip that raises at insert time when it loses.
331
+ */
332
+ function jsonTypeArgIsArray(node, sourceFile) {
333
+ if (!ts.isTypeReferenceNode(node))
334
+ return false;
335
+ const arg = node.typeArguments?.[0];
336
+ if (!arg)
337
+ return false;
338
+ return isArrayLikeTypeNode(arg, sourceFile);
339
+ }
340
+ function isArrayLikeTypeNode(node, sourceFile) {
341
+ if (ts.isArrayTypeNode(node))
342
+ return true;
343
+ // `readonly Item[]`
344
+ if (ts.isTypeOperatorNode(node) && node.operator === ts.SyntaxKind.ReadonlyKeyword) {
345
+ return isArrayLikeTypeNode(node.type, sourceFile);
346
+ }
347
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
348
+ return node.typeName.text === "Array" || node.typeName.text === "ReadonlyArray";
349
+ }
350
+ return false;
351
+ }
305
352
  function parseFieldType(fieldName, typeNode, sourceFile, blockAliases, bucketAliases, bucketsById, context = {}, resolveCtx) {
306
353
  const flags = {
307
354
  required: true,
@@ -318,7 +365,7 @@ function parseFieldType(fieldName, typeNode, sourceFile, blockAliases, bucketAli
318
365
  fieldDefault: undefined,
319
366
  localized: false,
320
367
  notLocalized: false,
321
- checkConstraint: undefined,
368
+ bounds: {},
322
369
  };
323
370
  const resolving = new Set();
324
371
  let current = typeNode;
@@ -390,31 +437,24 @@ function parseFieldType(fieldName, typeNode, sourceFile, blockAliases, bucketAli
390
437
  current = valueArg ?? current;
391
438
  continue;
392
439
  }
393
- case "MaxLength": {
394
- const max = parseNumericTypeArg(current.typeArguments?.[1], sourceFile);
395
- if (max !== undefined) {
396
- flags.checkConstraint = mergeCheckConstraint(flags.checkConstraint, `char_length("{name}") <= ${max}`);
397
- }
398
- current = current.typeArguments?.[0] ?? current;
399
- continue;
400
- }
401
- case "MinLength": {
402
- const min = parseNumericTypeArg(current.typeArguments?.[1], sourceFile);
403
- if (min !== undefined) {
404
- flags.checkConstraint = mergeCheckConstraint(flags.checkConstraint, `char_length("{name}") >= ${min}`);
440
+ case "MaxLength":
441
+ case "MinLength":
442
+ case "MaxItems":
443
+ case "MinItems": {
444
+ const amount = parseNumericTypeArg(current.typeArguments?.[1], sourceFile);
445
+ if (amount !== undefined) {
446
+ flags.bounds[BOUND_KEY_BY_MODIFIER[typeName]] = amount;
405
447
  }
406
448
  current = current.typeArguments?.[0] ?? current;
407
449
  continue;
408
450
  }
409
451
  case "Between": {
410
- const min = parseNumericTypeArg(current.typeArguments?.[1], sourceFile);
411
- const max = parseNumericTypeArg(current.typeArguments?.[2], sourceFile);
412
- if (min !== undefined) {
413
- flags.checkConstraint = mergeCheckConstraint(flags.checkConstraint, `"{name}"::numeric >= ${min}`);
414
- }
415
- if (max !== undefined) {
416
- flags.checkConstraint = mergeCheckConstraint(flags.checkConstraint, `"{name}"::numeric <= ${max}`);
417
- }
452
+ const min = parseRangeBound(current.typeArguments?.[1], sourceFile);
453
+ const max = parseRangeBound(current.typeArguments?.[2], sourceFile);
454
+ if (min !== undefined)
455
+ flags.bounds.min = min;
456
+ if (max !== undefined)
457
+ flags.bounds.max = max;
418
458
  current = current.typeArguments?.[0] ?? current;
419
459
  continue;
420
460
  }
@@ -577,10 +617,24 @@ function parseFieldType(fieldName, typeNode, sourceFile, blockAliases, bucketAli
577
617
  }
578
618
  parsed = { ...parsed, kernel };
579
619
  }
580
- if (flags.checkConstraint !== undefined) {
620
+ // Bounds compile here, not in the modifier cases above: `MaxLength<string[], 10>` is only knowably
621
+ // an item count once the wrappers are off and the kind is `array`. Compiling early is what made
622
+ // every bound `char_length`, which does not exist for an array and fails `CREATE TABLE`.
623
+ const jsonIsArray = parsed.kind === "json" && jsonTypeArgIsArray(current, sourceFile);
624
+ if (jsonIsArray) {
625
+ parsed = { ...parsed, kernel: { ...parsed.kernel, jsonArray: true } };
626
+ }
627
+ if (Object.keys(flags.bounds).length > 0) {
628
+ const compiled = compileBounds(fieldName, parsed.kind, flags.bounds, { jsonIsArray });
581
629
  parsed = {
582
630
  ...parsed,
583
- kernel: { ...parsed.kernel, check: flags.checkConstraint },
631
+ kernel: {
632
+ ...parsed.kernel,
633
+ ...(compiled.check !== undefined && {
634
+ check: mergeCheckConstraint(parsed.kernel.check, compiled.check),
635
+ }),
636
+ ...(compiled.validation !== undefined && { validation: compiled.validation }),
637
+ },
584
638
  };
585
639
  }
586
640
  return emitField(finalizeParsedField(parsed, flags, context));
@@ -1029,7 +1083,9 @@ function parsePartialBucketAccess(typeNode, sourceFile, bucketId, resolveCtx) {
1029
1083
  const key = getPropertyName(member.name);
1030
1084
  if (key !== "read" && key !== "create" && key !== "delete")
1031
1085
  continue;
1032
- access[key] = parseAccessRule(member.type, sourceFile, resolveCtx);
1086
+ const bucketRule = parseAccessRule(member.type, sourceFile, resolveCtx);
1087
+ assertAccessRuleIsRenderable(bucketRule, bucketId, key);
1088
+ access[key] = bucketRule;
1033
1089
  }
1034
1090
  return access;
1035
1091
  }
@@ -1320,9 +1376,128 @@ function parseModelMeta(metaArg, sourceFile, modelName, fieldsArg, fields, resol
1320
1376
  access: parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx),
1321
1377
  options,
1322
1378
  indexes: parseModelIndexes(metaArg, sourceFile, fields),
1379
+ constraints: parseModelConstraints(metaArg, sourceFile, modelName, fields, resolveCtx),
1323
1380
  hooks: parseModelHooks(metaArg, sourceFile),
1381
+ validators: parseModelValidators(metaArg, sourceFile, modelName, fields),
1324
1382
  };
1325
1383
  }
1384
+ /**
1385
+ * Operands a `CHECK` cannot evaluate, and why.
1386
+ *
1387
+ * All of these are legal in an access rule, which is exactly why they need refusing here rather
1388
+ * than left to fail at `CREATE TABLE`: the vocabulary is shared, so the mistake is easy and the
1389
+ * error Postgres gives for it names nothing useful.
1390
+ */
1391
+ const CONSTRAINT_FORBIDDEN_OPERANDS = {
1392
+ authUid: "a CHECK constraint cannot see who is writing",
1393
+ authRole: "a CHECK constraint cannot see who is writing",
1394
+ claim: "a CHECK constraint cannot read the caller's JWT",
1395
+ role: "a CHECK constraint cannot see the caller's role",
1396
+ now: "a row valid on insert would become invalid on update, so a constraint cannot read the clock",
1397
+ startOf: "a constraint cannot read the clock",
1398
+ ago: "a constraint cannot read the clock",
1399
+ fromNow: "a constraint cannot read the clock",
1400
+ rows: "a CHECK constraint cannot query another table",
1401
+ exists: "a CHECK constraint cannot query another table",
1402
+ };
1403
+ /**
1404
+ * Walk a parsed constraint node and refuse anything the database cannot enforce as a `CHECK`.
1405
+ *
1406
+ * Walks the parsed form rather than the syntax, so a node reached through `Any`, `All` or `Not`
1407
+ * is checked the same as a top-level one.
1408
+ */
1409
+ function assertConstraintIsEnforceable(node, model, index) {
1410
+ if (Array.isArray(node)) {
1411
+ for (const item of node)
1412
+ assertConstraintIsEnforceable(item, model, index);
1413
+ return;
1414
+ }
1415
+ if (typeof node !== "object" || node === null)
1416
+ return;
1417
+ const record = node;
1418
+ for (const key of ["type", "kind"]) {
1419
+ const value = record[key];
1420
+ if (typeof value !== "string")
1421
+ continue;
1422
+ const why = CONSTRAINT_FORBIDDEN_OPERANDS[value];
1423
+ if (why !== undefined) {
1424
+ throw new Error(`Model "${model}": constraint ${index + 1} uses \`${value}\`, which is not allowed in a ` +
1425
+ `constraint because ${why}. Move the rule to \`access\` if it depends on the caller.`);
1426
+ }
1427
+ }
1428
+ for (const value of Object.values(record))
1429
+ assertConstraintIsEnforceable(value, model, index);
1430
+ }
1431
+ /**
1432
+ * Resolve `Length<>` and `ItemCount<>` inside a constraint to the measure their column actually
1433
+ * takes, so the engine renders mechanically and never has to know about field kinds.
1434
+ *
1435
+ * Done here because this is the only layer that has both halves: the field's kind, and the table
1436
+ * the bounds modifiers already resolve against. The engine seeing `JSONB` cannot tell an array from
1437
+ * an object, and a second kind table in Rust is how `char_length(text[])` would come back in a new
1438
+ * file after being fixed once.
1439
+ */
1440
+ function resolveConstraintMeasures(node, fields, model, index) {
1441
+ if (Array.isArray(node)) {
1442
+ for (const item of node)
1443
+ resolveConstraintMeasures(item, fields, model, index);
1444
+ return;
1445
+ }
1446
+ if (typeof node !== "object" || node === null)
1447
+ return;
1448
+ const record = node;
1449
+ const kind = record["kind"];
1450
+ if (kind === "length" || kind === "itemCount") {
1451
+ const column = String(record["column"]);
1452
+ const field = fields[column];
1453
+ if (!field) {
1454
+ throw new Error(`Model "${model}": constraint ${index + 1} measures \`${column}\`, which is not a field on ` +
1455
+ "this model. Measures need a declared field, because the measure depends on its type.");
1456
+ }
1457
+ const measure = kind === "length" ? "length" : "items";
1458
+ const resolved = measureFormFor(field.kind, measure, {
1459
+ jsonIsArray: field["jsonArray"] === true,
1460
+ });
1461
+ if (resolved.form === undefined) {
1462
+ throw new Error(`Model "${model}": constraint ${index + 1} uses ` +
1463
+ `\`${kind === "length" ? "Length" : "ItemCount"}<"${column}">\`, but ` +
1464
+ `${resolved.instead}.`);
1465
+ }
1466
+ record["form"] = resolved.form;
1467
+ return;
1468
+ }
1469
+ for (const value of Object.values(record)) {
1470
+ resolveConstraintMeasures(value, fields, model, index);
1471
+ }
1472
+ }
1473
+ /**
1474
+ * Read `constraints` from a model's meta.
1475
+ *
1476
+ * Reuses `parseAccessRule`, because a constraint *is* an access rule with a narrower operand set:
1477
+ * a second parser for the same node vocabulary would be two grammars to keep aligned, and they
1478
+ * would drift the first time either gained a node.
1479
+ */
1480
+ function parseModelConstraints(metaArg, sourceFile, modelName, fields, resolveCtx) {
1481
+ if (!metaArg || !ts.isTypeLiteralNode(metaArg))
1482
+ return [];
1483
+ const prop = metaArg.members.find((member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "constraints");
1484
+ if (!prop || !ts.isPropertySignature(prop) || !prop.type)
1485
+ return [];
1486
+ if (!ts.isTupleTypeNode(prop.type)) {
1487
+ throw new Error(`Model "${modelName}": \`constraints\` must be a tuple, as in ` +
1488
+ `\`constraints: [Lte<"starts_at", "ends_at">]\`.`);
1489
+ }
1490
+ return prop.type.elements.map((element, index) => {
1491
+ const parsed = parseAccessRule(element, sourceFile, resolveCtx);
1492
+ if (parsed["type"] === "private") {
1493
+ throw new Error(`Model "${modelName}": constraint ${index + 1}, ` +
1494
+ `\`${ownerText(element, sourceFile)}\`, is not a rule this vocabulary knows.`);
1495
+ }
1496
+ assertConstraintIsEnforceable(parsed, modelName, index);
1497
+ resolveConstraintMeasures(parsed, fields, modelName, index);
1498
+ return parsed;
1499
+ });
1500
+ }
1326
1501
  const HOOK_EVENTS = ["beforeChange", "afterChange", "beforeDelete", "afterDelete"];
1327
1502
  /**
1328
1503
  * Read `hooks` from a model's meta.
@@ -1332,6 +1507,48 @@ const HOOK_EVENTS = ["beforeChange", "afterChange", "beforeDelete", "afterDelete
1332
1507
  * silently not firing is the failure this feature cannot have, so an unreadable declaration must
1333
1508
  * fail the push rather than extract to nothing.
1334
1509
  */
1510
+ /**
1511
+ * Read `validate` from a model's meta: field name to the function that checks it.
1512
+ *
1513
+ * Strict for the same reason `parseModelHooks` is: a validator that silently never fires is the
1514
+ * failure this feature cannot have. An entry that is neither a string nor an object with a
1515
+ * `function` name is dropped here and reported by `validateModelHooks`, which fails the push.
1516
+ *
1517
+ * Keyed by the **field** as written, not the column: the extractor resolves the two, and a validator
1518
+ * naming a field the model does not declare is an error worth catching here where the message can
1519
+ * list what the model does have.
1520
+ */
1521
+ function parseModelValidators(metaArg, sourceFile, modelName, fields) {
1522
+ if (!metaArg || !ts.isTypeLiteralNode(metaArg))
1523
+ return {};
1524
+ const prop = metaArg.members.find((member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "validate");
1525
+ if (!prop || !ts.isPropertySignature(prop) || !prop.type)
1526
+ return {};
1527
+ if (!ts.isTypeLiteralNode(prop.type)) {
1528
+ throw new Error(`Model "${modelName}": \`validate\` must be an object mapping a field to a function name, ` +
1529
+ 'as in `validate: { setupItems: "validate-setup-items" }`.');
1530
+ }
1531
+ const out = {};
1532
+ for (const member of prop.type.members) {
1533
+ if (!ts.isPropertySignature(member) || !member.type)
1534
+ continue;
1535
+ const field = getPropertyName(member.name);
1536
+ if (!field)
1537
+ continue;
1538
+ if (!Object.prototype.hasOwnProperty.call(fields, field)) {
1539
+ const known = Object.keys(fields).join(", ");
1540
+ throw new Error(`Model "${modelName}": \`validate\` names "${field}", which is not a field on this model. ` +
1541
+ `Fields are: ${known}.`);
1542
+ }
1543
+ const parsed = parseModelHookValue(member.type, sourceFile);
1544
+ if (parsed === null) {
1545
+ throw new Error(`Model "${modelName}": the validator for "${field}" must be a function name, or an object ` +
1546
+ 'with a `function` name, as in `{ function: "check-it", timeout: 5000 }`.');
1547
+ }
1548
+ out[field] = parsed;
1549
+ }
1550
+ return out;
1551
+ }
1335
1552
  function parseModelHooks(metaArg, sourceFile) {
1336
1553
  if (!metaArg || !ts.isTypeLiteralNode(metaArg))
1337
1554
  return {};
@@ -1455,6 +1672,37 @@ function resolveIndexFieldName(fieldName, fields) {
1455
1672
  * unsupported shape silently published a table with no row-level protection at
1456
1673
  * all. Failing the extract is the only safe outcome.
1457
1674
  */
1675
+ /**
1676
+ * Nodes that parse but that the engine's RLS renderer does not know.
1677
+ *
1678
+ * `Length`, `ItemCount` and `Matches` exist for `constraints`, and they share a parser with access
1679
+ * rules because a constraint *is* an access rule with a narrower operand set. That reuse runs both
1680
+ * ways: nothing stops someone writing `Gte<Length<"title">, Literal<5>>` in an `access` block, where
1681
+ * it would parse cleanly, reach an engine that has no case for it, and produce a policy that does
1682
+ * not say what the author wrote. Refusing here keeps the sharing honest until the RLS renderer
1683
+ * learns them.
1684
+ */
1685
+ const CONSTRAINT_ONLY_NODES = new Set(["length", "itemCount", "matches"]);
1686
+ function assertAccessRuleIsRenderable(node, model, operation) {
1687
+ if (Array.isArray(node)) {
1688
+ for (const item of node)
1689
+ assertAccessRuleIsRenderable(item, model, operation);
1690
+ return;
1691
+ }
1692
+ if (typeof node !== "object" || node === null)
1693
+ return;
1694
+ const record = node;
1695
+ // Operands are tagged `kind`, rules `type`, so both are checked: `Matches` is a rule.
1696
+ const tag = [record["kind"], record["type"]].find((value) => typeof value === "string" && CONSTRAINT_ONLY_NODES.has(value));
1697
+ if (typeof tag === "string") {
1698
+ const spelled = { length: "Length", itemCount: "ItemCount", matches: "Matches" }[tag] ?? tag;
1699
+ throw new Error(`Model "${model}": \`${spelled}<>\` is not supported in an \`access\` rule (\`${operation}\`). ` +
1700
+ "It belongs in `constraints`, which the database enforces for every writer.");
1701
+ }
1702
+ for (const value of Object.values(record)) {
1703
+ assertAccessRuleIsRenderable(value, model, operation);
1704
+ }
1705
+ }
1458
1706
  function parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx) {
1459
1707
  if (!metaArg || !ts.isTypeLiteralNode(metaArg))
1460
1708
  return {};
@@ -1497,7 +1745,9 @@ function parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx) {
1497
1745
  access["fields"] = fieldRules;
1498
1746
  continue;
1499
1747
  }
1500
- access[key] = parseAccessRule(member.type, sourceFile, resolveCtx);
1748
+ const rule = parseAccessRule(member.type, sourceFile, resolveCtx);
1749
+ assertAccessRuleIsRenderable(rule, modelName, key);
1750
+ access[key] = rule;
1501
1751
  }
1502
1752
  if (Object.keys(access).length === 0) {
1503
1753
  throw new Error(`Model "${modelName}": \`access\` resolved to an empty rule set ` +
@@ -1767,6 +2017,30 @@ function parseAccessRule(typeNode, sourceFile, resolveCtx, depth = 0) {
1767
2017
  right: parseAccessOperand(args[1], sourceFile),
1768
2018
  };
1769
2019
  }
2020
+ case "Matches": {
2021
+ const args = typeNode.typeArguments ?? [];
2022
+ if (args.length !== 2) {
2023
+ throw new Error('`Matches<>` takes a column and a pattern, as in `Matches<"sku", "^[A-Z]{3}$">`.');
2024
+ }
2025
+ const column = stringLiteralArg(args[0], "Matches", "column");
2026
+ const pattern = literalStringType(args[1]);
2027
+ if (pattern === null) {
2028
+ throw new Error('`Matches<>` needs a string literal pattern, as in `Matches<"sku", "^[A-Z]{3}$">`.');
2029
+ }
2030
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
2031
+ throw new Error(`\`Matches<"${column}", ...>\` does not name a valid column.`);
2032
+ }
2033
+ // Validated here so an unparseable pattern is a CLI error naming the field, rather than a
2034
+ // Postgres error part way through a migration. Postgres regex is POSIX and JavaScript's is
2035
+ // not, so this catches malformed patterns, not every dialect difference.
2036
+ try {
2037
+ new RegExp(pattern);
2038
+ }
2039
+ catch {
2040
+ throw new Error(`\`Matches<"${column}", "${pattern}">\`: the pattern is not a valid regular expression.`);
2041
+ }
2042
+ return { type: "matches", column, pattern };
2043
+ }
1770
2044
  case "IsNull":
1771
2045
  case "NotNull": {
1772
2046
  const operand = typeNode.typeArguments?.[0];
@@ -2020,6 +2294,14 @@ function parseAccessOperand(typeNode, sourceFile) {
2020
2294
  const ref = ownerText(typeNode.typeName, sourceFile);
2021
2295
  const operandArgs = typeNode.typeArguments ?? [];
2022
2296
  switch (ref) {
2297
+ case "Length":
2298
+ case "ItemCount": {
2299
+ const column = stringLiteralArg(operandArgs[0], ref, "column");
2300
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
2301
+ throw new Error(`\`${ref}<"${column}">\` does not name a valid column.`);
2302
+ }
2303
+ return { kind: ref === "Length" ? "length" : "itemCount", column };
2304
+ }
2023
2305
  case "AuthUid":
2024
2306
  return { kind: "authUid" };
2025
2307
  case "AuthRole":