@supatype/cli 0.1.6 → 0.1.7
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 +144 -94
- package/.turbo/turbo-typecheck.log +1 -1
- package/deno.d.ts +52 -0
- package/dist/augmentation-generator.d.ts +22 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +18 -4
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/binary-cache.d.ts +14 -0
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +33 -13
- package/dist/binary-cache.js.map +1 -1
- package/dist/cache-clean.d.ts +15 -0
- package/dist/cache-clean.d.ts.map +1 -0
- package/dist/cache-clean.js +76 -0
- package/dist/cache-clean.js.map +1 -0
- package/dist/cache-pins.d.ts +51 -0
- package/dist/cache-pins.d.ts.map +1 -0
- package/dist/cache-pins.js +184 -0
- package/dist/cache-pins.js.map +1 -0
- package/dist/cli-install-method.d.ts +19 -0
- package/dist/cli-install-method.d.ts.map +1 -0
- package/dist/cli-install-method.js +60 -0
- package/dist/cli-install-method.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-credentials.d.ts +34 -0
- package/dist/cloud-credentials.d.ts.map +1 -0
- package/dist/cloud-credentials.js +139 -0
- package/dist/cloud-credentials.js.map +1 -0
- package/dist/commands/admin.d.ts +11 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +121 -19
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +67 -71
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +32 -8
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/db.d.ts +1 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +147 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +50 -10
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +28 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +60 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/functions.d.ts +7 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +40 -6
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/hooks.d.ts +12 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +118 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +41 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/internal.d.ts +6 -0
- package/dist/commands/internal.d.ts.map +1 -0
- package/dist/commands/internal.js +22 -0
- package/dist/commands/internal.js.map +1 -0
- package/dist/commands/link-helpers.d.ts.map +1 -1
- package/dist/commands/link-helpers.js +8 -2
- package/dist/commands/link-helpers.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +110 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-update.d.ts +7 -0
- package/dist/commands/self-update.d.ts.map +1 -1
- package/dist/commands/self-update.js +18 -11
- package/dist/commands/self-update.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/dist/db-preflight.d.ts +78 -0
- package/dist/db-preflight.d.ts.map +1 -0
- package/dist/db-preflight.js +452 -0
- package/dist/db-preflight.js.map +1 -0
- package/dist/dev-compose.d.ts +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +127 -72
- package/dist/dev-compose.js.map +1 -1
- package/dist/env-file.d.ts +40 -2
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +132 -11
- package/dist/env-file.js.map +1 -1
- package/dist/field-masking-tier.d.ts +50 -0
- package/dist/field-masking-tier.d.ts.map +1 -0
- package/dist/field-masking-tier.js +91 -0
- package/dist/field-masking-tier.js.map +1 -0
- package/dist/functions-deno-types.d.ts +24 -0
- package/dist/functions-deno-types.d.ts.map +1 -0
- package/dist/functions-deno-types.js +155 -0
- package/dist/functions-deno-types.js.map +1 -0
- package/dist/hook-upload.d.ts +30 -0
- package/dist/hook-upload.d.ts.map +1 -0
- package/dist/hook-upload.js +86 -0
- package/dist/hook-upload.js.map +1 -0
- package/dist/hooks-generator.d.ts +8 -0
- package/dist/hooks-generator.d.ts.map +1 -0
- package/dist/hooks-generator.js +332 -0
- package/dist/hooks-generator.js.map +1 -0
- package/dist/link.d.ts +4 -0
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +2 -0
- package/dist/link.js.map +1 -1
- package/dist/local-secrets.d.ts +71 -0
- package/dist/local-secrets.d.ts.map +1 -0
- package/dist/local-secrets.js +100 -0
- package/dist/local-secrets.js.map +1 -0
- package/dist/model-hooks.d.ts +73 -0
- package/dist/model-hooks.d.ts.map +1 -0
- package/dist/model-hooks.js +211 -0
- package/dist/model-hooks.js.map +1 -0
- package/dist/postgres-ctl.d.ts +11 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +25 -2
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +121 -1
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +150 -1
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +4 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js +69 -73
- package/dist/resolve-target.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +2 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +4 -2
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/schema-push-lock.d.ts.map +1 -1
- package/dist/schema-push-lock.js +43 -16
- package/dist/schema-push-lock.js.map +1 -1
- package/dist/self-host-compose.d.ts +24 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +301 -69
- package/dist/self-host-compose.js.map +1 -1
- package/dist/service-role-check.d.ts +21 -0
- package/dist/service-role-check.d.ts.map +1 -0
- package/dist/service-role-check.js +85 -0
- package/dist/service-role-check.js.map +1 -0
- package/dist/target-client.d.ts +17 -0
- package/dist/target-client.d.ts.map +1 -1
- package/dist/target-client.js +69 -17
- package/dist/target-client.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +736 -32
- package/dist/type-extractor.js.map +1 -1
- package/dist/ui/runtime/command-chrome.js +1 -1
- package/dist/ui/runtime/command-chrome.js.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.js +3 -1
- package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
- package/package.json +4 -1
- package/scripts/generate-homebrew-formula.mjs +121 -0
- package/src/augmentation-generator.ts +18 -5
- package/src/binary-cache.ts +47 -15
- package/src/cache-clean.ts +81 -0
- package/src/cache-pins.ts +243 -0
- package/src/cli-install-method.ts +67 -0
- package/src/cli.ts +6 -0
- package/src/cloud-credentials.ts +183 -0
- package/src/commands/admin.ts +140 -25
- package/src/commands/cache.ts +90 -75
- package/src/commands/cloud.ts +32 -8
- package/src/commands/db.ts +162 -1
- package/src/commands/dev.ts +57 -8
- package/src/commands/doctor.ts +69 -3
- package/src/commands/functions.ts +42 -5
- package/src/commands/generate.ts +5 -1
- package/src/commands/hooks.ts +138 -0
- package/src/commands/init.ts +42 -8
- package/src/commands/internal.ts +23 -0
- package/src/commands/link-helpers.ts +9 -2
- package/src/commands/login.ts +127 -0
- package/src/commands/push.ts +124 -7
- package/src/commands/self-update.ts +85 -13
- package/src/config.ts +22 -3
- package/src/db-preflight.ts +575 -0
- package/src/dev-compose.ts +142 -73
- package/src/env-file.ts +157 -11
- package/src/field-masking-tier.ts +107 -0
- package/src/functions-deno-types.ts +188 -0
- package/src/hook-upload.ts +107 -0
- package/src/hooks-generator.ts +352 -0
- package/src/link.ts +7 -0
- package/src/local-secrets.ts +104 -0
- package/src/model-hooks.ts +260 -0
- package/src/postgres-ctl.ts +29 -2
- package/src/project-config.ts +236 -2
- package/src/resolve-target.ts +113 -68
- package/src/schema-ast-v2.ts +5 -2
- package/src/schema-push-lock.ts +56 -17
- package/src/self-host-compose.ts +344 -68
- package/src/service-role-check.ts +111 -0
- package/src/target-client.ts +105 -18
- package/src/type-extractor.ts +883 -28
- package/src/ui/runtime/command-chrome.ts +1 -1
- package/src/ui/runtime/stdin-after-ink.ts +3 -1
- package/tests/access-composition.test.ts +667 -0
- package/tests/admin-studio-membership.test.ts +121 -0
- package/tests/api-schemas.test.ts +83 -0
- package/tests/archive-name-alias.test.ts +42 -0
- package/tests/augmentation-generator.test.ts +28 -0
- package/tests/cache-clean.test.ts +206 -0
- package/tests/cli-help.test.ts +3 -0
- package/tests/cli-install-method.test.ts +66 -0
- package/tests/command-chrome-coverage.test.ts +31 -26
- package/tests/command-chrome-wrap.test.ts +1 -0
- package/tests/db-preflight.test.ts +72 -0
- package/tests/doctor-render.test.ts +62 -0
- package/tests/doctor-service-role.test.ts +55 -0
- package/tests/env-file.test.ts +121 -0
- package/tests/external-database-compose.test.ts +184 -0
- package/tests/external-database-config.test.ts +154 -0
- package/tests/field-masking-tier.test.ts +184 -0
- package/tests/functions-deno-types.test.ts +197 -0
- package/tests/homebrew-formula.test.ts +58 -0
- package/tests/hook-upload.test.ts +125 -0
- package/tests/hooks-generator.test.ts +212 -0
- package/tests/init.test.ts +3 -0
- package/tests/local-secrets.test.ts +92 -0
- package/tests/model-hooks.test.ts +322 -0
- package/tests/runtime-contract.test.ts +113 -1
- package/tests/service-role-check.test.ts +109 -0
- package/tests/service-role-routes.test.ts +51 -0
- package/tests/target-client-refresh.test.ts +94 -0
- package/tests/type-extractor.test.ts +187 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tests/engine-distribution.test.ts +0 -418
package/dist/type-extractor.js
CHANGED
|
@@ -14,7 +14,7 @@ export function extractSchemaAstFromTypes(schemaPath, cwd = process.cwd()) {
|
|
|
14
14
|
const bucketAliases = new Map();
|
|
15
15
|
const bucketsById = new Map();
|
|
16
16
|
for (const sourceFile of sourceFiles) {
|
|
17
|
-
const bucketContext = collectBucketContext(sourceFile);
|
|
17
|
+
const bucketContext = collectBucketContext(sourceFile, resolveCtx);
|
|
18
18
|
for (const [alias, bucketId] of bucketContext.aliases) {
|
|
19
19
|
bucketAliases.set(alias, bucketId);
|
|
20
20
|
}
|
|
@@ -64,8 +64,8 @@ export function extractSchemaAstFromTypes(schemaPath, cwd = process.cwd()) {
|
|
|
64
64
|
continue;
|
|
65
65
|
fields[name] = parseFieldType(name, member.type, sourceFile, blockAliases, bucketAliases, bucketsById, fieldContext, resolveCtx);
|
|
66
66
|
}
|
|
67
|
-
const { tableName, access, options, indexes } = parseModelMeta(metaArg, sourceFile, stmt.name.text, fieldsArg, fields);
|
|
68
|
-
models.push(emitModel(stmt.name.text, fields, options, tableName, access, indexes));
|
|
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));
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
if (models.length === 0)
|
|
@@ -713,8 +713,19 @@ function parseScalarType(typeNode, sourceFile, blockAliases, bucketAliases, buck
|
|
|
713
713
|
return scalar("tsVector");
|
|
714
714
|
case "Money":
|
|
715
715
|
return scalar("money");
|
|
716
|
-
case "Decimal":
|
|
717
|
-
|
|
716
|
+
case "Decimal": {
|
|
717
|
+
// `Decimal<10, 2>` names a precision and a scale, and the engine renders `NUMERIC(p, s)`
|
|
718
|
+
// from them — but nothing used to read the type arguments, so every Decimal became an
|
|
719
|
+
// unbounded NUMERIC. Silently, which is the worst way to lose a constraint on a money column.
|
|
720
|
+
const precision = parseNumericTypeArg(typeNode.typeArguments?.[0], sourceFile);
|
|
721
|
+
const scale = parseNumericTypeArg(typeNode.typeArguments?.[1], sourceFile);
|
|
722
|
+
return scalar("decimal", {
|
|
723
|
+
kernel: {
|
|
724
|
+
...(precision !== undefined && { precision }),
|
|
725
|
+
...(scale !== undefined && { scale }),
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
}
|
|
718
729
|
case "DateOnly":
|
|
719
730
|
return scalar("date");
|
|
720
731
|
case "Date":
|
|
@@ -736,7 +747,35 @@ function parseScalarType(typeNode, sourceFile, blockAliases, bucketAliases, buck
|
|
|
736
747
|
case "Button":
|
|
737
748
|
return scalar("button", { db: { pgType: "JSONB" } });
|
|
738
749
|
case "Duration":
|
|
739
|
-
return scalar("json", {
|
|
750
|
+
return scalar("json", {
|
|
751
|
+
db: { pgType: "JSONB" },
|
|
752
|
+
platform: { editor: "duration" },
|
|
753
|
+
kernel: { tsType: "{ ms: number }" },
|
|
754
|
+
});
|
|
755
|
+
// `Code` and `Currency` each carry two values, so a scalar column would have to drop one:
|
|
756
|
+
// JSONB keeps `lang` with its `source`, and an amount with the currency it is denominated in.
|
|
757
|
+
// `tsType` is what stops the generated client row from flattening them to an opaque object.
|
|
758
|
+
case "Code": {
|
|
759
|
+
const lang = literalStringType(typeNode.typeArguments?.[0]);
|
|
760
|
+
return scalar("json", {
|
|
761
|
+
db: { pgType: "JSONB" },
|
|
762
|
+
// `editor` picks the Studio widget. Without it a code snippet and a money amount both get
|
|
763
|
+
// the raw JSON editor, because the widget is otherwise chosen from the column kind alone
|
|
764
|
+
// and both of these are JSONB.
|
|
765
|
+
platform: { editor: "code" },
|
|
766
|
+
kernel: { tsType: `{ lang: ${lang !== null ? JSON.stringify(lang) : "string"}; source: string }` },
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
case "Currency": {
|
|
770
|
+
const currencyCode = literalStringType(typeNode.typeArguments?.[0]);
|
|
771
|
+
return scalar("json", {
|
|
772
|
+
db: { pgType: "JSONB" },
|
|
773
|
+
platform: { editor: "currency" },
|
|
774
|
+
kernel: {
|
|
775
|
+
tsType: `{ amount: string; code: ${currencyCode !== null ? JSON.stringify(currencyCode) : "string"} }`,
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
}
|
|
740
779
|
case "GeoPoint":
|
|
741
780
|
case "Geo":
|
|
742
781
|
return scalar("geo", { kernel: { geoType: "point", srid: 4326 } });
|
|
@@ -842,7 +881,7 @@ function parseStringLiteralTuple(node, sourceFile) {
|
|
|
842
881
|
}
|
|
843
882
|
return out;
|
|
844
883
|
}
|
|
845
|
-
function collectBucketContext(sourceFile) {
|
|
884
|
+
function collectBucketContext(sourceFile, resolveCtx) {
|
|
846
885
|
const aliases = new Map();
|
|
847
886
|
const bucketsById = new Map();
|
|
848
887
|
for (const stmt of sourceFile.statements) {
|
|
@@ -858,7 +897,7 @@ function collectBucketContext(sourceFile) {
|
|
|
858
897
|
const id = nameArg.literal.text;
|
|
859
898
|
aliases.set(stmt.name.text, id);
|
|
860
899
|
const parsed = configArg && ts.isTypeLiteralNode(configArg)
|
|
861
|
-
? parseBucketTypeLiteral(configArg, sourceFile)
|
|
900
|
+
? parseBucketTypeLiteral(configArg, sourceFile, id, resolveCtx)
|
|
862
901
|
: {};
|
|
863
902
|
const next = buildExtractedBucketAst(id, parsed);
|
|
864
903
|
const existing = bucketsById.get(id);
|
|
@@ -886,7 +925,7 @@ function buildExtractedBucketAst(id, parsed) {
|
|
|
886
925
|
};
|
|
887
926
|
return row;
|
|
888
927
|
}
|
|
889
|
-
function parseBucketTypeLiteral(lit, sourceFile) {
|
|
928
|
+
function parseBucketTypeLiteral(lit, sourceFile, bucketId, resolveCtx) {
|
|
890
929
|
const out = {};
|
|
891
930
|
for (const member of lit.members) {
|
|
892
931
|
if (!ts.isPropertySignature(member) || !member.type)
|
|
@@ -915,7 +954,7 @@ function parseBucketTypeLiteral(lit, sourceFile) {
|
|
|
915
954
|
continue;
|
|
916
955
|
}
|
|
917
956
|
if (key === "access") {
|
|
918
|
-
const acc = parsePartialBucketAccess(member.type, sourceFile);
|
|
957
|
+
const acc = parsePartialBucketAccess(member.type, sourceFile, bucketId, resolveCtx);
|
|
919
958
|
if (acc !== undefined && Object.keys(acc).length > 0)
|
|
920
959
|
out.access = acc;
|
|
921
960
|
continue;
|
|
@@ -971,17 +1010,26 @@ function parseMimeAcceptList(typeNode, sourceFile) {
|
|
|
971
1010
|
}
|
|
972
1011
|
return undefined;
|
|
973
1012
|
}
|
|
974
|
-
function parsePartialBucketAccess(typeNode, sourceFile) {
|
|
975
|
-
|
|
976
|
-
|
|
1013
|
+
function parsePartialBucketAccess(typeNode, sourceFile, bucketId, resolveCtx) {
|
|
1014
|
+
// Same hazard as model access: silently dropping these rules publishes a
|
|
1015
|
+
// bucket with no read/write restrictions, so an alias must resolve and an
|
|
1016
|
+
// unresolvable annotation must fail rather than degrade to "no rules".
|
|
1017
|
+
const literal = resolveAccessLiteral(typeNode, sourceFile, resolveCtx);
|
|
1018
|
+
if (!literal) {
|
|
1019
|
+
throw new Error(`Bucket "${bucketId}": could not resolve its \`access\` rules from ` +
|
|
1020
|
+
`\`${typeNode.getText(sourceFile)}\`.\n` +
|
|
1021
|
+
` access must be an object type, or a type alias for one, e.g.\n` +
|
|
1022
|
+
` export type MediaAccess = { read: Public; create: Authenticated }\n` +
|
|
1023
|
+
` Refusing to continue: an unresolved access block would leave this bucket unrestricted.`);
|
|
1024
|
+
}
|
|
977
1025
|
const access = {};
|
|
978
|
-
for (const member of
|
|
1026
|
+
for (const member of literal.members) {
|
|
979
1027
|
if (!ts.isPropertySignature(member) || !member.type)
|
|
980
1028
|
continue;
|
|
981
1029
|
const key = getPropertyName(member.name);
|
|
982
1030
|
if (key !== "read" && key !== "create" && key !== "delete")
|
|
983
1031
|
continue;
|
|
984
|
-
access[key] = parseAccessRule(member.type, sourceFile);
|
|
1032
|
+
access[key] = parseAccessRule(member.type, sourceFile, resolveCtx);
|
|
985
1033
|
}
|
|
986
1034
|
return access;
|
|
987
1035
|
}
|
|
@@ -1249,7 +1297,7 @@ function hasCompositeWrapper(typeNode, wrapperName) {
|
|
|
1249
1297
|
}
|
|
1250
1298
|
return false;
|
|
1251
1299
|
}
|
|
1252
|
-
function parseModelMeta(metaArg, sourceFile, modelName, fieldsArg, fields) {
|
|
1300
|
+
function parseModelMeta(metaArg, sourceFile, modelName, fieldsArg, fields, resolveCtx) {
|
|
1253
1301
|
const literal = parseMetaLiteral(metaArg, sourceFile);
|
|
1254
1302
|
const singleton = literal.singleton === true;
|
|
1255
1303
|
const tableName = literal.tableName ?? (singleton ? `_global_${toSnakeCase(modelName)}` : toSnakeCase(modelName));
|
|
@@ -1269,9 +1317,76 @@ function parseModelMeta(metaArg, sourceFile, modelName, fieldsArg, fields) {
|
|
|
1269
1317
|
options.autoLocalize = true;
|
|
1270
1318
|
return {
|
|
1271
1319
|
tableName,
|
|
1272
|
-
access: parseModelAccess(metaArg, sourceFile),
|
|
1320
|
+
access: parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx),
|
|
1273
1321
|
options,
|
|
1274
1322
|
indexes: parseModelIndexes(metaArg, sourceFile, fields),
|
|
1323
|
+
hooks: parseModelHooks(metaArg, sourceFile),
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
const HOOK_EVENTS = ["beforeChange", "afterChange", "beforeDelete", "afterDelete"];
|
|
1327
|
+
/**
|
|
1328
|
+
* Read `hooks` from a model's meta.
|
|
1329
|
+
*
|
|
1330
|
+
* Deliberately strict: an entry that is neither a string nor an object with a `function` name is
|
|
1331
|
+
* **dropped**, and `validateModelHooks` then reports the model whose hook did not survive. A hook
|
|
1332
|
+
* silently not firing is the failure this feature cannot have, so an unreadable declaration must
|
|
1333
|
+
* fail the push rather than extract to nothing.
|
|
1334
|
+
*/
|
|
1335
|
+
function parseModelHooks(metaArg, sourceFile) {
|
|
1336
|
+
if (!metaArg || !ts.isTypeLiteralNode(metaArg))
|
|
1337
|
+
return {};
|
|
1338
|
+
const hooksProp = metaArg.members.find((member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "hooks");
|
|
1339
|
+
if (!hooksProp || !ts.isPropertySignature(hooksProp) || !hooksProp.type)
|
|
1340
|
+
return {};
|
|
1341
|
+
if (!ts.isTypeLiteralNode(hooksProp.type))
|
|
1342
|
+
return {};
|
|
1343
|
+
const hooks = {};
|
|
1344
|
+
for (const member of hooksProp.type.members) {
|
|
1345
|
+
if (!ts.isPropertySignature(member) || !member.type)
|
|
1346
|
+
continue;
|
|
1347
|
+
const event = getPropertyName(member.name);
|
|
1348
|
+
if (!event || !HOOK_EVENTS.includes(event))
|
|
1349
|
+
continue;
|
|
1350
|
+
const parsed = parseModelHookValue(member.type, sourceFile);
|
|
1351
|
+
if (parsed !== null)
|
|
1352
|
+
hooks[event] = parsed;
|
|
1353
|
+
}
|
|
1354
|
+
return hooks;
|
|
1355
|
+
}
|
|
1356
|
+
function parseModelHookValue(node, sourceFile) {
|
|
1357
|
+
const literal = literalStringType(node);
|
|
1358
|
+
if (literal !== null) {
|
|
1359
|
+
return literal.trim().length > 0 ? { function: literal } : null;
|
|
1360
|
+
}
|
|
1361
|
+
if (!ts.isTypeLiteralNode(node))
|
|
1362
|
+
return null;
|
|
1363
|
+
let fn;
|
|
1364
|
+
let timeout;
|
|
1365
|
+
let onUnavailable;
|
|
1366
|
+
for (const member of node.members) {
|
|
1367
|
+
if (!ts.isPropertySignature(member) || !member.type)
|
|
1368
|
+
continue;
|
|
1369
|
+
const key = getPropertyName(member.name);
|
|
1370
|
+
if (key === "function") {
|
|
1371
|
+
const value = literalStringType(member.type);
|
|
1372
|
+
if (value !== null && value.trim().length > 0)
|
|
1373
|
+
fn = value;
|
|
1374
|
+
}
|
|
1375
|
+
else if (key === "timeout") {
|
|
1376
|
+
timeout = parseNumericTypeArg(member.type, sourceFile);
|
|
1377
|
+
}
|
|
1378
|
+
else if (key === "onUnavailable") {
|
|
1379
|
+
const value = literalStringType(member.type);
|
|
1380
|
+
if (value === "reject" || value === "log")
|
|
1381
|
+
onUnavailable = value;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (fn === undefined)
|
|
1385
|
+
return null;
|
|
1386
|
+
return {
|
|
1387
|
+
function: fn,
|
|
1388
|
+
...(timeout !== undefined && { timeout }),
|
|
1389
|
+
...(onUnavailable !== undefined && { onUnavailable }),
|
|
1275
1390
|
};
|
|
1276
1391
|
}
|
|
1277
1392
|
function parseModelIndexes(metaArg, sourceFile, fields) {
|
|
@@ -1327,28 +1442,236 @@ function resolveIndexFieldName(fieldName, fields) {
|
|
|
1327
1442
|
}
|
|
1328
1443
|
return fieldName;
|
|
1329
1444
|
}
|
|
1330
|
-
|
|
1445
|
+
/**
|
|
1446
|
+
* Resolve an `access:` annotation to its rule object.
|
|
1447
|
+
*
|
|
1448
|
+
* `access` may be written inline (`access: { read: Public }`) or factored into a
|
|
1449
|
+
* shared type alias (`access: CmsPublicReadAdminWrite`) — the latter is the
|
|
1450
|
+
* natural way to reuse one policy set across many models, so it has to resolve
|
|
1451
|
+
* through aliases and intersections, not just literals.
|
|
1452
|
+
*
|
|
1453
|
+
* Anything unresolvable throws. Returning `{}` here means "no access rules",
|
|
1454
|
+
* which the differ treats as "emit no RLS policies" — so a typo or an
|
|
1455
|
+
* unsupported shape silently published a table with no row-level protection at
|
|
1456
|
+
* all. Failing the extract is the only safe outcome.
|
|
1457
|
+
*/
|
|
1458
|
+
function parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx) {
|
|
1331
1459
|
if (!metaArg || !ts.isTypeLiteralNode(metaArg))
|
|
1332
1460
|
return {};
|
|
1333
1461
|
const accessProp = metaArg.members.find((member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "access");
|
|
1334
|
-
if (!accessProp || !ts.isPropertySignature(accessProp) || !accessProp.type
|
|
1462
|
+
if (!accessProp || !ts.isPropertySignature(accessProp) || !accessProp.type) {
|
|
1335
1463
|
return {};
|
|
1336
1464
|
}
|
|
1465
|
+
const literal = resolveAccessLiteral(accessProp.type, sourceFile, resolveCtx);
|
|
1466
|
+
if (!literal) {
|
|
1467
|
+
throw new Error(`Model "${modelName}": could not resolve its \`access\` rules from ` +
|
|
1468
|
+
`\`${accessProp.type.getText(sourceFile)}\`.\n` +
|
|
1469
|
+
` access must be an object type, or a type alias for one:\n` +
|
|
1470
|
+
` export type PublicReadAdminWrite = { read: Public; create: Role<"admin"> }\n` +
|
|
1471
|
+
` A \`const … as const\` value cannot be used here — access is read from types, and\n` +
|
|
1472
|
+
` rule names like Public / Role are types, not values.\n` +
|
|
1473
|
+
` Refusing to continue: an unresolved access block would publish this table with no RLS.`);
|
|
1474
|
+
}
|
|
1337
1475
|
const access = {};
|
|
1338
|
-
for (const member of
|
|
1476
|
+
for (const member of literal.members) {
|
|
1339
1477
|
if (!ts.isPropertySignature(member) || !member.type)
|
|
1340
1478
|
continue;
|
|
1341
1479
|
const key = getPropertyName(member.name);
|
|
1342
1480
|
if (!key)
|
|
1343
1481
|
continue;
|
|
1344
|
-
|
|
1482
|
+
// `update: { using, check }` splits which rows may be changed from what they
|
|
1483
|
+
// may be changed into. Flattened here into the two AST fields the engine
|
|
1484
|
+
// renders, so the engine never has to know about the sugar.
|
|
1485
|
+
if (key === "update") {
|
|
1486
|
+
const split = parseUpdateSplit(member.type, sourceFile, resolveCtx);
|
|
1487
|
+
if (split) {
|
|
1488
|
+
access["update"] = split.using;
|
|
1489
|
+
if (split.check !== undefined)
|
|
1490
|
+
access["updateCheck"] = split.check;
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
if (key === "fields") {
|
|
1495
|
+
const fieldRules = parseFieldAccess(member.type, sourceFile, modelName, fields, resolveCtx);
|
|
1496
|
+
if (Object.keys(fieldRules).length > 0)
|
|
1497
|
+
access["fields"] = fieldRules;
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
access[key] = parseAccessRule(member.type, sourceFile, resolveCtx);
|
|
1501
|
+
}
|
|
1502
|
+
if (Object.keys(access).length === 0) {
|
|
1503
|
+
throw new Error(`Model "${modelName}": \`access\` resolved to an empty rule set ` +
|
|
1504
|
+
`(from \`${accessProp.type.getText(sourceFile)}\`). Remove \`access\` if the table is ` +
|
|
1505
|
+
`intentionally unrestricted, or declare at least one of read/create/update/delete.`);
|
|
1345
1506
|
}
|
|
1346
1507
|
return access;
|
|
1347
1508
|
}
|
|
1348
|
-
|
|
1509
|
+
/**
|
|
1510
|
+
* `fields: { [column]: { read?, write? } }` — per-column narrowing of the table rules.
|
|
1511
|
+
*
|
|
1512
|
+
* Any rule the DSL can express is allowed. Enforcement is a query rewrite that
|
|
1513
|
+
* evaluates the rule per row with the caller's claims, so ownership, membership and
|
|
1514
|
+
* application roles all work here — none of which a column privilege could express.
|
|
1515
|
+
*/
|
|
1516
|
+
function parseFieldAccess(typeNode, sourceFile, modelName, fields, resolveCtx) {
|
|
1517
|
+
const literal = resolveCtx
|
|
1518
|
+
? resolveAccessLiteral(typeNode, sourceFile, resolveCtx)
|
|
1519
|
+
: ts.isTypeLiteralNode(typeNode)
|
|
1520
|
+
? typeNode
|
|
1521
|
+
: null;
|
|
1522
|
+
if (!literal) {
|
|
1523
|
+
throw new Error(`Model "${modelName}": could not resolve \`access.fields\` from ` +
|
|
1524
|
+
`\`${typeNode.getText(sourceFile)}\`. It must be an object type, or a type ` +
|
|
1525
|
+
`alias for one: \`fields: { salary: { read: Private } }\`.`);
|
|
1526
|
+
}
|
|
1527
|
+
const out = {};
|
|
1528
|
+
for (const member of literal.members) {
|
|
1529
|
+
if (!ts.isPropertySignature(member) || !member.type)
|
|
1530
|
+
continue;
|
|
1531
|
+
const declared = getPropertyName(member.name);
|
|
1532
|
+
if (!declared)
|
|
1533
|
+
continue;
|
|
1534
|
+
// A privilege is granted on a column, and a relation field is not one --
|
|
1535
|
+
// `author` is stored as `author_id`. Same mapping the index builder uses.
|
|
1536
|
+
// Names that match nothing are passed through rather than rejected: composite
|
|
1537
|
+
// wrappers expand into columns this map never sees, so a hard error here would
|
|
1538
|
+
// refuse valid schemas. Postgres rejects the unknown column at push instead.
|
|
1539
|
+
const column = resolveIndexFieldName(declared, fields) ?? declared;
|
|
1540
|
+
const columnLiteral = resolveCtx
|
|
1541
|
+
? resolveAccessLiteral(member.type, sourceFile, resolveCtx)
|
|
1542
|
+
: ts.isTypeLiteralNode(member.type)
|
|
1543
|
+
? member.type
|
|
1544
|
+
: null;
|
|
1545
|
+
if (!columnLiteral) {
|
|
1546
|
+
throw new Error(`Model "${modelName}": \`access.fields.${column}\` must be an object with ` +
|
|
1547
|
+
`\`read\` and/or \`write\`, found \`${member.type.getText(sourceFile)}\`.`);
|
|
1548
|
+
}
|
|
1549
|
+
const rules = {};
|
|
1550
|
+
for (const opMember of columnLiteral.members) {
|
|
1551
|
+
if (!ts.isPropertySignature(opMember) || !opMember.type)
|
|
1552
|
+
continue;
|
|
1553
|
+
const operation = getPropertyName(opMember.name);
|
|
1554
|
+
if (operation !== "read" && operation !== "write") {
|
|
1555
|
+
throw new Error(`Model "${modelName}": \`access.fields.${column}\` may only contain ` +
|
|
1556
|
+
`\`read\` and \`write\`, found "${operation}".`);
|
|
1557
|
+
}
|
|
1558
|
+
rules[operation] = parseAccessRule(opMember.type, sourceFile, resolveCtx);
|
|
1559
|
+
}
|
|
1560
|
+
// An empty `{}` reads as "this column is restricted" but restricts nothing.
|
|
1561
|
+
if (Object.keys(rules).length === 0) {
|
|
1562
|
+
throw new Error(`Model "${modelName}": \`access.fields.${column}\` declares no rules. ` +
|
|
1563
|
+
`Give it a \`read\` or a \`write\`, or remove the entry.`);
|
|
1564
|
+
}
|
|
1565
|
+
out[column] = rules;
|
|
1566
|
+
}
|
|
1567
|
+
return out;
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* `update: { using: …, check: … }`, or null when `update` is a plain rule.
|
|
1571
|
+
*
|
|
1572
|
+
* An update policy has two halves: `USING` picks which existing rows may be
|
|
1573
|
+
* modified, `WITH CHECK` constrains what they may become. One rule for both is the
|
|
1574
|
+
* right default and what every existing schema means — but it makes "an editor may
|
|
1575
|
+
* move a post between their own sites" inexpressible, because the row they are
|
|
1576
|
+
* changing and the row they are changing it into are judged by the same predicate.
|
|
1577
|
+
*/
|
|
1578
|
+
function parseUpdateSplit(typeNode, sourceFile, resolveCtx) {
|
|
1579
|
+
const literal = resolveCtx
|
|
1580
|
+
? resolveAccessLiteral(typeNode, sourceFile, resolveCtx)
|
|
1581
|
+
: ts.isTypeLiteralNode(typeNode)
|
|
1582
|
+
? typeNode
|
|
1583
|
+
: null;
|
|
1584
|
+
if (!literal)
|
|
1585
|
+
return null;
|
|
1586
|
+
let using;
|
|
1587
|
+
let check;
|
|
1588
|
+
for (const member of literal.members) {
|
|
1589
|
+
if (!ts.isPropertySignature(member) || !member.type)
|
|
1590
|
+
continue;
|
|
1591
|
+
const name = getPropertyName(member.name);
|
|
1592
|
+
if (name === "using")
|
|
1593
|
+
using = member.type;
|
|
1594
|
+
else if (name === "check")
|
|
1595
|
+
check = member.type;
|
|
1596
|
+
else if (name !== undefined) {
|
|
1597
|
+
throw new Error(`\`update\` may only contain \`using\` and \`check\`, found "${name}".`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
// Not the split form at all — an object with neither key is some other shape.
|
|
1601
|
+
if (!using && !check)
|
|
1602
|
+
return null;
|
|
1603
|
+
if (!using) {
|
|
1604
|
+
throw new Error("`update: { check }` needs a `using` rule too — without it no row is " +
|
|
1605
|
+
"selectable for update, so the check can never apply.");
|
|
1606
|
+
}
|
|
1607
|
+
const parsed = {
|
|
1608
|
+
using: parseAccessRule(using, sourceFile, resolveCtx),
|
|
1609
|
+
};
|
|
1610
|
+
if (check)
|
|
1611
|
+
parsed.check = parseAccessRule(check, sourceFile, resolveCtx);
|
|
1612
|
+
return parsed;
|
|
1613
|
+
}
|
|
1614
|
+
/** Walk aliases/intersections down to the object type holding the access rules. */
|
|
1615
|
+
function resolveAccessLiteral(typeNode, sourceFile, resolveCtx, depth = 0) {
|
|
1616
|
+
if (depth > 16)
|
|
1617
|
+
return null;
|
|
1618
|
+
if (ts.isTypeLiteralNode(typeNode))
|
|
1619
|
+
return typeNode;
|
|
1620
|
+
if (ts.isIntersectionTypeNode(typeNode)) {
|
|
1621
|
+
const members = [];
|
|
1622
|
+
for (const part of typeNode.types) {
|
|
1623
|
+
const resolved = resolveAccessLiteral(part, sourceFile, resolveCtx, depth + 1);
|
|
1624
|
+
if (!resolved)
|
|
1625
|
+
return null;
|
|
1626
|
+
members.push(...resolved.members);
|
|
1627
|
+
}
|
|
1628
|
+
return ts.factory.createTypeLiteralNode(members);
|
|
1629
|
+
}
|
|
1630
|
+
if (ts.isTypeReferenceNode(typeNode)) {
|
|
1631
|
+
const expanded = tryResolveTypeReference(typeNode, sourceFile, resolveCtx);
|
|
1632
|
+
if (expanded)
|
|
1633
|
+
return resolveAccessLiteral(expanded, sourceFile, resolveCtx, depth + 1);
|
|
1634
|
+
}
|
|
1635
|
+
if (needsChecker(typeNode)) {
|
|
1636
|
+
const resolved = resolveTypeNode(typeNode, sourceFile, resolveCtx);
|
|
1637
|
+
if (resolved !== typeNode) {
|
|
1638
|
+
return resolveAccessLiteral(resolved, sourceFile, resolveCtx, depth + 1);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
return null;
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* `access` rules may be declared in a different file from the model (a shared
|
|
1645
|
+
* alias), and `getText(file)` reads the given file's text at the node's offsets
|
|
1646
|
+
* — so passing the model's file for a node owned by another one yielded garbage
|
|
1647
|
+
* and every rule silently fell through to `private`. Always read a node's text
|
|
1648
|
+
* from the file that actually owns it.
|
|
1649
|
+
*/
|
|
1650
|
+
function ownerText(node, fallback) {
|
|
1651
|
+
const own = node.pos >= 0 ? node.getSourceFile() : undefined;
|
|
1652
|
+
return node.getText(own ?? fallback);
|
|
1653
|
+
}
|
|
1654
|
+
function parseAccessRule(typeNode, sourceFile, resolveCtx, depth = 0) {
|
|
1655
|
+
// Reuse a single rule from a shared set: `access: { read: Rules["read"] }`.
|
|
1656
|
+
if (ts.isIndexedAccessTypeNode(typeNode) && resolveCtx) {
|
|
1657
|
+
const member = resolveIndexedAccessRule(typeNode, sourceFile, resolveCtx);
|
|
1658
|
+
if (member)
|
|
1659
|
+
return parseAccessRule(member, sourceFile, resolveCtx);
|
|
1660
|
+
throw new Error(`Could not resolve access rule from \`${ownerText(typeNode, sourceFile)}\`. ` +
|
|
1661
|
+
`Reference a rule set member as \`Rules["read"]\`, where Rules is a type alias.`);
|
|
1662
|
+
}
|
|
1349
1663
|
if (!ts.isTypeReferenceNode(typeNode))
|
|
1350
1664
|
return { type: "private" };
|
|
1351
|
-
|
|
1665
|
+
// `Rules.read` is a qualified name (namespace lookup), not a member of a type
|
|
1666
|
+
// alias — TypeScript rejects it, and it used to fall through to `private`,
|
|
1667
|
+
// silently denying an operation the author meant to grant.
|
|
1668
|
+
if (ts.isQualifiedName(typeNode.typeName)) {
|
|
1669
|
+
const text = ownerText(typeNode.typeName, sourceFile);
|
|
1670
|
+
const [head, ...rest] = text.split(".");
|
|
1671
|
+
throw new Error(`Access rule \`${text}\` is not valid: a member of a rule-set type must be written ` +
|
|
1672
|
+
`\`${head}["${rest.join(".")}"]\`, not \`${text}\`.`);
|
|
1673
|
+
}
|
|
1674
|
+
const ref = ownerText(typeNode.typeName, sourceFile);
|
|
1352
1675
|
switch (ref) {
|
|
1353
1676
|
case "Public":
|
|
1354
1677
|
case "BucketPublic":
|
|
@@ -1365,24 +1688,389 @@ function parseAccessRule(typeNode, sourceFile) {
|
|
|
1365
1688
|
const args = typeNode.typeArguments ?? [];
|
|
1366
1689
|
const keyArg = args.length >= 2 ? args[1] : args[0];
|
|
1367
1690
|
// Must match engine `AccessRule::Owner { field }` (see supatype-schema-engine parser/ast.rs).
|
|
1368
|
-
return {
|
|
1691
|
+
return {
|
|
1692
|
+
type: "owner",
|
|
1693
|
+
field: keyArg ? ownerText(keyArg, sourceFile).replace(/['"]/g, "") : "user_id",
|
|
1694
|
+
};
|
|
1369
1695
|
}
|
|
1370
1696
|
case "OwnerFrom": {
|
|
1371
1697
|
const relationArg = typeNode.typeArguments?.[0];
|
|
1372
|
-
const relationField = relationArg
|
|
1698
|
+
const relationField = relationArg
|
|
1699
|
+
? ownerText(relationArg, sourceFile).replace(/['"]/g, "")
|
|
1700
|
+
: "owner";
|
|
1373
1701
|
return { type: "owner", field: relationField };
|
|
1374
1702
|
}
|
|
1375
|
-
case "Role":
|
|
1376
|
-
const roleArg = typeNode.typeArguments?.[0];
|
|
1377
|
-
return { type: "role", roles: [roleArg?.getText(sourceFile).replace(/['"]/g, "") ?? "admin"] };
|
|
1378
|
-
}
|
|
1703
|
+
case "Role":
|
|
1379
1704
|
case "BucketRole": {
|
|
1380
1705
|
const roleArg = typeNode.typeArguments?.[0];
|
|
1381
|
-
return {
|
|
1706
|
+
return {
|
|
1707
|
+
type: "role",
|
|
1708
|
+
roles: [roleArg ? ownerText(roleArg, sourceFile).replace(/['"]/g, "") : "admin"],
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
case "Any": {
|
|
1712
|
+
const listArg = typeNode.typeArguments?.[0];
|
|
1713
|
+
if (!listArg || !ts.isTupleTypeNode(listArg)) {
|
|
1714
|
+
throw new Error(`\`Any<>\` takes a tuple of rules, as in ` +
|
|
1715
|
+
`\`Any<[Role<"admin">, Owner<"author_id">]>\`.`);
|
|
1716
|
+
}
|
|
1717
|
+
// An empty list would compile to a policy that grants nothing while
|
|
1718
|
+
// reading like a grant — the exact silent-denial failure the unknown-rule
|
|
1719
|
+
// branch below exists to prevent.
|
|
1720
|
+
if (listArg.elements.length === 0) {
|
|
1721
|
+
throw new Error("`Any<[]>` grants nothing. List the rules that should allow access, or " +
|
|
1722
|
+
"use `Private` if denying is the intent.");
|
|
1723
|
+
}
|
|
1724
|
+
return {
|
|
1725
|
+
type: "any",
|
|
1726
|
+
rules: listArg.elements.map((element) => parseAccessRule(element, sourceFile, resolveCtx)),
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
case "All": {
|
|
1730
|
+
const listArg = typeNode.typeArguments?.[0];
|
|
1731
|
+
if (!listArg || !ts.isTupleTypeNode(listArg)) {
|
|
1732
|
+
throw new Error('`All<>` takes a tuple of rules, as in `All<[Role<"editor">, NotNull<"published_at">]>`.');
|
|
1733
|
+
}
|
|
1734
|
+
// Unlike `Any<[]>`, an empty `All` grants *everything* — an empty AND is
|
|
1735
|
+
// true. Reading as a restriction while imposing none is worse than an error.
|
|
1736
|
+
if (listArg.elements.length === 0) {
|
|
1737
|
+
throw new Error("`All<[]>` restricts nothing and grants everything. List the rules that " +
|
|
1738
|
+
"must hold, or use `Public` if that is the intent.");
|
|
1739
|
+
}
|
|
1740
|
+
return {
|
|
1741
|
+
type: "all",
|
|
1742
|
+
rules: listArg.elements.map((element) => parseAccessRule(element, sourceFile, resolveCtx)),
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
case "Not": {
|
|
1746
|
+
const inner = typeNode.typeArguments?.[0];
|
|
1747
|
+
if (!inner) {
|
|
1748
|
+
throw new Error('`Not<>` takes one rule, as in `Not<Role<"banned">>`.');
|
|
1749
|
+
}
|
|
1750
|
+
return { type: "not", rule: parseAccessRule(inner, sourceFile, resolveCtx) };
|
|
1751
|
+
}
|
|
1752
|
+
case "Eq":
|
|
1753
|
+
case "Neq":
|
|
1754
|
+
case "Gt":
|
|
1755
|
+
case "Gte":
|
|
1756
|
+
case "Lt":
|
|
1757
|
+
case "Lte":
|
|
1758
|
+
case "Like": {
|
|
1759
|
+
const args = typeNode.typeArguments ?? [];
|
|
1760
|
+
if (args.length !== 2) {
|
|
1761
|
+
throw new Error(`\`${ref}<>\` takes two operands, as in \`${ref}<"author_id", AuthUid>\`.`);
|
|
1762
|
+
}
|
|
1763
|
+
return {
|
|
1764
|
+
type: "compare",
|
|
1765
|
+
op: ref.toLowerCase(),
|
|
1766
|
+
left: parseAccessOperand(args[0], sourceFile),
|
|
1767
|
+
right: parseAccessOperand(args[1], sourceFile),
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
case "IsNull":
|
|
1771
|
+
case "NotNull": {
|
|
1772
|
+
const operand = typeNode.typeArguments?.[0];
|
|
1773
|
+
if (!operand) {
|
|
1774
|
+
throw new Error(`\`${ref}<>\` takes one operand, as in \`${ref}<"deleted_at">\`.`);
|
|
1775
|
+
}
|
|
1776
|
+
return {
|
|
1777
|
+
type: "nullCheck",
|
|
1778
|
+
operand: parseAccessOperand(operand, sourceFile),
|
|
1779
|
+
isNull: ref === "IsNull",
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
case "Exists": {
|
|
1783
|
+
const source = typeNode.typeArguments?.[0];
|
|
1784
|
+
if (!source) {
|
|
1785
|
+
throw new Error('`Exists<>` takes a source, as in `Exists<MySites>`.');
|
|
1786
|
+
}
|
|
1787
|
+
return {
|
|
1788
|
+
type: "exists",
|
|
1789
|
+
source: parseMembershipSource(source, sourceFile, resolveCtx),
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
case "In": {
|
|
1793
|
+
const args = typeNode.typeArguments ?? [];
|
|
1794
|
+
if (args.length !== 2) {
|
|
1795
|
+
throw new Error('`In<>` takes a column and a source, as in `In<"site_id", MySites>`.');
|
|
1796
|
+
}
|
|
1797
|
+
const column = parseAccessOperand(args[0], sourceFile);
|
|
1798
|
+
if (column["kind"] !== "column") {
|
|
1799
|
+
throw new Error("`In<>`'s first argument must be a column name — the membership test is " +
|
|
1800
|
+
"about a column of the row being checked.");
|
|
1801
|
+
}
|
|
1802
|
+
return {
|
|
1803
|
+
type: "in",
|
|
1804
|
+
column: column["name"],
|
|
1805
|
+
source: parseMembershipSource(args[1], sourceFile, resolveCtx),
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
default: {
|
|
1809
|
+
// A named alias — possibly parameterised — standing for a rule. Expanded
|
|
1810
|
+
// before giving up, so `SiteAccess<"site_id">` works.
|
|
1811
|
+
if (resolveCtx && depth < 16) {
|
|
1812
|
+
const expanded = resolveAccessAliasNode(typeNode, sourceFile, resolveCtx);
|
|
1813
|
+
if (expanded)
|
|
1814
|
+
return parseAccessRule(expanded, sourceFile, resolveCtx, depth + 1);
|
|
1815
|
+
}
|
|
1816
|
+
// Falling back to `private` here would silently deny an operation the
|
|
1817
|
+
// author believed they had granted — and with deny-by-default there is no
|
|
1818
|
+
// longer any need for a permissive guess. Name the offending rule instead.
|
|
1819
|
+
throw new Error(`Unknown access rule "${ref}". Supported: Public, Private, LoggedIn, ` +
|
|
1820
|
+
`Owner<"field">, OwnerFrom<"relation">, Role<"name">, ` +
|
|
1821
|
+
`Any<[rule, …]>, All<[rule, …]>, Not<rule>, ` +
|
|
1822
|
+
`Eq/Neq/Gt/Gte/Lt/Lte/Like<left, right>, IsNull<operand>, NotNull<operand>` +
|
|
1823
|
+
` (buckets also accept BucketPublic, BucketPrivate, BucketLoggedIn, BucketOwner, BucketRole).`);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Expands a named alias to the type it stands for.
|
|
1829
|
+
*
|
|
1830
|
+
* This is what makes rules parameterisable — the type-level equivalent of
|
|
1831
|
+
* Payload's access-control factory:
|
|
1832
|
+
*
|
|
1833
|
+
* ```typescript
|
|
1834
|
+
* type SiteAccess<F extends string> = Any<[Role<"admin">, In<F, MySites>]>
|
|
1835
|
+
* access: { update: SiteAccess<"site_id"> }
|
|
1836
|
+
* ```
|
|
1837
|
+
*
|
|
1838
|
+
* Without it a rule had to be written inline at every use, and any alias — even an
|
|
1839
|
+
* unparameterised one — was reported as an unknown rule.
|
|
1840
|
+
*/
|
|
1841
|
+
function resolveAccessAliasNode(typeNode, sourceFile, resolveCtx) {
|
|
1842
|
+
const expanded = tryResolveTypeReference(typeNode, sourceFile, resolveCtx);
|
|
1843
|
+
if (expanded && expanded !== typeNode)
|
|
1844
|
+
return expanded;
|
|
1845
|
+
if (needsChecker(typeNode)) {
|
|
1846
|
+
const resolved = resolveTypeNode(typeNode, sourceFile, resolveCtx);
|
|
1847
|
+
if (resolved !== typeNode)
|
|
1848
|
+
return resolved;
|
|
1849
|
+
}
|
|
1850
|
+
return undefined;
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* The set on the right of an `In<>`.
|
|
1854
|
+
*
|
|
1855
|
+
* Resolved through the same alias machinery as rules, so a shared
|
|
1856
|
+
* `type MySites = Rows<…>` can be reused across models — the parameterisation the
|
|
1857
|
+
* plan calls the type-level equivalent of Payload's access factory.
|
|
1858
|
+
*/
|
|
1859
|
+
function parseMembershipSource(typeNode, sourceFile, resolveCtx) {
|
|
1860
|
+
if (ts.isIndexedAccessTypeNode(typeNode) && resolveCtx) {
|
|
1861
|
+
const member = resolveIndexedAccessRule(typeNode, sourceFile, resolveCtx);
|
|
1862
|
+
if (member)
|
|
1863
|
+
return parseMembershipSource(member, sourceFile, resolveCtx);
|
|
1864
|
+
}
|
|
1865
|
+
if (ts.isTypeReferenceNode(typeNode) && !ts.isQualifiedName(typeNode.typeName)) {
|
|
1866
|
+
const ref = ownerText(typeNode.typeName, sourceFile);
|
|
1867
|
+
const args = typeNode.typeArguments ?? [];
|
|
1868
|
+
if (ref === "Claim") {
|
|
1869
|
+
return parseAccessOperand(typeNode, sourceFile);
|
|
1870
|
+
}
|
|
1871
|
+
if (ref === "Rows") {
|
|
1872
|
+
const table = stringLiteralArg(args[0], "Rows", "table name");
|
|
1873
|
+
const column = stringLiteralArg(args[1], "Rows", "column name");
|
|
1874
|
+
const source = { kind: "rows", table, column };
|
|
1875
|
+
// The third argument narrows the source rows. Absent means "every row",
|
|
1876
|
+
// which is a lookup rather than a membership check — allowed, but rarely
|
|
1877
|
+
// what was meant, so it stays explicit rather than being defaulted.
|
|
1878
|
+
if (args[2]) {
|
|
1879
|
+
source["where"] = parseAccessRule(args[2], sourceFile, resolveCtx);
|
|
1880
|
+
}
|
|
1881
|
+
return source;
|
|
1882
|
+
}
|
|
1883
|
+
if (ref === "Values") {
|
|
1884
|
+
const listArg = args[0];
|
|
1885
|
+
if (!listArg || !ts.isTupleTypeNode(listArg)) {
|
|
1886
|
+
throw new Error('`Values<>` takes a tuple, as in `Values<["draft", "review"]>`.');
|
|
1887
|
+
}
|
|
1888
|
+
if (listArg.elements.length === 0) {
|
|
1889
|
+
throw new Error("`Values<[]>` matches nothing. List the values, or use `Private`.");
|
|
1890
|
+
}
|
|
1891
|
+
// Not `parseAccessOperand`: there, a bare string is a *column*, which is
|
|
1892
|
+
// right for a comparison and wrong here. Inside a value list a string can
|
|
1893
|
+
// only be a value, so it is read as one — no `Literal<>` wrapper needed.
|
|
1894
|
+
return {
|
|
1895
|
+
kind: "literal",
|
|
1896
|
+
values: listArg.elements.map((element) => literalFromNode(element, sourceFile)),
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
// A named alias for a source: `type MySites = Rows<…>`.
|
|
1900
|
+
if (resolveCtx) {
|
|
1901
|
+
const resolved = resolveAccessAliasNode(typeNode, sourceFile, resolveCtx);
|
|
1902
|
+
if (resolved)
|
|
1903
|
+
return parseMembershipSource(resolved, sourceFile, resolveCtx);
|
|
1382
1904
|
}
|
|
1383
|
-
default:
|
|
1384
|
-
return { type: "private" };
|
|
1385
1905
|
}
|
|
1906
|
+
throw new Error(`\`${ownerText(typeNode, sourceFile)}\` is not a valid membership source. Use ` +
|
|
1907
|
+
`Rows<"table", "column", Where>, Claim<"path"> or Values<[…]>.`);
|
|
1908
|
+
}
|
|
1909
|
+
/** Interval units, matching `TimeUnit` in `@supatype/types` and the engine. */
|
|
1910
|
+
const TIME_UNITS = [
|
|
1911
|
+
"seconds",
|
|
1912
|
+
"minutes",
|
|
1913
|
+
"hours",
|
|
1914
|
+
"days",
|
|
1915
|
+
"weeks",
|
|
1916
|
+
"months",
|
|
1917
|
+
"years",
|
|
1918
|
+
];
|
|
1919
|
+
/** Granularities for `StartOf<>`. */
|
|
1920
|
+
const TRUNC_UNITS = ["day", "week", "month", "year"];
|
|
1921
|
+
/**
|
|
1922
|
+
* `Ago<30, "days">` / `FromNow<7, "days">`.
|
|
1923
|
+
*
|
|
1924
|
+
* The amount and unit are validated here and the interval is re-assembled from the
|
|
1925
|
+
* parsed integer and the matched keyword, so no author-supplied text reaches
|
|
1926
|
+
* Postgres. That is what keeps this from being a small escape hatch: a permissive
|
|
1927
|
+
* `"30 days"` string would be raw SQL by another name.
|
|
1928
|
+
*/
|
|
1929
|
+
function parseDurationOperand(ref, args, sourceFile) {
|
|
1930
|
+
if (args.length !== 2) {
|
|
1931
|
+
throw new Error(`\`${ref}<>\` takes an amount and a unit, as in \`${ref}<30, "days">\`.`);
|
|
1932
|
+
}
|
|
1933
|
+
const amountNode = args[0];
|
|
1934
|
+
const opposite = ref === "Ago" ? "FromNow" : "Ago";
|
|
1935
|
+
// A negative literal is a prefix-unary expression in the TypeScript AST, not a
|
|
1936
|
+
// numeric literal, so it has to be recognised separately — otherwise `Ago<-5, …>`
|
|
1937
|
+
// is refused for the wrong reason and the message misses the real advice.
|
|
1938
|
+
if (ts.isLiteralTypeNode(amountNode) &&
|
|
1939
|
+
ts.isPrefixUnaryExpression(amountNode.literal) &&
|
|
1940
|
+
amountNode.literal.operator === ts.SyntaxKind.MinusToken) {
|
|
1941
|
+
throw new Error(`\`${ref}<>\` does not take a negative amount. For the other direction use ` +
|
|
1942
|
+
`\`${opposite}<>\`, which reads correctly instead of relying on a double negative.`);
|
|
1943
|
+
}
|
|
1944
|
+
if (!ts.isLiteralTypeNode(amountNode) || !ts.isNumericLiteral(amountNode.literal)) {
|
|
1945
|
+
throw new Error(`\`${ref}<>\` needs a number literal amount, as in \`${ref}<30, "days">\`.`);
|
|
1946
|
+
}
|
|
1947
|
+
const amount = Number(amountNode.literal.text);
|
|
1948
|
+
if (!Number.isInteger(amount)) {
|
|
1949
|
+
throw new Error(`\`${ref}<${amount}, …>\` must be a whole number of units — Postgres intervals ` +
|
|
1950
|
+
`take integers, so a fraction would be silently truncated or rejected.`);
|
|
1951
|
+
}
|
|
1952
|
+
const unit = stringLiteralArg(args[1], ref, "unit");
|
|
1953
|
+
if (!TIME_UNITS.includes(unit)) {
|
|
1954
|
+
throw new Error(`\`${ref}<${amount}, "${unit}">\` is not a valid unit. Use one of: ` +
|
|
1955
|
+
`${TIME_UNITS.join(", ")} (plural).`);
|
|
1956
|
+
}
|
|
1957
|
+
void sourceFile;
|
|
1958
|
+
return { kind: ref === "Ago" ? "ago" : "fromNow", amount, unit };
|
|
1959
|
+
}
|
|
1960
|
+
/** A string, number or boolean literal as its plain JS value. */
|
|
1961
|
+
function literalFromNode(node, sourceFile) {
|
|
1962
|
+
if (ts.isLiteralTypeNode(node)) {
|
|
1963
|
+
const literal = node.literal;
|
|
1964
|
+
if (ts.isStringLiteral(literal))
|
|
1965
|
+
return literal.text;
|
|
1966
|
+
if (ts.isNumericLiteral(literal))
|
|
1967
|
+
return Number(literal.text);
|
|
1968
|
+
if (literal.kind === ts.SyntaxKind.TrueKeyword)
|
|
1969
|
+
return true;
|
|
1970
|
+
if (literal.kind === ts.SyntaxKind.FalseKeyword)
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
throw new Error(`\`${ownerText(node, sourceFile)}\` is not a string, number or boolean literal.`);
|
|
1974
|
+
}
|
|
1975
|
+
function stringLiteralArg(node, owner, what) {
|
|
1976
|
+
if (!node || !ts.isLiteralTypeNode(node) || !ts.isStringLiteral(node.literal)) {
|
|
1977
|
+
throw new Error(`\`${owner}<>\` needs a ${what} as a string literal.`);
|
|
1978
|
+
}
|
|
1979
|
+
const value = node.literal.text.trim();
|
|
1980
|
+
// Rendered as a SQL identifier, so refuse anything that is not one.
|
|
1981
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
1982
|
+
throw new Error(`"${value}" is not a valid ${what}.`);
|
|
1983
|
+
}
|
|
1984
|
+
return value;
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* One side of a comparison.
|
|
1988
|
+
*
|
|
1989
|
+
* A bare string literal is a **column** on the model the rule is attached to.
|
|
1990
|
+
* That is the common case by a wide margin, and making it the default keeps
|
|
1991
|
+
* `Eq<"author_id", AuthUid>` readable; a constant is written `Literal<"x">` so the
|
|
1992
|
+
* two can never be confused. Numbers and booleans are unambiguous, so they are
|
|
1993
|
+
* taken as constants directly.
|
|
1994
|
+
*/
|
|
1995
|
+
function parseAccessOperand(typeNode, sourceFile) {
|
|
1996
|
+
if (ts.isLiteralTypeNode(typeNode)) {
|
|
1997
|
+
const literal = typeNode.literal;
|
|
1998
|
+
if (ts.isStringLiteral(literal)) {
|
|
1999
|
+
const name = literal.text.trim();
|
|
2000
|
+
if (name === "") {
|
|
2001
|
+
throw new Error("An empty column name is not a valid operand.");
|
|
2002
|
+
}
|
|
2003
|
+
// Rendered into SQL as an identifier, so refuse anything that is not one
|
|
2004
|
+
// rather than letting it through to the policy.
|
|
2005
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
2006
|
+
throw new Error(`"${name}" is not a valid column name. Use \`Literal<"${name}">\` for a ` +
|
|
2007
|
+
`constant, or quote a real column.`);
|
|
2008
|
+
}
|
|
2009
|
+
return { kind: "column", name };
|
|
2010
|
+
}
|
|
2011
|
+
if (ts.isNumericLiteral(literal)) {
|
|
2012
|
+
return { kind: "literal", value: Number(literal.text) };
|
|
2013
|
+
}
|
|
2014
|
+
if (literal.kind === ts.SyntaxKind.TrueKeyword)
|
|
2015
|
+
return { kind: "literal", value: true };
|
|
2016
|
+
if (literal.kind === ts.SyntaxKind.FalseKeyword)
|
|
2017
|
+
return { kind: "literal", value: false };
|
|
2018
|
+
}
|
|
2019
|
+
if (ts.isTypeReferenceNode(typeNode) && !ts.isQualifiedName(typeNode.typeName)) {
|
|
2020
|
+
const ref = ownerText(typeNode.typeName, sourceFile);
|
|
2021
|
+
const operandArgs = typeNode.typeArguments ?? [];
|
|
2022
|
+
switch (ref) {
|
|
2023
|
+
case "AuthUid":
|
|
2024
|
+
return { kind: "authUid" };
|
|
2025
|
+
case "AuthRole":
|
|
2026
|
+
return { kind: "authRole" };
|
|
2027
|
+
case "Now":
|
|
2028
|
+
return { kind: "now" };
|
|
2029
|
+
case "StartOf": {
|
|
2030
|
+
const unit = stringLiteralArg(operandArgs[0], "StartOf", "unit");
|
|
2031
|
+
if (!TRUNC_UNITS.includes(unit)) {
|
|
2032
|
+
throw new Error(`\`StartOf<"${unit}">\` is not a valid granularity. Use one of: ` +
|
|
2033
|
+
`${TRUNC_UNITS.join(", ")}.`);
|
|
2034
|
+
}
|
|
2035
|
+
return { kind: "startOf", unit };
|
|
2036
|
+
}
|
|
2037
|
+
case "Ago":
|
|
2038
|
+
case "FromNow":
|
|
2039
|
+
return parseDurationOperand(ref, operandArgs, sourceFile);
|
|
2040
|
+
case "Claim": {
|
|
2041
|
+
const pathArg = typeNode.typeArguments?.[0];
|
|
2042
|
+
if (!pathArg || !ts.isLiteralTypeNode(pathArg) || !ts.isStringLiteral(pathArg.literal)) {
|
|
2043
|
+
throw new Error('`Claim<>` takes a dotted path literal, as in `Claim<"app_metadata.tier">`.');
|
|
2044
|
+
}
|
|
2045
|
+
const path = pathArg.literal.text.trim();
|
|
2046
|
+
// The path is split on dots to walk the claims object, so an empty
|
|
2047
|
+
// segment would silently look up a key that cannot exist.
|
|
2048
|
+
if (path === "" || path.split(".").some((segment) => segment.trim() === "")) {
|
|
2049
|
+
throw new Error(`\`Claim<"${path}">\` is not a valid claim path — use dotted segments, ` +
|
|
2050
|
+
`as in \`Claim<"app_metadata.tier">\`.`);
|
|
2051
|
+
}
|
|
2052
|
+
return { kind: "claim", path };
|
|
2053
|
+
}
|
|
2054
|
+
case "Literal": {
|
|
2055
|
+
const valueArg = typeNode.typeArguments?.[0];
|
|
2056
|
+
if (!valueArg || !ts.isLiteralTypeNode(valueArg)) {
|
|
2057
|
+
throw new Error('`Literal<>` takes a string, number or boolean, as in `Literal<"published">`.');
|
|
2058
|
+
}
|
|
2059
|
+
const literal = valueArg.literal;
|
|
2060
|
+
if (ts.isStringLiteral(literal))
|
|
2061
|
+
return { kind: "literal", value: literal.text };
|
|
2062
|
+
if (ts.isNumericLiteral(literal))
|
|
2063
|
+
return { kind: "literal", value: Number(literal.text) };
|
|
2064
|
+
if (literal.kind === ts.SyntaxKind.TrueKeyword)
|
|
2065
|
+
return { kind: "literal", value: true };
|
|
2066
|
+
if (literal.kind === ts.SyntaxKind.FalseKeyword)
|
|
2067
|
+
return { kind: "literal", value: false };
|
|
2068
|
+
throw new Error('`Literal<>` takes a string, number or boolean.');
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
throw new Error(`\`${ownerText(typeNode, sourceFile)}\` is not a valid operand. Use a column name ` +
|
|
2073
|
+
`("author_id"), AuthUid, AuthRole, Claim<"path"> or Literal<value>.`);
|
|
1386
2074
|
}
|
|
1387
2075
|
function parseRelationOptions(optsArg, sourceFile) {
|
|
1388
2076
|
const out = {};
|
|
@@ -1453,4 +2141,20 @@ function relationForeignKeyFromField(fieldName) {
|
|
|
1453
2141
|
const base = snake.replace(/_id$/i, "");
|
|
1454
2142
|
return `${base}_id`;
|
|
1455
2143
|
}
|
|
2144
|
+
/** Resolve `Rules["read"]` to the rule type declared for that member. */
|
|
2145
|
+
function resolveIndexedAccessRule(node, sourceFile, resolveCtx) {
|
|
2146
|
+
const literal = resolveAccessLiteral(node.objectType, sourceFile, resolveCtx);
|
|
2147
|
+
if (!literal)
|
|
2148
|
+
return undefined;
|
|
2149
|
+
if (!ts.isLiteralTypeNode(node.indexType) || !ts.isStringLiteral(node.indexType.literal)) {
|
|
2150
|
+
return undefined;
|
|
2151
|
+
}
|
|
2152
|
+
const key = node.indexType.literal.text;
|
|
2153
|
+
for (const member of literal.members) {
|
|
2154
|
+
if (ts.isPropertySignature(member) && getPropertyName(member.name) === key) {
|
|
2155
|
+
return member.type;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
return undefined;
|
|
2159
|
+
}
|
|
1456
2160
|
//# sourceMappingURL=type-extractor.js.map
|