@supatype/cli 0.1.5 → 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 +143 -93
- 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 +43 -7
- 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 +131 -22
- 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/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +2 -0
- package/dist/commands/keys.js.map +1 -1
- 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 +129 -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 +46 -9
- 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 +153 -28
- 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/keys.ts +2 -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 +144 -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 +72 -4
- 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/src/type-extractor.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function extractSchemaAstFromTypes(
|
|
|
46
46
|
const bucketAliases = new Map<string, string>()
|
|
47
47
|
const bucketsById = new Map<string, ExtractedStorageBucketAst>()
|
|
48
48
|
for (const sourceFile of sourceFiles) {
|
|
49
|
-
const bucketContext = collectBucketContext(sourceFile)
|
|
49
|
+
const bucketContext = collectBucketContext(sourceFile, resolveCtx)
|
|
50
50
|
for (const [alias, bucketId] of bucketContext.aliases) {
|
|
51
51
|
bucketAliases.set(alias, bucketId)
|
|
52
52
|
}
|
|
@@ -105,16 +105,17 @@ export function extractSchemaAstFromTypes(
|
|
|
105
105
|
)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
const { tableName, access, options, indexes } = parseModelMeta(
|
|
108
|
+
const { tableName, access, options, indexes, hooks } = parseModelMeta(
|
|
109
109
|
metaArg,
|
|
110
110
|
sourceFile,
|
|
111
111
|
stmt.name.text,
|
|
112
112
|
fieldsArg,
|
|
113
113
|
fields,
|
|
114
|
+
resolveCtx,
|
|
114
115
|
)
|
|
115
116
|
|
|
116
117
|
models.push(
|
|
117
|
-
emitModel(stmt.name.text, fields, options, tableName, access, indexes),
|
|
118
|
+
emitModel(stmt.name.text, fields, options, tableName, access, indexes, hooks),
|
|
118
119
|
)
|
|
119
120
|
}
|
|
120
121
|
}
|
|
@@ -907,8 +908,19 @@ function parseScalarType(
|
|
|
907
908
|
return scalar("tsVector")
|
|
908
909
|
case "Money":
|
|
909
910
|
return scalar("money")
|
|
910
|
-
case "Decimal":
|
|
911
|
-
|
|
911
|
+
case "Decimal": {
|
|
912
|
+
// `Decimal<10, 2>` names a precision and a scale, and the engine renders `NUMERIC(p, s)`
|
|
913
|
+
// from them — but nothing used to read the type arguments, so every Decimal became an
|
|
914
|
+
// unbounded NUMERIC. Silently, which is the worst way to lose a constraint on a money column.
|
|
915
|
+
const precision = parseNumericTypeArg(typeNode.typeArguments?.[0], sourceFile)
|
|
916
|
+
const scale = parseNumericTypeArg(typeNode.typeArguments?.[1], sourceFile)
|
|
917
|
+
return scalar("decimal", {
|
|
918
|
+
kernel: {
|
|
919
|
+
...(precision !== undefined && { precision }),
|
|
920
|
+
...(scale !== undefined && { scale }),
|
|
921
|
+
},
|
|
922
|
+
})
|
|
923
|
+
}
|
|
912
924
|
case "DateOnly":
|
|
913
925
|
return scalar("date")
|
|
914
926
|
case "Date":
|
|
@@ -930,7 +942,35 @@ function parseScalarType(
|
|
|
930
942
|
case "Button":
|
|
931
943
|
return scalar("button", { db: { pgType: "JSONB" } })
|
|
932
944
|
case "Duration":
|
|
933
|
-
return scalar("json", {
|
|
945
|
+
return scalar("json", {
|
|
946
|
+
db: { pgType: "JSONB" },
|
|
947
|
+
platform: { editor: "duration" },
|
|
948
|
+
kernel: { tsType: "{ ms: number }" },
|
|
949
|
+
})
|
|
950
|
+
// `Code` and `Currency` each carry two values, so a scalar column would have to drop one:
|
|
951
|
+
// JSONB keeps `lang` with its `source`, and an amount with the currency it is denominated in.
|
|
952
|
+
// `tsType` is what stops the generated client row from flattening them to an opaque object.
|
|
953
|
+
case "Code": {
|
|
954
|
+
const lang = literalStringType(typeNode.typeArguments?.[0])
|
|
955
|
+
return scalar("json", {
|
|
956
|
+
db: { pgType: "JSONB" },
|
|
957
|
+
// `editor` picks the Studio widget. Without it a code snippet and a money amount both get
|
|
958
|
+
// the raw JSON editor, because the widget is otherwise chosen from the column kind alone
|
|
959
|
+
// and both of these are JSONB.
|
|
960
|
+
platform: { editor: "code" },
|
|
961
|
+
kernel: { tsType: `{ lang: ${lang !== null ? JSON.stringify(lang) : "string"}; source: string }` },
|
|
962
|
+
})
|
|
963
|
+
}
|
|
964
|
+
case "Currency": {
|
|
965
|
+
const currencyCode = literalStringType(typeNode.typeArguments?.[0])
|
|
966
|
+
return scalar("json", {
|
|
967
|
+
db: { pgType: "JSONB" },
|
|
968
|
+
platform: { editor: "currency" },
|
|
969
|
+
kernel: {
|
|
970
|
+
tsType: `{ amount: string; code: ${currencyCode !== null ? JSON.stringify(currencyCode) : "string"} }`,
|
|
971
|
+
},
|
|
972
|
+
})
|
|
973
|
+
}
|
|
934
974
|
case "GeoPoint":
|
|
935
975
|
case "Geo":
|
|
936
976
|
return scalar("geo", { kernel: { geoType: "point", srid: 4326 } })
|
|
@@ -1066,7 +1106,10 @@ function parseStringLiteralTuple(node: ts.TypeNode, sourceFile: ts.SourceFile):
|
|
|
1066
1106
|
return out
|
|
1067
1107
|
}
|
|
1068
1108
|
|
|
1069
|
-
function collectBucketContext(
|
|
1109
|
+
function collectBucketContext(
|
|
1110
|
+
sourceFile: ts.SourceFile,
|
|
1111
|
+
resolveCtx: ResolveContext,
|
|
1112
|
+
): {
|
|
1070
1113
|
aliases: Map<string, string>
|
|
1071
1114
|
bucketsById: Map<string, ExtractedStorageBucketAst>
|
|
1072
1115
|
} {
|
|
@@ -1084,7 +1127,7 @@ function collectBucketContext(sourceFile: ts.SourceFile): {
|
|
|
1084
1127
|
|
|
1085
1128
|
const parsed =
|
|
1086
1129
|
configArg && ts.isTypeLiteralNode(configArg)
|
|
1087
|
-
? parseBucketTypeLiteral(configArg, sourceFile)
|
|
1130
|
+
? parseBucketTypeLiteral(configArg, sourceFile, id, resolveCtx)
|
|
1088
1131
|
: {}
|
|
1089
1132
|
|
|
1090
1133
|
const next = buildExtractedBucketAst(id, parsed)
|
|
@@ -1133,6 +1176,8 @@ interface ParsedBucketLiteral {
|
|
|
1133
1176
|
function parseBucketTypeLiteral(
|
|
1134
1177
|
lit: ts.TypeLiteralNode,
|
|
1135
1178
|
sourceFile: ts.SourceFile,
|
|
1179
|
+
bucketId: string,
|
|
1180
|
+
resolveCtx: ResolveContext,
|
|
1136
1181
|
): Partial<ParsedBucketLiteral> {
|
|
1137
1182
|
const out: Partial<ParsedBucketLiteral> = {}
|
|
1138
1183
|
for (const member of lit.members) {
|
|
@@ -1159,7 +1204,7 @@ function parseBucketTypeLiteral(
|
|
|
1159
1204
|
continue
|
|
1160
1205
|
}
|
|
1161
1206
|
if (key === "access") {
|
|
1162
|
-
const acc = parsePartialBucketAccess(member.type, sourceFile)
|
|
1207
|
+
const acc = parsePartialBucketAccess(member.type, sourceFile, bucketId, resolveCtx)
|
|
1163
1208
|
if (acc !== undefined && Object.keys(acc).length > 0) out.access = acc
|
|
1164
1209
|
continue
|
|
1165
1210
|
}
|
|
@@ -1221,14 +1266,28 @@ function parseMimeAcceptList(typeNode: ts.TypeNode, sourceFile: ts.SourceFile):
|
|
|
1221
1266
|
function parsePartialBucketAccess(
|
|
1222
1267
|
typeNode: ts.TypeNode,
|
|
1223
1268
|
sourceFile: ts.SourceFile,
|
|
1269
|
+
bucketId: string,
|
|
1270
|
+
resolveCtx: ResolveContext,
|
|
1224
1271
|
): Record<string, unknown> | undefined {
|
|
1225
|
-
|
|
1272
|
+
// Same hazard as model access: silently dropping these rules publishes a
|
|
1273
|
+
// bucket with no read/write restrictions, so an alias must resolve and an
|
|
1274
|
+
// unresolvable annotation must fail rather than degrade to "no rules".
|
|
1275
|
+
const literal = resolveAccessLiteral(typeNode, sourceFile, resolveCtx)
|
|
1276
|
+
if (!literal) {
|
|
1277
|
+
throw new Error(
|
|
1278
|
+
`Bucket "${bucketId}": could not resolve its \`access\` rules from ` +
|
|
1279
|
+
`\`${typeNode.getText(sourceFile)}\`.\n` +
|
|
1280
|
+
` access must be an object type, or a type alias for one, e.g.\n` +
|
|
1281
|
+
` export type MediaAccess = { read: Public; create: Authenticated }\n` +
|
|
1282
|
+
` Refusing to continue: an unresolved access block would leave this bucket unrestricted.`,
|
|
1283
|
+
)
|
|
1284
|
+
}
|
|
1226
1285
|
const access: Record<string, unknown> = {}
|
|
1227
|
-
for (const member of
|
|
1286
|
+
for (const member of literal.members) {
|
|
1228
1287
|
if (!ts.isPropertySignature(member) || !member.type) continue
|
|
1229
1288
|
const key = getPropertyName(member.name)
|
|
1230
1289
|
if (key !== "read" && key !== "create" && key !== "delete") continue
|
|
1231
|
-
access[key] = parseAccessRule(member.type, sourceFile)
|
|
1290
|
+
access[key] = parseAccessRule(member.type, sourceFile, resolveCtx)
|
|
1232
1291
|
}
|
|
1233
1292
|
return access
|
|
1234
1293
|
}
|
|
@@ -1554,11 +1613,13 @@ function parseModelMeta(
|
|
|
1554
1613
|
modelName: string,
|
|
1555
1614
|
fieldsArg: ts.TypeNode,
|
|
1556
1615
|
fields: Record<string, FieldAstV2>,
|
|
1616
|
+
resolveCtx: ResolveContext,
|
|
1557
1617
|
): {
|
|
1558
1618
|
tableName: string
|
|
1559
1619
|
access: Record<string, unknown>
|
|
1560
1620
|
options: Record<string, unknown>
|
|
1561
1621
|
indexes: unknown[]
|
|
1622
|
+
hooks: Record<string, ParsedModelHook>
|
|
1562
1623
|
} {
|
|
1563
1624
|
const literal = parseMetaLiteral(metaArg, sourceFile)
|
|
1564
1625
|
const singleton = literal.singleton === true
|
|
@@ -1582,9 +1643,87 @@ function parseModelMeta(
|
|
|
1582
1643
|
|
|
1583
1644
|
return {
|
|
1584
1645
|
tableName,
|
|
1585
|
-
access: parseModelAccess(metaArg, sourceFile),
|
|
1646
|
+
access: parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx),
|
|
1586
1647
|
options,
|
|
1587
1648
|
indexes: parseModelIndexes(metaArg, sourceFile, fields),
|
|
1649
|
+
hooks: parseModelHooks(metaArg, sourceFile),
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/** One lifecycle hook: `"fn-name"` or `{ function: "fn-name", timeout: 5000 }`. */
|
|
1654
|
+
interface ParsedModelHook {
|
|
1655
|
+
function: string
|
|
1656
|
+
timeout?: number
|
|
1657
|
+
onUnavailable?: "reject" | "log"
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
const HOOK_EVENTS = ["beforeChange", "afterChange", "beforeDelete", "afterDelete"] as const
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Read `hooks` from a model's meta.
|
|
1664
|
+
*
|
|
1665
|
+
* Deliberately strict: an entry that is neither a string nor an object with a `function` name is
|
|
1666
|
+
* **dropped**, and `validateModelHooks` then reports the model whose hook did not survive. A hook
|
|
1667
|
+
* silently not firing is the failure this feature cannot have, so an unreadable declaration must
|
|
1668
|
+
* fail the push rather than extract to nothing.
|
|
1669
|
+
*/
|
|
1670
|
+
function parseModelHooks(
|
|
1671
|
+
metaArg: ts.TypeNode | undefined,
|
|
1672
|
+
sourceFile: ts.SourceFile,
|
|
1673
|
+
): Record<string, ParsedModelHook> {
|
|
1674
|
+
if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return {}
|
|
1675
|
+
|
|
1676
|
+
const hooksProp = metaArg.members.find(
|
|
1677
|
+
(member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "hooks",
|
|
1678
|
+
)
|
|
1679
|
+
if (!hooksProp || !ts.isPropertySignature(hooksProp) || !hooksProp.type) return {}
|
|
1680
|
+
if (!ts.isTypeLiteralNode(hooksProp.type)) return {}
|
|
1681
|
+
|
|
1682
|
+
const hooks: Record<string, ParsedModelHook> = {}
|
|
1683
|
+
for (const member of hooksProp.type.members) {
|
|
1684
|
+
if (!ts.isPropertySignature(member) || !member.type) continue
|
|
1685
|
+
const event = getPropertyName(member.name)
|
|
1686
|
+
if (!event || !(HOOK_EVENTS as readonly string[]).includes(event)) continue
|
|
1687
|
+
|
|
1688
|
+
const parsed = parseModelHookValue(member.type, sourceFile)
|
|
1689
|
+
if (parsed !== null) hooks[event] = parsed
|
|
1690
|
+
}
|
|
1691
|
+
return hooks
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function parseModelHookValue(
|
|
1695
|
+
node: ts.TypeNode,
|
|
1696
|
+
sourceFile: ts.SourceFile,
|
|
1697
|
+
): ParsedModelHook | null {
|
|
1698
|
+
const literal = literalStringType(node)
|
|
1699
|
+
if (literal !== null) {
|
|
1700
|
+
return literal.trim().length > 0 ? { function: literal } : null
|
|
1701
|
+
}
|
|
1702
|
+
if (!ts.isTypeLiteralNode(node)) return null
|
|
1703
|
+
|
|
1704
|
+
let fn: string | undefined
|
|
1705
|
+
let timeout: number | undefined
|
|
1706
|
+
let onUnavailable: "reject" | "log" | undefined
|
|
1707
|
+
|
|
1708
|
+
for (const member of node.members) {
|
|
1709
|
+
if (!ts.isPropertySignature(member) || !member.type) continue
|
|
1710
|
+
const key = getPropertyName(member.name)
|
|
1711
|
+
if (key === "function") {
|
|
1712
|
+
const value = literalStringType(member.type)
|
|
1713
|
+
if (value !== null && value.trim().length > 0) fn = value
|
|
1714
|
+
} else if (key === "timeout") {
|
|
1715
|
+
timeout = parseNumericTypeArg(member.type, sourceFile)
|
|
1716
|
+
} else if (key === "onUnavailable") {
|
|
1717
|
+
const value = literalStringType(member.type)
|
|
1718
|
+
if (value === "reject" || value === "log") onUnavailable = value
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
if (fn === undefined) return null
|
|
1723
|
+
return {
|
|
1724
|
+
function: fn,
|
|
1725
|
+
...(timeout !== undefined && { timeout }),
|
|
1726
|
+
...(onUnavailable !== undefined && { onUnavailable }),
|
|
1588
1727
|
}
|
|
1589
1728
|
}
|
|
1590
1729
|
|
|
@@ -1646,28 +1785,289 @@ function resolveIndexFieldName(fieldName: string, fields: Record<string, FieldAs
|
|
|
1646
1785
|
return fieldName
|
|
1647
1786
|
}
|
|
1648
1787
|
|
|
1649
|
-
|
|
1788
|
+
/**
|
|
1789
|
+
* Resolve an `access:` annotation to its rule object.
|
|
1790
|
+
*
|
|
1791
|
+
* `access` may be written inline (`access: { read: Public }`) or factored into a
|
|
1792
|
+
* shared type alias (`access: CmsPublicReadAdminWrite`) — the latter is the
|
|
1793
|
+
* natural way to reuse one policy set across many models, so it has to resolve
|
|
1794
|
+
* through aliases and intersections, not just literals.
|
|
1795
|
+
*
|
|
1796
|
+
* Anything unresolvable throws. Returning `{}` here means "no access rules",
|
|
1797
|
+
* which the differ treats as "emit no RLS policies" — so a typo or an
|
|
1798
|
+
* unsupported shape silently published a table with no row-level protection at
|
|
1799
|
+
* all. Failing the extract is the only safe outcome.
|
|
1800
|
+
*/
|
|
1801
|
+
function parseModelAccess(
|
|
1802
|
+
metaArg: ts.TypeNode | undefined,
|
|
1803
|
+
sourceFile: ts.SourceFile,
|
|
1804
|
+
modelName: string,
|
|
1805
|
+
fields: Record<string, FieldAstV2>,
|
|
1806
|
+
resolveCtx: ResolveContext,
|
|
1807
|
+
): Record<string, unknown> {
|
|
1650
1808
|
if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return {}
|
|
1651
1809
|
const accessProp = metaArg.members.find(
|
|
1652
1810
|
(member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "access",
|
|
1653
1811
|
)
|
|
1654
|
-
if (!accessProp || !ts.isPropertySignature(accessProp) || !accessProp.type
|
|
1812
|
+
if (!accessProp || !ts.isPropertySignature(accessProp) || !accessProp.type) {
|
|
1655
1813
|
return {}
|
|
1656
1814
|
}
|
|
1657
1815
|
|
|
1816
|
+
const literal = resolveAccessLiteral(accessProp.type, sourceFile, resolveCtx)
|
|
1817
|
+
if (!literal) {
|
|
1818
|
+
throw new Error(
|
|
1819
|
+
`Model "${modelName}": could not resolve its \`access\` rules from ` +
|
|
1820
|
+
`\`${accessProp.type.getText(sourceFile)}\`.\n` +
|
|
1821
|
+
` access must be an object type, or a type alias for one:\n` +
|
|
1822
|
+
` export type PublicReadAdminWrite = { read: Public; create: Role<"admin"> }\n` +
|
|
1823
|
+
` A \`const … as const\` value cannot be used here — access is read from types, and\n` +
|
|
1824
|
+
` rule names like Public / Role are types, not values.\n` +
|
|
1825
|
+
` Refusing to continue: an unresolved access block would publish this table with no RLS.`,
|
|
1826
|
+
)
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1658
1829
|
const access: Record<string, unknown> = {}
|
|
1659
|
-
for (const member of
|
|
1830
|
+
for (const member of literal.members) {
|
|
1660
1831
|
if (!ts.isPropertySignature(member) || !member.type) continue
|
|
1661
1832
|
const key = getPropertyName(member.name)
|
|
1662
1833
|
if (!key) continue
|
|
1663
|
-
|
|
1834
|
+
|
|
1835
|
+
// `update: { using, check }` splits which rows may be changed from what they
|
|
1836
|
+
// may be changed into. Flattened here into the two AST fields the engine
|
|
1837
|
+
// renders, so the engine never has to know about the sugar.
|
|
1838
|
+
if (key === "update") {
|
|
1839
|
+
const split = parseUpdateSplit(member.type, sourceFile, resolveCtx)
|
|
1840
|
+
if (split) {
|
|
1841
|
+
access["update"] = split.using
|
|
1842
|
+
if (split.check !== undefined) access["updateCheck"] = split.check
|
|
1843
|
+
continue
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
if (key === "fields") {
|
|
1848
|
+
const fieldRules = parseFieldAccess(member.type, sourceFile, modelName, fields, resolveCtx)
|
|
1849
|
+
if (Object.keys(fieldRules).length > 0) access["fields"] = fieldRules
|
|
1850
|
+
continue
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
access[key] = parseAccessRule(member.type, sourceFile, resolveCtx)
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
if (Object.keys(access).length === 0) {
|
|
1857
|
+
throw new Error(
|
|
1858
|
+
`Model "${modelName}": \`access\` resolved to an empty rule set ` +
|
|
1859
|
+
`(from \`${accessProp.type.getText(sourceFile)}\`). Remove \`access\` if the table is ` +
|
|
1860
|
+
`intentionally unrestricted, or declare at least one of read/create/update/delete.`,
|
|
1861
|
+
)
|
|
1664
1862
|
}
|
|
1665
1863
|
return access
|
|
1666
1864
|
}
|
|
1667
1865
|
|
|
1668
|
-
|
|
1866
|
+
/**
|
|
1867
|
+
* `fields: { [column]: { read?, write? } }` — per-column narrowing of the table rules.
|
|
1868
|
+
*
|
|
1869
|
+
* Any rule the DSL can express is allowed. Enforcement is a query rewrite that
|
|
1870
|
+
* evaluates the rule per row with the caller's claims, so ownership, membership and
|
|
1871
|
+
* application roles all work here — none of which a column privilege could express.
|
|
1872
|
+
*/
|
|
1873
|
+
function parseFieldAccess(
|
|
1874
|
+
typeNode: ts.TypeNode,
|
|
1875
|
+
sourceFile: ts.SourceFile,
|
|
1876
|
+
modelName: string,
|
|
1877
|
+
fields: Record<string, FieldAstV2>,
|
|
1878
|
+
resolveCtx?: ResolveContext,
|
|
1879
|
+
): Record<string, Record<string, unknown>> {
|
|
1880
|
+
const literal = resolveCtx
|
|
1881
|
+
? resolveAccessLiteral(typeNode, sourceFile, resolveCtx)
|
|
1882
|
+
: ts.isTypeLiteralNode(typeNode)
|
|
1883
|
+
? typeNode
|
|
1884
|
+
: null
|
|
1885
|
+
if (!literal) {
|
|
1886
|
+
throw new Error(
|
|
1887
|
+
`Model "${modelName}": could not resolve \`access.fields\` from ` +
|
|
1888
|
+
`\`${typeNode.getText(sourceFile)}\`. It must be an object type, or a type ` +
|
|
1889
|
+
`alias for one: \`fields: { salary: { read: Private } }\`.`,
|
|
1890
|
+
)
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const out: Record<string, Record<string, unknown>> = {}
|
|
1894
|
+
for (const member of literal.members) {
|
|
1895
|
+
if (!ts.isPropertySignature(member) || !member.type) continue
|
|
1896
|
+
const declared = getPropertyName(member.name)
|
|
1897
|
+
if (!declared) continue
|
|
1898
|
+
// A privilege is granted on a column, and a relation field is not one --
|
|
1899
|
+
// `author` is stored as `author_id`. Same mapping the index builder uses.
|
|
1900
|
+
// Names that match nothing are passed through rather than rejected: composite
|
|
1901
|
+
// wrappers expand into columns this map never sees, so a hard error here would
|
|
1902
|
+
// refuse valid schemas. Postgres rejects the unknown column at push instead.
|
|
1903
|
+
const column = resolveIndexFieldName(declared, fields) ?? declared
|
|
1904
|
+
|
|
1905
|
+
const columnLiteral = resolveCtx
|
|
1906
|
+
? resolveAccessLiteral(member.type, sourceFile, resolveCtx)
|
|
1907
|
+
: ts.isTypeLiteralNode(member.type)
|
|
1908
|
+
? member.type
|
|
1909
|
+
: null
|
|
1910
|
+
if (!columnLiteral) {
|
|
1911
|
+
throw new Error(
|
|
1912
|
+
`Model "${modelName}": \`access.fields.${column}\` must be an object with ` +
|
|
1913
|
+
`\`read\` and/or \`write\`, found \`${member.type.getText(sourceFile)}\`.`,
|
|
1914
|
+
)
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
const rules: Record<string, unknown> = {}
|
|
1918
|
+
for (const opMember of columnLiteral.members) {
|
|
1919
|
+
if (!ts.isPropertySignature(opMember) || !opMember.type) continue
|
|
1920
|
+
const operation = getPropertyName(opMember.name)
|
|
1921
|
+
if (operation !== "read" && operation !== "write") {
|
|
1922
|
+
throw new Error(
|
|
1923
|
+
`Model "${modelName}": \`access.fields.${column}\` may only contain ` +
|
|
1924
|
+
`\`read\` and \`write\`, found "${operation}".`,
|
|
1925
|
+
)
|
|
1926
|
+
}
|
|
1927
|
+
rules[operation] = parseAccessRule(opMember.type, sourceFile, resolveCtx)
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// An empty `{}` reads as "this column is restricted" but restricts nothing.
|
|
1931
|
+
if (Object.keys(rules).length === 0) {
|
|
1932
|
+
throw new Error(
|
|
1933
|
+
`Model "${modelName}": \`access.fields.${column}\` declares no rules. ` +
|
|
1934
|
+
`Give it a \`read\` or a \`write\`, or remove the entry.`,
|
|
1935
|
+
)
|
|
1936
|
+
}
|
|
1937
|
+
out[column] = rules
|
|
1938
|
+
}
|
|
1939
|
+
return out
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* `update: { using: …, check: … }`, or null when `update` is a plain rule.
|
|
1944
|
+
*
|
|
1945
|
+
* An update policy has two halves: `USING` picks which existing rows may be
|
|
1946
|
+
* modified, `WITH CHECK` constrains what they may become. One rule for both is the
|
|
1947
|
+
* right default and what every existing schema means — but it makes "an editor may
|
|
1948
|
+
* move a post between their own sites" inexpressible, because the row they are
|
|
1949
|
+
* changing and the row they are changing it into are judged by the same predicate.
|
|
1950
|
+
*/
|
|
1951
|
+
function parseUpdateSplit(
|
|
1952
|
+
typeNode: ts.TypeNode,
|
|
1953
|
+
sourceFile: ts.SourceFile,
|
|
1954
|
+
resolveCtx?: ResolveContext,
|
|
1955
|
+
): { using: Record<string, unknown>; check?: Record<string, unknown> } | null {
|
|
1956
|
+
const literal = resolveCtx
|
|
1957
|
+
? resolveAccessLiteral(typeNode, sourceFile, resolveCtx)
|
|
1958
|
+
: ts.isTypeLiteralNode(typeNode)
|
|
1959
|
+
? typeNode
|
|
1960
|
+
: null
|
|
1961
|
+
if (!literal) return null
|
|
1962
|
+
|
|
1963
|
+
let using: ts.TypeNode | undefined
|
|
1964
|
+
let check: ts.TypeNode | undefined
|
|
1965
|
+
for (const member of literal.members) {
|
|
1966
|
+
if (!ts.isPropertySignature(member) || !member.type) continue
|
|
1967
|
+
const name = getPropertyName(member.name)
|
|
1968
|
+
if (name === "using") using = member.type
|
|
1969
|
+
else if (name === "check") check = member.type
|
|
1970
|
+
else if (name !== undefined) {
|
|
1971
|
+
throw new Error(
|
|
1972
|
+
`\`update\` may only contain \`using\` and \`check\`, found "${name}".`,
|
|
1973
|
+
)
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Not the split form at all — an object with neither key is some other shape.
|
|
1978
|
+
if (!using && !check) return null
|
|
1979
|
+
if (!using) {
|
|
1980
|
+
throw new Error(
|
|
1981
|
+
"`update: { check }` needs a `using` rule too — without it no row is " +
|
|
1982
|
+
"selectable for update, so the check can never apply.",
|
|
1983
|
+
)
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const parsed: { using: Record<string, unknown>; check?: Record<string, unknown> } = {
|
|
1987
|
+
using: parseAccessRule(using, sourceFile, resolveCtx),
|
|
1988
|
+
}
|
|
1989
|
+
if (check) parsed.check = parseAccessRule(check, sourceFile, resolveCtx)
|
|
1990
|
+
return parsed
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** Walk aliases/intersections down to the object type holding the access rules. */
|
|
1994
|
+
function resolveAccessLiteral(
|
|
1995
|
+
typeNode: ts.TypeNode,
|
|
1996
|
+
sourceFile: ts.SourceFile,
|
|
1997
|
+
resolveCtx: ResolveContext,
|
|
1998
|
+
depth = 0,
|
|
1999
|
+
): ts.TypeLiteralNode | null {
|
|
2000
|
+
if (depth > 16) return null
|
|
2001
|
+
if (ts.isTypeLiteralNode(typeNode)) return typeNode
|
|
2002
|
+
|
|
2003
|
+
if (ts.isIntersectionTypeNode(typeNode)) {
|
|
2004
|
+
const members: ts.TypeElement[] = []
|
|
2005
|
+
for (const part of typeNode.types) {
|
|
2006
|
+
const resolved = resolveAccessLiteral(part, sourceFile, resolveCtx, depth + 1)
|
|
2007
|
+
if (!resolved) return null
|
|
2008
|
+
members.push(...resolved.members)
|
|
2009
|
+
}
|
|
2010
|
+
return ts.factory.createTypeLiteralNode(members)
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
if (ts.isTypeReferenceNode(typeNode)) {
|
|
2014
|
+
const expanded = tryResolveTypeReference(typeNode, sourceFile, resolveCtx)
|
|
2015
|
+
if (expanded) return resolveAccessLiteral(expanded, sourceFile, resolveCtx, depth + 1)
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
if (needsChecker(typeNode)) {
|
|
2019
|
+
const resolved = resolveTypeNode(typeNode, sourceFile, resolveCtx)
|
|
2020
|
+
if (resolved !== typeNode) {
|
|
2021
|
+
return resolveAccessLiteral(resolved, sourceFile, resolveCtx, depth + 1)
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
return null
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* `access` rules may be declared in a different file from the model (a shared
|
|
2030
|
+
* alias), and `getText(file)` reads the given file's text at the node's offsets
|
|
2031
|
+
* — so passing the model's file for a node owned by another one yielded garbage
|
|
2032
|
+
* and every rule silently fell through to `private`. Always read a node's text
|
|
2033
|
+
* from the file that actually owns it.
|
|
2034
|
+
*/
|
|
2035
|
+
function ownerText(node: ts.Node, fallback: ts.SourceFile): string {
|
|
2036
|
+
const own = node.pos >= 0 ? node.getSourceFile() : undefined
|
|
2037
|
+
return node.getText(own ?? fallback)
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function parseAccessRule(
|
|
2041
|
+
typeNode: ts.TypeNode,
|
|
2042
|
+
sourceFile: ts.SourceFile,
|
|
2043
|
+
resolveCtx?: ResolveContext,
|
|
2044
|
+
depth = 0,
|
|
2045
|
+
): Record<string, unknown> {
|
|
2046
|
+
// Reuse a single rule from a shared set: `access: { read: Rules["read"] }`.
|
|
2047
|
+
if (ts.isIndexedAccessTypeNode(typeNode) && resolveCtx) {
|
|
2048
|
+
const member = resolveIndexedAccessRule(typeNode, sourceFile, resolveCtx)
|
|
2049
|
+
if (member) return parseAccessRule(member, sourceFile, resolveCtx)
|
|
2050
|
+
throw new Error(
|
|
2051
|
+
`Could not resolve access rule from \`${ownerText(typeNode, sourceFile)}\`. ` +
|
|
2052
|
+
`Reference a rule set member as \`Rules["read"]\`, where Rules is a type alias.`,
|
|
2053
|
+
)
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1669
2056
|
if (!ts.isTypeReferenceNode(typeNode)) return { type: "private" }
|
|
1670
|
-
|
|
2057
|
+
|
|
2058
|
+
// `Rules.read` is a qualified name (namespace lookup), not a member of a type
|
|
2059
|
+
// alias — TypeScript rejects it, and it used to fall through to `private`,
|
|
2060
|
+
// silently denying an operation the author meant to grant.
|
|
2061
|
+
if (ts.isQualifiedName(typeNode.typeName)) {
|
|
2062
|
+
const text = ownerText(typeNode.typeName, sourceFile)
|
|
2063
|
+
const [head, ...rest] = text.split(".")
|
|
2064
|
+
throw new Error(
|
|
2065
|
+
`Access rule \`${text}\` is not valid: a member of a rule-set type must be written ` +
|
|
2066
|
+
`\`${head}["${rest.join(".")}"]\`, not \`${text}\`.`,
|
|
2067
|
+
)
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
const ref = ownerText(typeNode.typeName, sourceFile)
|
|
1671
2071
|
switch (ref) {
|
|
1672
2072
|
case "Public":
|
|
1673
2073
|
case "BucketPublic":
|
|
@@ -1684,24 +2084,459 @@ function parseAccessRule(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): Reco
|
|
|
1684
2084
|
const args = typeNode.typeArguments ?? []
|
|
1685
2085
|
const keyArg = args.length >= 2 ? args[1] : args[0]
|
|
1686
2086
|
// Must match engine `AccessRule::Owner { field }` (see supatype-schema-engine parser/ast.rs).
|
|
1687
|
-
return {
|
|
2087
|
+
return {
|
|
2088
|
+
type: "owner",
|
|
2089
|
+
field: keyArg ? ownerText(keyArg, sourceFile).replace(/['"]/g, "") : "user_id",
|
|
2090
|
+
}
|
|
1688
2091
|
}
|
|
1689
2092
|
case "OwnerFrom": {
|
|
1690
2093
|
const relationArg = typeNode.typeArguments?.[0]
|
|
1691
|
-
const relationField = relationArg
|
|
2094
|
+
const relationField = relationArg
|
|
2095
|
+
? ownerText(relationArg, sourceFile).replace(/['"]/g, "")
|
|
2096
|
+
: "owner"
|
|
1692
2097
|
return { type: "owner", field: relationField }
|
|
1693
2098
|
}
|
|
1694
|
-
case "Role":
|
|
1695
|
-
const roleArg = typeNode.typeArguments?.[0]
|
|
1696
|
-
return { type: "role", roles: [roleArg?.getText(sourceFile).replace(/['"]/g, "") ?? "admin"] }
|
|
1697
|
-
}
|
|
2099
|
+
case "Role":
|
|
1698
2100
|
case "BucketRole": {
|
|
1699
2101
|
const roleArg = typeNode.typeArguments?.[0]
|
|
1700
|
-
return {
|
|
2102
|
+
return {
|
|
2103
|
+
type: "role",
|
|
2104
|
+
roles: [roleArg ? ownerText(roleArg, sourceFile).replace(/['"]/g, "") : "admin"],
|
|
2105
|
+
}
|
|
1701
2106
|
}
|
|
1702
|
-
|
|
1703
|
-
|
|
2107
|
+
case "Any": {
|
|
2108
|
+
const listArg = typeNode.typeArguments?.[0]
|
|
2109
|
+
if (!listArg || !ts.isTupleTypeNode(listArg)) {
|
|
2110
|
+
throw new Error(
|
|
2111
|
+
`\`Any<>\` takes a tuple of rules, as in ` +
|
|
2112
|
+
`\`Any<[Role<"admin">, Owner<"author_id">]>\`.`,
|
|
2113
|
+
)
|
|
2114
|
+
}
|
|
2115
|
+
// An empty list would compile to a policy that grants nothing while
|
|
2116
|
+
// reading like a grant — the exact silent-denial failure the unknown-rule
|
|
2117
|
+
// branch below exists to prevent.
|
|
2118
|
+
if (listArg.elements.length === 0) {
|
|
2119
|
+
throw new Error(
|
|
2120
|
+
"`Any<[]>` grants nothing. List the rules that should allow access, or " +
|
|
2121
|
+
"use `Private` if denying is the intent.",
|
|
2122
|
+
)
|
|
2123
|
+
}
|
|
2124
|
+
return {
|
|
2125
|
+
type: "any",
|
|
2126
|
+
rules: listArg.elements.map((element) =>
|
|
2127
|
+
parseAccessRule(element, sourceFile, resolveCtx),
|
|
2128
|
+
),
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
case "All": {
|
|
2132
|
+
const listArg = typeNode.typeArguments?.[0]
|
|
2133
|
+
if (!listArg || !ts.isTupleTypeNode(listArg)) {
|
|
2134
|
+
throw new Error(
|
|
2135
|
+
'`All<>` takes a tuple of rules, as in `All<[Role<"editor">, NotNull<"published_at">]>`.',
|
|
2136
|
+
)
|
|
2137
|
+
}
|
|
2138
|
+
// Unlike `Any<[]>`, an empty `All` grants *everything* — an empty AND is
|
|
2139
|
+
// true. Reading as a restriction while imposing none is worse than an error.
|
|
2140
|
+
if (listArg.elements.length === 0) {
|
|
2141
|
+
throw new Error(
|
|
2142
|
+
"`All<[]>` restricts nothing and grants everything. List the rules that " +
|
|
2143
|
+
"must hold, or use `Public` if that is the intent.",
|
|
2144
|
+
)
|
|
2145
|
+
}
|
|
2146
|
+
return {
|
|
2147
|
+
type: "all",
|
|
2148
|
+
rules: listArg.elements.map((element) =>
|
|
2149
|
+
parseAccessRule(element, sourceFile, resolveCtx),
|
|
2150
|
+
),
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
case "Not": {
|
|
2154
|
+
const inner = typeNode.typeArguments?.[0]
|
|
2155
|
+
if (!inner) {
|
|
2156
|
+
throw new Error('`Not<>` takes one rule, as in `Not<Role<"banned">>`.')
|
|
2157
|
+
}
|
|
2158
|
+
return { type: "not", rule: parseAccessRule(inner, sourceFile, resolveCtx) }
|
|
2159
|
+
}
|
|
2160
|
+
case "Eq":
|
|
2161
|
+
case "Neq":
|
|
2162
|
+
case "Gt":
|
|
2163
|
+
case "Gte":
|
|
2164
|
+
case "Lt":
|
|
2165
|
+
case "Lte":
|
|
2166
|
+
case "Like": {
|
|
2167
|
+
const args = typeNode.typeArguments ?? []
|
|
2168
|
+
if (args.length !== 2) {
|
|
2169
|
+
throw new Error(
|
|
2170
|
+
`\`${ref}<>\` takes two operands, as in \`${ref}<"author_id", AuthUid>\`.`,
|
|
2171
|
+
)
|
|
2172
|
+
}
|
|
2173
|
+
return {
|
|
2174
|
+
type: "compare",
|
|
2175
|
+
op: ref.toLowerCase(),
|
|
2176
|
+
left: parseAccessOperand(args[0]!, sourceFile),
|
|
2177
|
+
right: parseAccessOperand(args[1]!, sourceFile),
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
case "IsNull":
|
|
2181
|
+
case "NotNull": {
|
|
2182
|
+
const operand = typeNode.typeArguments?.[0]
|
|
2183
|
+
if (!operand) {
|
|
2184
|
+
throw new Error(`\`${ref}<>\` takes one operand, as in \`${ref}<"deleted_at">\`.`)
|
|
2185
|
+
}
|
|
2186
|
+
return {
|
|
2187
|
+
type: "nullCheck",
|
|
2188
|
+
operand: parseAccessOperand(operand, sourceFile),
|
|
2189
|
+
isNull: ref === "IsNull",
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
case "Exists": {
|
|
2193
|
+
const source = typeNode.typeArguments?.[0]
|
|
2194
|
+
if (!source) {
|
|
2195
|
+
throw new Error('`Exists<>` takes a source, as in `Exists<MySites>`.')
|
|
2196
|
+
}
|
|
2197
|
+
return {
|
|
2198
|
+
type: "exists",
|
|
2199
|
+
source: parseMembershipSource(source, sourceFile, resolveCtx),
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
case "In": {
|
|
2203
|
+
const args = typeNode.typeArguments ?? []
|
|
2204
|
+
if (args.length !== 2) {
|
|
2205
|
+
throw new Error(
|
|
2206
|
+
'`In<>` takes a column and a source, as in `In<"site_id", MySites>`.',
|
|
2207
|
+
)
|
|
2208
|
+
}
|
|
2209
|
+
const column = parseAccessOperand(args[0]!, sourceFile)
|
|
2210
|
+
if (column["kind"] !== "column") {
|
|
2211
|
+
throw new Error(
|
|
2212
|
+
"`In<>`'s first argument must be a column name — the membership test is " +
|
|
2213
|
+
"about a column of the row being checked.",
|
|
2214
|
+
)
|
|
2215
|
+
}
|
|
2216
|
+
return {
|
|
2217
|
+
type: "in",
|
|
2218
|
+
column: column["name"],
|
|
2219
|
+
source: parseMembershipSource(args[1]!, sourceFile, resolveCtx),
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
default: {
|
|
2223
|
+
// A named alias — possibly parameterised — standing for a rule. Expanded
|
|
2224
|
+
// before giving up, so `SiteAccess<"site_id">` works.
|
|
2225
|
+
if (resolveCtx && depth < 16) {
|
|
2226
|
+
const expanded = resolveAccessAliasNode(typeNode, sourceFile, resolveCtx)
|
|
2227
|
+
if (expanded) return parseAccessRule(expanded, sourceFile, resolveCtx, depth + 1)
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// Falling back to `private` here would silently deny an operation the
|
|
2231
|
+
// author believed they had granted — and with deny-by-default there is no
|
|
2232
|
+
// longer any need for a permissive guess. Name the offending rule instead.
|
|
2233
|
+
throw new Error(
|
|
2234
|
+
`Unknown access rule "${ref}". Supported: Public, Private, LoggedIn, ` +
|
|
2235
|
+
`Owner<"field">, OwnerFrom<"relation">, Role<"name">, ` +
|
|
2236
|
+
`Any<[rule, …]>, All<[rule, …]>, Not<rule>, ` +
|
|
2237
|
+
`Eq/Neq/Gt/Gte/Lt/Lte/Like<left, right>, IsNull<operand>, NotNull<operand>` +
|
|
2238
|
+
` (buckets also accept BucketPublic, BucketPrivate, BucketLoggedIn, BucketOwner, BucketRole).`,
|
|
2239
|
+
)
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Expands a named alias to the type it stands for.
|
|
2246
|
+
*
|
|
2247
|
+
* This is what makes rules parameterisable — the type-level equivalent of
|
|
2248
|
+
* Payload's access-control factory:
|
|
2249
|
+
*
|
|
2250
|
+
* ```typescript
|
|
2251
|
+
* type SiteAccess<F extends string> = Any<[Role<"admin">, In<F, MySites>]>
|
|
2252
|
+
* access: { update: SiteAccess<"site_id"> }
|
|
2253
|
+
* ```
|
|
2254
|
+
*
|
|
2255
|
+
* Without it a rule had to be written inline at every use, and any alias — even an
|
|
2256
|
+
* unparameterised one — was reported as an unknown rule.
|
|
2257
|
+
*/
|
|
2258
|
+
function resolveAccessAliasNode(
|
|
2259
|
+
typeNode: ts.TypeReferenceNode,
|
|
2260
|
+
sourceFile: ts.SourceFile,
|
|
2261
|
+
resolveCtx: ResolveContext,
|
|
2262
|
+
): ts.TypeNode | undefined {
|
|
2263
|
+
const expanded = tryResolveTypeReference(typeNode, sourceFile, resolveCtx)
|
|
2264
|
+
if (expanded && expanded !== typeNode) return expanded
|
|
2265
|
+
|
|
2266
|
+
if (needsChecker(typeNode)) {
|
|
2267
|
+
const resolved = resolveTypeNode(typeNode, sourceFile, resolveCtx)
|
|
2268
|
+
if (resolved !== typeNode) return resolved
|
|
1704
2269
|
}
|
|
2270
|
+
return undefined
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* The set on the right of an `In<>`.
|
|
2275
|
+
*
|
|
2276
|
+
* Resolved through the same alias machinery as rules, so a shared
|
|
2277
|
+
* `type MySites = Rows<…>` can be reused across models — the parameterisation the
|
|
2278
|
+
* plan calls the type-level equivalent of Payload's access factory.
|
|
2279
|
+
*/
|
|
2280
|
+
function parseMembershipSource(
|
|
2281
|
+
typeNode: ts.TypeNode,
|
|
2282
|
+
sourceFile: ts.SourceFile,
|
|
2283
|
+
resolveCtx?: ResolveContext,
|
|
2284
|
+
): Record<string, unknown> {
|
|
2285
|
+
if (ts.isIndexedAccessTypeNode(typeNode) && resolveCtx) {
|
|
2286
|
+
const member = resolveIndexedAccessRule(typeNode, sourceFile, resolveCtx)
|
|
2287
|
+
if (member) return parseMembershipSource(member, sourceFile, resolveCtx)
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
if (ts.isTypeReferenceNode(typeNode) && !ts.isQualifiedName(typeNode.typeName)) {
|
|
2291
|
+
const ref = ownerText(typeNode.typeName, sourceFile)
|
|
2292
|
+
const args = typeNode.typeArguments ?? []
|
|
2293
|
+
|
|
2294
|
+
if (ref === "Claim") {
|
|
2295
|
+
return parseAccessOperand(typeNode, sourceFile)
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
if (ref === "Rows") {
|
|
2299
|
+
const table = stringLiteralArg(args[0], "Rows", "table name")
|
|
2300
|
+
const column = stringLiteralArg(args[1], "Rows", "column name")
|
|
2301
|
+
const source: Record<string, unknown> = { kind: "rows", table, column }
|
|
2302
|
+
// The third argument narrows the source rows. Absent means "every row",
|
|
2303
|
+
// which is a lookup rather than a membership check — allowed, but rarely
|
|
2304
|
+
// what was meant, so it stays explicit rather than being defaulted.
|
|
2305
|
+
if (args[2]) {
|
|
2306
|
+
source["where"] = parseAccessRule(args[2], sourceFile, resolveCtx)
|
|
2307
|
+
}
|
|
2308
|
+
return source
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
if (ref === "Values") {
|
|
2312
|
+
const listArg = args[0]
|
|
2313
|
+
if (!listArg || !ts.isTupleTypeNode(listArg)) {
|
|
2314
|
+
throw new Error('`Values<>` takes a tuple, as in `Values<["draft", "review"]>`.')
|
|
2315
|
+
}
|
|
2316
|
+
if (listArg.elements.length === 0) {
|
|
2317
|
+
throw new Error("`Values<[]>` matches nothing. List the values, or use `Private`.")
|
|
2318
|
+
}
|
|
2319
|
+
// Not `parseAccessOperand`: there, a bare string is a *column*, which is
|
|
2320
|
+
// right for a comparison and wrong here. Inside a value list a string can
|
|
2321
|
+
// only be a value, so it is read as one — no `Literal<>` wrapper needed.
|
|
2322
|
+
return {
|
|
2323
|
+
kind: "literal",
|
|
2324
|
+
values: listArg.elements.map((element) => literalFromNode(element, sourceFile)),
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// A named alias for a source: `type MySites = Rows<…>`.
|
|
2329
|
+
if (resolveCtx) {
|
|
2330
|
+
const resolved = resolveAccessAliasNode(typeNode, sourceFile, resolveCtx)
|
|
2331
|
+
if (resolved) return parseMembershipSource(resolved, sourceFile, resolveCtx)
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
throw new Error(
|
|
2336
|
+
`\`${ownerText(typeNode, sourceFile)}\` is not a valid membership source. Use ` +
|
|
2337
|
+
`Rows<"table", "column", Where>, Claim<"path"> or Values<[…]>.`,
|
|
2338
|
+
)
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
/** Interval units, matching `TimeUnit` in `@supatype/types` and the engine. */
|
|
2342
|
+
const TIME_UNITS = [
|
|
2343
|
+
"seconds",
|
|
2344
|
+
"minutes",
|
|
2345
|
+
"hours",
|
|
2346
|
+
"days",
|
|
2347
|
+
"weeks",
|
|
2348
|
+
"months",
|
|
2349
|
+
"years",
|
|
2350
|
+
] as const
|
|
2351
|
+
|
|
2352
|
+
/** Granularities for `StartOf<>`. */
|
|
2353
|
+
const TRUNC_UNITS = ["day", "week", "month", "year"] as const
|
|
2354
|
+
|
|
2355
|
+
/**
|
|
2356
|
+
* `Ago<30, "days">` / `FromNow<7, "days">`.
|
|
2357
|
+
*
|
|
2358
|
+
* The amount and unit are validated here and the interval is re-assembled from the
|
|
2359
|
+
* parsed integer and the matched keyword, so no author-supplied text reaches
|
|
2360
|
+
* Postgres. That is what keeps this from being a small escape hatch: a permissive
|
|
2361
|
+
* `"30 days"` string would be raw SQL by another name.
|
|
2362
|
+
*/
|
|
2363
|
+
function parseDurationOperand(
|
|
2364
|
+
ref: "Ago" | "FromNow",
|
|
2365
|
+
args: readonly ts.TypeNode[],
|
|
2366
|
+
sourceFile: ts.SourceFile,
|
|
2367
|
+
): Record<string, unknown> {
|
|
2368
|
+
if (args.length !== 2) {
|
|
2369
|
+
throw new Error(`\`${ref}<>\` takes an amount and a unit, as in \`${ref}<30, "days">\`.`)
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
const amountNode = args[0]!
|
|
2373
|
+
const opposite = ref === "Ago" ? "FromNow" : "Ago"
|
|
2374
|
+
|
|
2375
|
+
// A negative literal is a prefix-unary expression in the TypeScript AST, not a
|
|
2376
|
+
// numeric literal, so it has to be recognised separately — otherwise `Ago<-5, …>`
|
|
2377
|
+
// is refused for the wrong reason and the message misses the real advice.
|
|
2378
|
+
if (
|
|
2379
|
+
ts.isLiteralTypeNode(amountNode) &&
|
|
2380
|
+
ts.isPrefixUnaryExpression(amountNode.literal) &&
|
|
2381
|
+
amountNode.literal.operator === ts.SyntaxKind.MinusToken
|
|
2382
|
+
) {
|
|
2383
|
+
throw new Error(
|
|
2384
|
+
`\`${ref}<>\` does not take a negative amount. For the other direction use ` +
|
|
2385
|
+
`\`${opposite}<>\`, which reads correctly instead of relying on a double negative.`,
|
|
2386
|
+
)
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
if (!ts.isLiteralTypeNode(amountNode) || !ts.isNumericLiteral(amountNode.literal)) {
|
|
2390
|
+
throw new Error(`\`${ref}<>\` needs a number literal amount, as in \`${ref}<30, "days">\`.`)
|
|
2391
|
+
}
|
|
2392
|
+
const amount = Number(amountNode.literal.text)
|
|
2393
|
+
if (!Number.isInteger(amount)) {
|
|
2394
|
+
throw new Error(
|
|
2395
|
+
`\`${ref}<${amount}, …>\` must be a whole number of units — Postgres intervals ` +
|
|
2396
|
+
`take integers, so a fraction would be silently truncated or rejected.`,
|
|
2397
|
+
)
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
const unit = stringLiteralArg(args[1], ref, "unit")
|
|
2401
|
+
if (!TIME_UNITS.includes(unit as (typeof TIME_UNITS)[number])) {
|
|
2402
|
+
throw new Error(
|
|
2403
|
+
`\`${ref}<${amount}, "${unit}">\` is not a valid unit. Use one of: ` +
|
|
2404
|
+
`${TIME_UNITS.join(", ")} (plural).`,
|
|
2405
|
+
)
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
void sourceFile
|
|
2409
|
+
return { kind: ref === "Ago" ? "ago" : "fromNow", amount, unit }
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/** A string, number or boolean literal as its plain JS value. */
|
|
2413
|
+
function literalFromNode(
|
|
2414
|
+
node: ts.TypeNode,
|
|
2415
|
+
sourceFile: ts.SourceFile,
|
|
2416
|
+
): string | number | boolean {
|
|
2417
|
+
if (ts.isLiteralTypeNode(node)) {
|
|
2418
|
+
const literal = node.literal
|
|
2419
|
+
if (ts.isStringLiteral(literal)) return literal.text
|
|
2420
|
+
if (ts.isNumericLiteral(literal)) return Number(literal.text)
|
|
2421
|
+
if (literal.kind === ts.SyntaxKind.TrueKeyword) return true
|
|
2422
|
+
if (literal.kind === ts.SyntaxKind.FalseKeyword) return false
|
|
2423
|
+
}
|
|
2424
|
+
throw new Error(
|
|
2425
|
+
`\`${ownerText(node, sourceFile)}\` is not a string, number or boolean literal.`,
|
|
2426
|
+
)
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function stringLiteralArg(
|
|
2430
|
+
node: ts.TypeNode | undefined,
|
|
2431
|
+
owner: string,
|
|
2432
|
+
what: string,
|
|
2433
|
+
): string {
|
|
2434
|
+
if (!node || !ts.isLiteralTypeNode(node) || !ts.isStringLiteral(node.literal)) {
|
|
2435
|
+
throw new Error(`\`${owner}<>\` needs a ${what} as a string literal.`)
|
|
2436
|
+
}
|
|
2437
|
+
const value = node.literal.text.trim()
|
|
2438
|
+
// Rendered as a SQL identifier, so refuse anything that is not one.
|
|
2439
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
2440
|
+
throw new Error(`"${value}" is not a valid ${what}.`)
|
|
2441
|
+
}
|
|
2442
|
+
return value
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* One side of a comparison.
|
|
2447
|
+
*
|
|
2448
|
+
* A bare string literal is a **column** on the model the rule is attached to.
|
|
2449
|
+
* That is the common case by a wide margin, and making it the default keeps
|
|
2450
|
+
* `Eq<"author_id", AuthUid>` readable; a constant is written `Literal<"x">` so the
|
|
2451
|
+
* two can never be confused. Numbers and booleans are unambiguous, so they are
|
|
2452
|
+
* taken as constants directly.
|
|
2453
|
+
*/
|
|
2454
|
+
function parseAccessOperand(
|
|
2455
|
+
typeNode: ts.TypeNode,
|
|
2456
|
+
sourceFile: ts.SourceFile,
|
|
2457
|
+
): Record<string, unknown> {
|
|
2458
|
+
if (ts.isLiteralTypeNode(typeNode)) {
|
|
2459
|
+
const literal = typeNode.literal
|
|
2460
|
+
if (ts.isStringLiteral(literal)) {
|
|
2461
|
+
const name = literal.text.trim()
|
|
2462
|
+
if (name === "") {
|
|
2463
|
+
throw new Error("An empty column name is not a valid operand.")
|
|
2464
|
+
}
|
|
2465
|
+
// Rendered into SQL as an identifier, so refuse anything that is not one
|
|
2466
|
+
// rather than letting it through to the policy.
|
|
2467
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
2468
|
+
throw new Error(
|
|
2469
|
+
`"${name}" is not a valid column name. Use \`Literal<"${name}">\` for a ` +
|
|
2470
|
+
`constant, or quote a real column.`,
|
|
2471
|
+
)
|
|
2472
|
+
}
|
|
2473
|
+
return { kind: "column", name }
|
|
2474
|
+
}
|
|
2475
|
+
if (ts.isNumericLiteral(literal)) {
|
|
2476
|
+
return { kind: "literal", value: Number(literal.text) }
|
|
2477
|
+
}
|
|
2478
|
+
if (literal.kind === ts.SyntaxKind.TrueKeyword) return { kind: "literal", value: true }
|
|
2479
|
+
if (literal.kind === ts.SyntaxKind.FalseKeyword) return { kind: "literal", value: false }
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
if (ts.isTypeReferenceNode(typeNode) && !ts.isQualifiedName(typeNode.typeName)) {
|
|
2483
|
+
const ref = ownerText(typeNode.typeName, sourceFile)
|
|
2484
|
+
const operandArgs = typeNode.typeArguments ?? []
|
|
2485
|
+
switch (ref) {
|
|
2486
|
+
case "AuthUid":
|
|
2487
|
+
return { kind: "authUid" }
|
|
2488
|
+
case "AuthRole":
|
|
2489
|
+
return { kind: "authRole" }
|
|
2490
|
+
case "Now":
|
|
2491
|
+
return { kind: "now" }
|
|
2492
|
+
case "StartOf": {
|
|
2493
|
+
const unit = stringLiteralArg(operandArgs[0], "StartOf", "unit")
|
|
2494
|
+
if (!TRUNC_UNITS.includes(unit as (typeof TRUNC_UNITS)[number])) {
|
|
2495
|
+
throw new Error(
|
|
2496
|
+
`\`StartOf<"${unit}">\` is not a valid granularity. Use one of: ` +
|
|
2497
|
+
`${TRUNC_UNITS.join(", ")}.`,
|
|
2498
|
+
)
|
|
2499
|
+
}
|
|
2500
|
+
return { kind: "startOf", unit }
|
|
2501
|
+
}
|
|
2502
|
+
case "Ago":
|
|
2503
|
+
case "FromNow":
|
|
2504
|
+
return parseDurationOperand(ref, operandArgs, sourceFile)
|
|
2505
|
+
case "Claim": {
|
|
2506
|
+
const pathArg = typeNode.typeArguments?.[0]
|
|
2507
|
+
if (!pathArg || !ts.isLiteralTypeNode(pathArg) || !ts.isStringLiteral(pathArg.literal)) {
|
|
2508
|
+
throw new Error('`Claim<>` takes a dotted path literal, as in `Claim<"app_metadata.tier">`.')
|
|
2509
|
+
}
|
|
2510
|
+
const path = pathArg.literal.text.trim()
|
|
2511
|
+
// The path is split on dots to walk the claims object, so an empty
|
|
2512
|
+
// segment would silently look up a key that cannot exist.
|
|
2513
|
+
if (path === "" || path.split(".").some((segment) => segment.trim() === "")) {
|
|
2514
|
+
throw new Error(
|
|
2515
|
+
`\`Claim<"${path}">\` is not a valid claim path — use dotted segments, ` +
|
|
2516
|
+
`as in \`Claim<"app_metadata.tier">\`.`,
|
|
2517
|
+
)
|
|
2518
|
+
}
|
|
2519
|
+
return { kind: "claim", path }
|
|
2520
|
+
}
|
|
2521
|
+
case "Literal": {
|
|
2522
|
+
const valueArg = typeNode.typeArguments?.[0]
|
|
2523
|
+
if (!valueArg || !ts.isLiteralTypeNode(valueArg)) {
|
|
2524
|
+
throw new Error('`Literal<>` takes a string, number or boolean, as in `Literal<"published">`.')
|
|
2525
|
+
}
|
|
2526
|
+
const literal = valueArg.literal
|
|
2527
|
+
if (ts.isStringLiteral(literal)) return { kind: "literal", value: literal.text }
|
|
2528
|
+
if (ts.isNumericLiteral(literal)) return { kind: "literal", value: Number(literal.text) }
|
|
2529
|
+
if (literal.kind === ts.SyntaxKind.TrueKeyword) return { kind: "literal", value: true }
|
|
2530
|
+
if (literal.kind === ts.SyntaxKind.FalseKeyword) return { kind: "literal", value: false }
|
|
2531
|
+
throw new Error('`Literal<>` takes a string, number or boolean.')
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
throw new Error(
|
|
2537
|
+
`\`${ownerText(typeNode, sourceFile)}\` is not a valid operand. Use a column name ` +
|
|
2538
|
+
`("author_id"), AuthUid, AuthRole, Claim<"path"> or Literal<value>.`,
|
|
2539
|
+
)
|
|
1705
2540
|
}
|
|
1706
2541
|
|
|
1707
2542
|
interface ParsedRelationOptions {
|
|
@@ -1790,3 +2625,23 @@ function relationForeignKeyFromField(fieldName: string): string {
|
|
|
1790
2625
|
const base = snake.replace(/_id$/i, "")
|
|
1791
2626
|
return `${base}_id`
|
|
1792
2627
|
}
|
|
2628
|
+
|
|
2629
|
+
/** Resolve `Rules["read"]` to the rule type declared for that member. */
|
|
2630
|
+
function resolveIndexedAccessRule(
|
|
2631
|
+
node: ts.IndexedAccessTypeNode,
|
|
2632
|
+
sourceFile: ts.SourceFile,
|
|
2633
|
+
resolveCtx: ResolveContext,
|
|
2634
|
+
): ts.TypeNode | undefined {
|
|
2635
|
+
const literal = resolveAccessLiteral(node.objectType, sourceFile, resolveCtx)
|
|
2636
|
+
if (!literal) return undefined
|
|
2637
|
+
if (!ts.isLiteralTypeNode(node.indexType) || !ts.isStringLiteral(node.indexType.literal)) {
|
|
2638
|
+
return undefined
|
|
2639
|
+
}
|
|
2640
|
+
const key = node.indexType.literal.text
|
|
2641
|
+
for (const member of literal.members) {
|
|
2642
|
+
if (ts.isPropertySignature(member) && getPropertyName(member.name) === key) {
|
|
2643
|
+
return member.type
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
return undefined
|
|
2647
|
+
}
|