@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.
Files changed (247) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +143 -93
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/deno.d.ts +52 -0
  5. package/dist/augmentation-generator.d.ts +22 -0
  6. package/dist/augmentation-generator.d.ts.map +1 -1
  7. package/dist/augmentation-generator.js +43 -7
  8. package/dist/augmentation-generator.js.map +1 -1
  9. package/dist/binary-cache.d.ts +14 -0
  10. package/dist/binary-cache.d.ts.map +1 -1
  11. package/dist/binary-cache.js +33 -13
  12. package/dist/binary-cache.js.map +1 -1
  13. package/dist/cache-clean.d.ts +15 -0
  14. package/dist/cache-clean.d.ts.map +1 -0
  15. package/dist/cache-clean.js +76 -0
  16. package/dist/cache-clean.js.map +1 -0
  17. package/dist/cache-pins.d.ts +51 -0
  18. package/dist/cache-pins.d.ts.map +1 -0
  19. package/dist/cache-pins.js +184 -0
  20. package/dist/cache-pins.js.map +1 -0
  21. package/dist/cli-install-method.d.ts +19 -0
  22. package/dist/cli-install-method.d.ts.map +1 -0
  23. package/dist/cli-install-method.js +60 -0
  24. package/dist/cli-install-method.js.map +1 -0
  25. package/dist/cli.d.ts.map +1 -1
  26. package/dist/cli.js +6 -0
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloud-credentials.d.ts +34 -0
  29. package/dist/cloud-credentials.d.ts.map +1 -0
  30. package/dist/cloud-credentials.js +139 -0
  31. package/dist/cloud-credentials.js.map +1 -0
  32. package/dist/commands/admin.d.ts +11 -0
  33. package/dist/commands/admin.d.ts.map +1 -1
  34. package/dist/commands/admin.js +131 -22
  35. package/dist/commands/admin.js.map +1 -1
  36. package/dist/commands/cache.d.ts.map +1 -1
  37. package/dist/commands/cache.js +67 -71
  38. package/dist/commands/cache.js.map +1 -1
  39. package/dist/commands/cloud.d.ts.map +1 -1
  40. package/dist/commands/cloud.js +32 -8
  41. package/dist/commands/cloud.js.map +1 -1
  42. package/dist/commands/db.d.ts +1 -0
  43. package/dist/commands/db.d.ts.map +1 -1
  44. package/dist/commands/db.js +147 -1
  45. package/dist/commands/db.js.map +1 -1
  46. package/dist/commands/dev.d.ts.map +1 -1
  47. package/dist/commands/dev.js +50 -10
  48. package/dist/commands/dev.js.map +1 -1
  49. package/dist/commands/doctor.d.ts +28 -0
  50. package/dist/commands/doctor.d.ts.map +1 -1
  51. package/dist/commands/doctor.js +60 -3
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/functions.d.ts +7 -0
  54. package/dist/commands/functions.d.ts.map +1 -1
  55. package/dist/commands/functions.js +40 -6
  56. package/dist/commands/functions.js.map +1 -1
  57. package/dist/commands/generate.d.ts.map +1 -1
  58. package/dist/commands/generate.js +5 -1
  59. package/dist/commands/generate.js.map +1 -1
  60. package/dist/commands/hooks.d.ts +12 -0
  61. package/dist/commands/hooks.d.ts.map +1 -0
  62. package/dist/commands/hooks.js +118 -0
  63. package/dist/commands/hooks.js.map +1 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +41 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/commands/internal.d.ts +6 -0
  68. package/dist/commands/internal.d.ts.map +1 -0
  69. package/dist/commands/internal.js +22 -0
  70. package/dist/commands/internal.js.map +1 -0
  71. package/dist/commands/keys.d.ts.map +1 -1
  72. package/dist/commands/keys.js +2 -0
  73. package/dist/commands/keys.js.map +1 -1
  74. package/dist/commands/link-helpers.d.ts.map +1 -1
  75. package/dist/commands/link-helpers.js +8 -2
  76. package/dist/commands/link-helpers.js.map +1 -1
  77. package/dist/commands/login.d.ts +7 -0
  78. package/dist/commands/login.d.ts.map +1 -0
  79. package/dist/commands/login.js +110 -0
  80. package/dist/commands/login.js.map +1 -0
  81. package/dist/commands/push.d.ts.map +1 -1
  82. package/dist/commands/push.js +110 -7
  83. package/dist/commands/push.js.map +1 -1
  84. package/dist/commands/self-update.d.ts +7 -0
  85. package/dist/commands/self-update.d.ts.map +1 -1
  86. package/dist/commands/self-update.js +18 -11
  87. package/dist/commands/self-update.js.map +1 -1
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +18 -3
  90. package/dist/config.js.map +1 -1
  91. package/dist/db-preflight.d.ts +78 -0
  92. package/dist/db-preflight.d.ts.map +1 -0
  93. package/dist/db-preflight.js +452 -0
  94. package/dist/db-preflight.js.map +1 -0
  95. package/dist/dev-compose.d.ts +1 -1
  96. package/dist/dev-compose.d.ts.map +1 -1
  97. package/dist/dev-compose.js +129 -72
  98. package/dist/dev-compose.js.map +1 -1
  99. package/dist/env-file.d.ts +40 -2
  100. package/dist/env-file.d.ts.map +1 -1
  101. package/dist/env-file.js +132 -11
  102. package/dist/env-file.js.map +1 -1
  103. package/dist/field-masking-tier.d.ts +50 -0
  104. package/dist/field-masking-tier.d.ts.map +1 -0
  105. package/dist/field-masking-tier.js +91 -0
  106. package/dist/field-masking-tier.js.map +1 -0
  107. package/dist/functions-deno-types.d.ts +24 -0
  108. package/dist/functions-deno-types.d.ts.map +1 -0
  109. package/dist/functions-deno-types.js +155 -0
  110. package/dist/functions-deno-types.js.map +1 -0
  111. package/dist/hook-upload.d.ts +30 -0
  112. package/dist/hook-upload.d.ts.map +1 -0
  113. package/dist/hook-upload.js +86 -0
  114. package/dist/hook-upload.js.map +1 -0
  115. package/dist/hooks-generator.d.ts +8 -0
  116. package/dist/hooks-generator.d.ts.map +1 -0
  117. package/dist/hooks-generator.js +332 -0
  118. package/dist/hooks-generator.js.map +1 -0
  119. package/dist/link.d.ts +4 -0
  120. package/dist/link.d.ts.map +1 -1
  121. package/dist/link.js +2 -0
  122. package/dist/link.js.map +1 -1
  123. package/dist/local-secrets.d.ts +71 -0
  124. package/dist/local-secrets.d.ts.map +1 -0
  125. package/dist/local-secrets.js +100 -0
  126. package/dist/local-secrets.js.map +1 -0
  127. package/dist/model-hooks.d.ts +73 -0
  128. package/dist/model-hooks.d.ts.map +1 -0
  129. package/dist/model-hooks.js +211 -0
  130. package/dist/model-hooks.js.map +1 -0
  131. package/dist/postgres-ctl.d.ts +11 -0
  132. package/dist/postgres-ctl.d.ts.map +1 -1
  133. package/dist/postgres-ctl.js +25 -2
  134. package/dist/postgres-ctl.js.map +1 -1
  135. package/dist/project-config.d.ts +121 -1
  136. package/dist/project-config.d.ts.map +1 -1
  137. package/dist/project-config.js +150 -1
  138. package/dist/project-config.js.map +1 -1
  139. package/dist/resolve-target.d.ts +4 -0
  140. package/dist/resolve-target.d.ts.map +1 -1
  141. package/dist/resolve-target.js +69 -73
  142. package/dist/resolve-target.js.map +1 -1
  143. package/dist/schema-ast-v2.d.ts +2 -1
  144. package/dist/schema-ast-v2.d.ts.map +1 -1
  145. package/dist/schema-ast-v2.js +4 -2
  146. package/dist/schema-ast-v2.js.map +1 -1
  147. package/dist/schema-push-lock.d.ts.map +1 -1
  148. package/dist/schema-push-lock.js +43 -16
  149. package/dist/schema-push-lock.js.map +1 -1
  150. package/dist/self-host-compose.d.ts +24 -0
  151. package/dist/self-host-compose.d.ts.map +1 -1
  152. package/dist/self-host-compose.js +301 -69
  153. package/dist/self-host-compose.js.map +1 -1
  154. package/dist/service-role-check.d.ts +21 -0
  155. package/dist/service-role-check.d.ts.map +1 -0
  156. package/dist/service-role-check.js +85 -0
  157. package/dist/service-role-check.js.map +1 -0
  158. package/dist/target-client.d.ts +17 -0
  159. package/dist/target-client.d.ts.map +1 -1
  160. package/dist/target-client.js +69 -17
  161. package/dist/target-client.js.map +1 -1
  162. package/dist/type-extractor.d.ts.map +1 -1
  163. package/dist/type-extractor.js +736 -32
  164. package/dist/type-extractor.js.map +1 -1
  165. package/dist/ui/runtime/command-chrome.js +1 -1
  166. package/dist/ui/runtime/command-chrome.js.map +1 -1
  167. package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
  168. package/dist/ui/runtime/stdin-after-ink.js +3 -1
  169. package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
  170. package/package.json +4 -1
  171. package/scripts/generate-homebrew-formula.mjs +121 -0
  172. package/src/augmentation-generator.ts +46 -9
  173. package/src/binary-cache.ts +47 -15
  174. package/src/cache-clean.ts +81 -0
  175. package/src/cache-pins.ts +243 -0
  176. package/src/cli-install-method.ts +67 -0
  177. package/src/cli.ts +6 -0
  178. package/src/cloud-credentials.ts +183 -0
  179. package/src/commands/admin.ts +153 -28
  180. package/src/commands/cache.ts +90 -75
  181. package/src/commands/cloud.ts +32 -8
  182. package/src/commands/db.ts +162 -1
  183. package/src/commands/dev.ts +57 -8
  184. package/src/commands/doctor.ts +69 -3
  185. package/src/commands/functions.ts +42 -5
  186. package/src/commands/generate.ts +5 -1
  187. package/src/commands/hooks.ts +138 -0
  188. package/src/commands/init.ts +42 -8
  189. package/src/commands/internal.ts +23 -0
  190. package/src/commands/keys.ts +2 -0
  191. package/src/commands/link-helpers.ts +9 -2
  192. package/src/commands/login.ts +127 -0
  193. package/src/commands/push.ts +124 -7
  194. package/src/commands/self-update.ts +85 -13
  195. package/src/config.ts +22 -3
  196. package/src/db-preflight.ts +575 -0
  197. package/src/dev-compose.ts +144 -73
  198. package/src/env-file.ts +157 -11
  199. package/src/field-masking-tier.ts +107 -0
  200. package/src/functions-deno-types.ts +188 -0
  201. package/src/hook-upload.ts +107 -0
  202. package/src/hooks-generator.ts +352 -0
  203. package/src/link.ts +7 -0
  204. package/src/local-secrets.ts +104 -0
  205. package/src/model-hooks.ts +260 -0
  206. package/src/postgres-ctl.ts +29 -2
  207. package/src/project-config.ts +236 -2
  208. package/src/resolve-target.ts +113 -68
  209. package/src/schema-ast-v2.ts +5 -2
  210. package/src/schema-push-lock.ts +56 -17
  211. package/src/self-host-compose.ts +344 -68
  212. package/src/service-role-check.ts +111 -0
  213. package/src/target-client.ts +105 -18
  214. package/src/type-extractor.ts +883 -28
  215. package/src/ui/runtime/command-chrome.ts +1 -1
  216. package/src/ui/runtime/stdin-after-ink.ts +3 -1
  217. package/tests/access-composition.test.ts +667 -0
  218. package/tests/admin-studio-membership.test.ts +121 -0
  219. package/tests/api-schemas.test.ts +83 -0
  220. package/tests/archive-name-alias.test.ts +42 -0
  221. package/tests/augmentation-generator.test.ts +72 -4
  222. package/tests/cache-clean.test.ts +206 -0
  223. package/tests/cli-help.test.ts +3 -0
  224. package/tests/cli-install-method.test.ts +66 -0
  225. package/tests/command-chrome-coverage.test.ts +31 -26
  226. package/tests/command-chrome-wrap.test.ts +1 -0
  227. package/tests/db-preflight.test.ts +72 -0
  228. package/tests/doctor-render.test.ts +62 -0
  229. package/tests/doctor-service-role.test.ts +55 -0
  230. package/tests/env-file.test.ts +121 -0
  231. package/tests/external-database-compose.test.ts +184 -0
  232. package/tests/external-database-config.test.ts +154 -0
  233. package/tests/field-masking-tier.test.ts +184 -0
  234. package/tests/functions-deno-types.test.ts +197 -0
  235. package/tests/homebrew-formula.test.ts +58 -0
  236. package/tests/hook-upload.test.ts +125 -0
  237. package/tests/hooks-generator.test.ts +212 -0
  238. package/tests/init.test.ts +3 -0
  239. package/tests/local-secrets.test.ts +92 -0
  240. package/tests/model-hooks.test.ts +322 -0
  241. package/tests/runtime-contract.test.ts +113 -1
  242. package/tests/service-role-check.test.ts +109 -0
  243. package/tests/service-role-routes.test.ts +51 -0
  244. package/tests/target-client-refresh.test.ts +94 -0
  245. package/tests/type-extractor.test.ts +187 -0
  246. package/tsconfig.tsbuildinfo +1 -1
  247. package/tests/engine-distribution.test.ts +0 -418
@@ -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
- return scalar("decimal");
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", { db: { pgType: "JSONB" } });
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
- if (!ts.isTypeLiteralNode(typeNode))
976
- return undefined;
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 typeNode.members) {
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
- function parseModelAccess(metaArg, sourceFile) {
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 || !ts.isTypeLiteralNode(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 accessProp.type.members) {
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
- access[key] = parseAccessRule(member.type, sourceFile);
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
- function parseAccessRule(typeNode, sourceFile) {
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
- const ref = typeNode.typeName.getText(sourceFile);
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 { type: "owner", field: keyArg?.getText(sourceFile).replace(/['"]/g, "") ?? "user_id" };
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?.getText(sourceFile).replace(/['"]/g, "") ?? "owner";
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 { type: "role", roles: [roleArg?.getText(sourceFile).replace(/['"]/g, "") ?? "admin"] };
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