@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/dist/type-extractor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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 =
|
|
411
|
-
const max =
|
|
412
|
-
if (min !== undefined)
|
|
413
|
-
flags.
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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":
|