@rebasepro/common 0.13.0 → 0.13.1-canary.g06dbe5b

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 (41) hide show
  1. package/dist/data/buildRebaseData.d.ts +10 -1
  2. package/dist/data/filter-conditions.d.ts +34 -0
  3. package/dist/data/filter-dialect.d.ts +19 -3
  4. package/dist/data/paginate.d.ts +20 -0
  5. package/dist/data/query_builder.d.ts +16 -4
  6. package/dist/data/resolveDataSource.d.ts +36 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.es.js +622 -92
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/util/builders.d.ts +2 -2
  11. package/dist/util/collections.d.ts +17 -0
  12. package/dist/util/conditions.d.ts +7 -3
  13. package/dist/util/entities.d.ts +8 -1
  14. package/dist/util/index.d.ts +1 -0
  15. package/dist/util/internal-tables.d.ts +95 -0
  16. package/dist/util/permissions.d.ts +30 -0
  17. package/dist/util/policy/sqlToPolicy.d.ts +4 -4
  18. package/dist/util/relations.d.ts +18 -1
  19. package/dist/util/resolutions.d.ts +31 -0
  20. package/package.json +4 -3
  21. package/src/data/buildRebaseData.ts +161 -27
  22. package/src/data/filter-conditions.ts +46 -0
  23. package/src/data/filter-dialect.ts +110 -37
  24. package/src/data/paginate.ts +30 -0
  25. package/src/data/query_builder.ts +26 -4
  26. package/src/data/resolveDataSource.ts +56 -0
  27. package/src/index.ts +1 -0
  28. package/src/util/auth-default-policies.ts +8 -2
  29. package/src/util/builders.ts +3 -3
  30. package/src/util/collections.ts +17 -1
  31. package/src/util/conditions.ts +8 -3
  32. package/src/util/entities.ts +15 -1
  33. package/src/util/index.ts +1 -0
  34. package/src/util/internal-tables.ts +154 -0
  35. package/src/util/permissions.test.ts +23 -2
  36. package/src/util/permissions.ts +43 -6
  37. package/src/util/policy/evaluatePolicy.ts +24 -2
  38. package/src/util/policy/policyToPostgres.ts +23 -10
  39. package/src/util/policy/sqlToPolicy.ts +127 -26
  40. package/src/util/relations.ts +31 -0
  41. package/src/util/resolutions.ts +77 -5
package/dist/index.es.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, CANONICAL_TO_REST, DEFAULT_DATA_SOURCE_KEY, EntityReference, EntityRelation, NULL_OPS, REST_TO_CANONICAL, getDataSourceCapabilities, getDeclaredSubcollections, isAnonymousUid, isManyToMany, isPostgresCollectionConfig, isRelationalCollectionConfig, policy, toCanonicalOp } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, CANONICAL_TO_REST, DEFAULT_DATA_SOURCE_KEY, DEFAULT_LIST_LIMIT, EntityReference, EntityRelation, NULL_OPS, REST_TO_CANONICAL, RLS_ROLES_SQL, RLS_UID_SQL, getDataSourceCapabilities, getDeclaredSubcollections, isAnonymousUid, isManyToMany, isPostgresCollectionConfig, isRelationalCollectionConfig, policy, rewriteLegacyRlsFunctions, toCanonicalOp } from "@rebasepro/types";
2
2
  import { deepClone, generateForeignKeyName, getIn, getPolicyNamesForRules, getPolicyOperations, isDefaultFieldConfigId, mergeDeep, prettifyIdentifier, randomString, removeFunctions, toSnakeCase } from "@rebasepro/utils";
3
3
  import jsonLogic from "json-logic-js";
4
4
  import { deepEqual } from "fast-equals";
@@ -89,10 +89,21 @@ function getRelationFrom(entity) {
89
89
  * have `id` and `path` fields — these are relation-shaped objects from
90
90
  * edge cases in the data pipeline (REST fallback, stale cache, custom data source).
91
91
  *
92
+ * When `targetPath` is given, also accepts a bare id. A relation column is a
93
+ * foreign key, and the REST layer returns it as the scalar it is; only some
94
+ * fetch paths hydrate it into an object. Which form a caller sees therefore
95
+ * depends on how the row was loaded, and a caller that only accepted objects
96
+ * reported half of its own data as a type error. The declared target is the
97
+ * missing half: with it, an id is a relation that has not been fetched yet.
98
+ *
92
99
  * Returns null if the value cannot be coerced.
93
100
  */
94
- function normalizeToEntityRelation(value, propertyType) {
101
+ function normalizeToEntityRelation(value, propertyType, targetPath) {
95
102
  if (value instanceof EntityRelation) return value;
103
+ if (targetPath && (typeof value === "string" || typeof value === "number")) {
104
+ if (value === "") return null;
105
+ return new EntityRelation(value, targetPath);
106
+ }
96
107
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
97
108
  const obj = value;
98
109
  if (!(obj.__type === "relation" || obj.__type === "reference" || typeof obj.isEntityRelation === "function" && obj.isEntityRelation() || typeof obj.isEntityReference === "function" && obj.isEntityReference() || propertyType === "relation" && typeof obj.id !== "undefined" && typeof obj.path === "string")) return null;
@@ -215,6 +226,23 @@ function sortProperties(properties, propertiesOrder) {
215
226
  return properties;
216
227
  }
217
228
  }
229
+ /**
230
+ * A copy of `collections` ordered by slug.
231
+ *
232
+ * Every generator that turns collections into a file is order-dependent, and
233
+ * every one of them is compared against its own output — `rebase doctor`
234
+ * regenerates in memory and diffs, `generate-sdk && git diff --exit-code` gates
235
+ * CI. While only the *writers* sorted, a project whose `readdirSync` order
236
+ * differed from its slug order was reported permanently out of date, and the
237
+ * fix the message printed rewrote the file in the order it was already in. The
238
+ * generators sort themselves now, so no caller can get this wrong.
239
+ *
240
+ * A slug-less collection is left to the generator's own validation, which names
241
+ * the offending collection; sorting must not throw first.
242
+ */
243
+ function sortCollectionsBySlug(collections) {
244
+ return [...collections].sort((a, b) => (a.slug ?? "").localeCompare(b.slug ?? ""));
245
+ }
218
246
  function getPrimaryKeys(collection) {
219
247
  const properties = collection.properties;
220
248
  if (!properties) return ["id"];
@@ -608,6 +636,33 @@ function resolveCollectionRelations(collection) {
608
636
  _resolvedRelationsCache.set(collection, relations);
609
637
  return relations;
610
638
  }
639
+ /**
640
+ * The path of the collection a relation property points at, derived from the
641
+ * property alone.
642
+ *
643
+ * A preview holds a property and a value and no collection, so it cannot call
644
+ * `resolveRelationProperty`. It does not need to: both forms that carry a
645
+ * target — the stamped `resolvedRelation` and the inline `relation` — name it
646
+ * directly. Only the third form, a relation declared by name in the
647
+ * collection's `relations` array, is out of reach, and that one has no target
648
+ * to read without the collection anyway.
649
+ *
650
+ * This is what lets a preview render a relation column that arrived as a bare
651
+ * foreign key: the id says *which* row, the declared target says *which
652
+ * collection*, and `RelationPreview` fetches the rest. Without it a scalar id
653
+ * is indistinguishable from a value of the wrong type.
654
+ */
655
+ function getRelationTargetPath(property) {
656
+ const stamped = property.resolvedRelation?.targetSlug;
657
+ if (stamped) return stamped;
658
+ const target = property.relation?.target;
659
+ if (typeof target !== "function") return void 0;
660
+ try {
661
+ return target()?.slug;
662
+ } catch (_e) {
663
+ return;
664
+ }
665
+ }
611
666
  function getTableName(collection) {
612
667
  if (isRelationalCollectionConfig(collection)) return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
613
668
  return toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
@@ -882,6 +937,59 @@ function getEntityChildViews(collection) {
882
937
  return views;
883
938
  }
884
939
  /**
940
+ * Each of `collection`'s tabs paired with the property that declared it, when a
941
+ * property declared it: child view key → property key.
942
+ *
943
+ * A many-relation can only be declared as a property — that is the documented
944
+ * and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So
945
+ * one declaration reaches the panel twice, and neither surface knew about the
946
+ * other. The form rendered a relation picker beside the tab, and the collection
947
+ * table rendered *two* columns under one heading: the relation's own column,
948
+ * showing the child rows, and a jump-to-tab button carrying the same name.
949
+ *
950
+ * The pairing is what lets each surface decide which half is redundant, and it
951
+ * has to be a pairing rather than two sets because the two keys differ whenever
952
+ * a relation is named. The match is on the resolved `relationName` — the
953
+ * identity `getEntityChildViews` itself dedupes on — so a relation declared in
954
+ * `relations` and pointed at by a differently-named property is recognised too.
955
+ *
956
+ * A relation with no property of its own is absent here, which is the point: it
957
+ * has exactly one surface already, and nothing to weigh it against.
958
+ *
959
+ * Only top-level properties: a relation nested inside a `map` gets no tab.
960
+ */
961
+ function getChildViewDeclaringProperties(collection) {
962
+ const pairs = /* @__PURE__ */ new Map();
963
+ const relationProperties = Object.entries(collection.properties ?? {}).filter(([, property]) => property?.type === "relation");
964
+ if (relationProperties.length === 0) return pairs;
965
+ const relationViews = getEntityChildViews(collection).filter((view) => view.source.kind === "relation");
966
+ if (relationViews.length === 0) return pairs;
967
+ const resolvedRelations = resolveCollectionRelations(collection);
968
+ const identityOf = (relationKey) => resolvedRelations[relationKey]?.relationName ?? relationKey;
969
+ const declaringPropertyByIdentity = /* @__PURE__ */ new Map();
970
+ for (const [propertyKey, property] of relationProperties) {
971
+ const relation = property.resolvedRelation ?? resolvedRelations[propertyKey];
972
+ if (relation?.cardinality !== "many") continue;
973
+ const identity = relation.relationName ?? propertyKey;
974
+ if (!declaringPropertyByIdentity.has(identity)) declaringPropertyByIdentity.set(identity, propertyKey);
975
+ }
976
+ for (const view of relationViews) {
977
+ const propertyKey = declaringPropertyByIdentity.get(identityOf(view.source.relationKey));
978
+ if (propertyKey) pairs.set(view.key, propertyKey);
979
+ }
980
+ return pairs;
981
+ }
982
+ /**
983
+ * The property keys of `collection` whose relation is already one of its tabs.
984
+ *
985
+ * What a form asks: the tab is the treatment for a list of child rows, so the
986
+ * picker beside it is the redundant half. See
987
+ * {@link getChildViewDeclaringProperties}.
988
+ */
989
+ function getChildViewRelationPropertyKeys(collection) {
990
+ return new Set(getChildViewDeclaringProperties(collection).values());
991
+ }
992
+ /**
885
993
  * The child views of `collection` as bare collections.
886
994
  *
887
995
  * The flattened view of {@link getEntityChildViews}, for navigation code that
@@ -931,9 +1039,9 @@ function isKeywordAt(upper, i, keyword) {
931
1039
  *
932
1040
  * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
933
1041
  * `AND` inside
934
- * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
1042
+ * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
935
1043
  * split the expression, and re-emitting the halves produced
936
- * `(EXISTS (...) AND m.user_id = auth.uid())`
1044
+ * `(EXISTS (...) AND m.user_id = rebase.uid())`
937
1045
  * where `m` is no longer in scope — SQL that Postgres rejects outright with
938
1046
  * "missing FROM-clause entry for table". Returning null instead keeps such a
939
1047
  * clause as a `raw` expression, which round-trips verbatim.
@@ -1007,15 +1115,15 @@ function stripOuterParens(sql) {
1007
1115
  }
1008
1116
  }
1009
1117
  function sqlToPolicy(sql) {
1010
- const trimmed = stripOuterParens(sql.trim());
1118
+ const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
1011
1119
  if (trimmed.toLowerCase() === "true") return policy.true();
1012
1120
  if (trimmed.toLowerCase() === "false") return policy.false();
1013
- const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
1121
+ const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
1014
1122
  if (overlapMatch) {
1015
1123
  const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
1016
1124
  return policy.rolesOverlap(roles);
1017
1125
  }
1018
- const containMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
1126
+ const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
1019
1127
  if (containMatch) {
1020
1128
  const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
1021
1129
  return policy.rolesContain(roles);
@@ -1031,10 +1139,10 @@ function sqlToPolicy(sql) {
1031
1139
  const right = parseOperand(rightStr.trim());
1032
1140
  if (left && right) return policy.compare(left, op === "=" ? "eq" : "neq", right);
1033
1141
  }
1034
- return policy.raw(sql);
1142
+ return policy.raw(trimmed);
1035
1143
  }
1036
1144
  /**
1037
- * Literals from other BaaS platforms that people compare `auth.uid()` against
1145
+ * Literals from other BaaS platforms that people compare `rebase.uid()` against
1038
1146
  * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
1039
1147
  * `pgRoles`, one surface over: the same muscle memory inside a `using:` string
1040
1148
  * is the more dangerous spelling, because it inverts a rule instead of
@@ -1045,18 +1153,25 @@ var FOREIGN_CONVENTION_UIDS = {
1045
1153
  authenticated: "Supabase",
1046
1154
  service_role: "Supabase"
1047
1155
  };
1048
- /** `auth.uid() IS NOT NULL` in raw SQL, the clause that is always true. */
1049
- var UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
1156
+ /**
1157
+ * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
1158
+ *
1159
+ * Both schema spellings, because this runs over policy bodies read back from a
1160
+ * database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
1161
+ * A security check that stops recognising a dangerous clause because the
1162
+ * framework renamed a function is a check that silently turns off.
1163
+ */
1164
+ var UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
1050
1165
  /**
1051
1166
  * Find clauses that read as "signed-in users only" but admit anonymous callers.
1052
1167
  *
1053
- * Both spellings come from the same place — Supabase, where `auth.uid()` really
1054
- * is NULL for an anonymous request. Rebase substitutes
1168
+ * Both spellings come from the same place — Supabase, where its own `auth.uid()`
1169
+ * really is NULL for an anonymous request. Rebase substitutes
1055
1170
  * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
1056
1171
  * is how the trusted *server* context is recognised), so:
1057
1172
  *
1058
- * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
1059
- * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
1173
+ * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
1174
+ * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
1060
1175
  * other. This one is not hypothetical and was not only a foreign habit:
1061
1176
  * rebase's own request path reported `'anon'` while everything that compiled
1062
1177
  * or checked a policy used `'anonymous'`, so whichever literal an author
@@ -1088,7 +1203,7 @@ function findAnonymousGrants(expr) {
1088
1203
  if (UID_NOT_NULL.test(e.sql)) found.push({
1089
1204
  pattern: "uid-not-null",
1090
1205
  detail: e.sql,
1091
- explanation: `\`auth.uid() IS NOT NULL\` is true for every request that came from a client, including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. Use \`condition: policy.authenticated()\` to mean "signed in".`
1206
+ explanation: `\`rebase.uid() IS NOT NULL\` is true for every request that came from a client, including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. Use \`condition: policy.authenticated()\` to mean "signed in".`
1092
1207
  });
1093
1208
  return;
1094
1209
  case "compare": {
@@ -1110,12 +1225,43 @@ function findAnonymousGrants(expr) {
1110
1225
  return found;
1111
1226
  }
1112
1227
  function parseOperand(str) {
1113
- if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
1114
- const stringMatch = str.match(/^'(.+)'$/);
1115
- if (stringMatch) return policy.literal(stringMatch[1]);
1116
- if (/^\w+$/.test(str)) return policy.field(str);
1228
+ if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
1229
+ const literal = parseSingleQuoted(str);
1230
+ if (literal !== null) return policy.literal(literal);
1231
+ if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
1232
+ if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
1233
+ if (/^true$/i.test(str)) return policy.literal(true);
1234
+ if (/^false$/i.test(str)) return policy.literal(false);
1235
+ if (/^null$/i.test(str)) return policy.literal(null);
1236
+ if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
1117
1237
  return null;
1118
1238
  }
1239
+ /**
1240
+ * Decode a single-quoted SQL literal, or null when `str` is not exactly one.
1241
+ *
1242
+ * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
1243
+ * operator, not one literal whose body contains a quote, and a regex anchored
1244
+ * on the outer quotes would happily read it as the latter. Every interior quote
1245
+ * must therefore be part of a `''` pair.
1246
+ */
1247
+ function parseSingleQuoted(str) {
1248
+ if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
1249
+ const body = str.slice(1, -1);
1250
+ let out = "";
1251
+ for (let i = 0; i < body.length; i++) {
1252
+ if (body[i] !== "'") {
1253
+ out += body[i];
1254
+ continue;
1255
+ }
1256
+ if (body[i + 1] === "'") {
1257
+ out += "'";
1258
+ i++;
1259
+ continue;
1260
+ }
1261
+ return null;
1262
+ }
1263
+ return out;
1264
+ }
1119
1265
  //#endregion
1120
1266
  //#region src/util/policy/securityRuleToConditions.ts
1121
1267
  /**
@@ -1192,12 +1338,12 @@ function compile(expr, scope) {
1192
1338
  const rightSql = castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
1193
1339
  return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
1194
1340
  }
1195
- case "rolesOverlap": return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
1196
- case "rolesContain": return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
1197
- case "authenticated": return `auth.uid() IS NOT NULL AND auth.uid() NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
1198
- case "serverContext": return "auth.uid() IS NULL";
1341
+ case "rolesOverlap": return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
1342
+ case "rolesContain": return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
1343
+ case "authenticated": return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
1344
+ case "serverContext": return `${RLS_UID_SQL} IS NULL`;
1199
1345
  case "existsIn": return compileExistsIn(expr, scope);
1200
- case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
1346
+ case "raw": return rewriteLegacyRlsFunctions(expr.sql).replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
1201
1347
  }
1202
1348
  }
1203
1349
  /**
@@ -1234,8 +1380,8 @@ function operandToSql(operand, scope) {
1234
1380
  case "field": return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
1235
1381
  case "outerField": return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
1236
1382
  case "literal": return quoteLiteral(operand.value);
1237
- case "authUid": return "auth.uid()";
1238
- case "authRoles": return "string_to_array(auth.roles(), ',')";
1383
+ case "authUid": return RLS_UID_SQL;
1384
+ case "authRoles": return `string_to_array(${RLS_ROLES_SQL}, ',')`;
1239
1385
  }
1240
1386
  }
1241
1387
  /**
@@ -1340,11 +1486,7 @@ function evaluateCompare(op, left, right, ctx) {
1340
1486
  if (!l.known || !r.known) return "unknown";
1341
1487
  const a = l.value;
1342
1488
  const b = r.value;
1343
- if (a === null || b === null) {
1344
- if (op === "eq") return false;
1345
- if (op === "neq") return true;
1346
- return "unknown";
1347
- }
1489
+ if (a === null || b === null) return "unknown";
1348
1490
  if (op === "eq") return a === b;
1349
1491
  if (op === "neq") return a !== b;
1350
1492
  if (typeof a === "string" && typeof b === "string") {
@@ -1391,11 +1533,11 @@ function ruleApplies(rule, targetOperation) {
1391
1533
  * in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
1392
1534
  * INSERT/UPDATE; both must pass for UPDATE.
1393
1535
  */
1394
- function evaluateRuleForOperation(rule, ctx, targetOperation) {
1536
+ function evaluateRuleForOperation(rule, ctx, targetOperation, clauses = "both") {
1395
1537
  const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
1396
1538
  const clause = (expr) => expr === null ? false : evaluatePolicy(expr, ctx);
1397
- const needsUsing = targetOperation !== "insert";
1398
- const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
1539
+ const needsUsing = targetOperation !== "insert" && clauses !== "withCheck";
1540
+ const needsWithCheck = (targetOperation === "insert" || targetOperation === "update") && clauses !== "using";
1399
1541
  const results = [];
1400
1542
  if (needsUsing) results.push(clause(usingExpr));
1401
1543
  if (needsWithCheck) results.push(clause(withCheckExpr));
@@ -1411,13 +1553,25 @@ function resolveTriState(value, onUnknown) {
1411
1553
  * the same model compiled to Postgres RLS DDL, so the decision matches database
1412
1554
  * enforcement for every non-raw rule.
1413
1555
  *
1556
+ * Engine-independent by design. `securityRules` are a declaration about the
1557
+ * data, not about Postgres: the engine decides *who* enforces them (Postgres
1558
+ * compiles them to RLS DDL, a document driver applies them in-process), never
1559
+ * *whether* they hold. Gating this function on the engine's `supportsRLS`
1560
+ * capability is what made every `{ onUnknown: "deny" }` call site in the Mongo
1561
+ * driver return `true` before it evaluated anything — and it did so only for
1562
+ * collections that spelled their engine out, so declaring `engine: "mongodb"`
1563
+ * was what switched authorization off.
1564
+ *
1414
1565
  * @param options.onUnknown how to treat rules that cannot be decided
1415
1566
  * client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
1416
1567
  * for optimistic UI gating; enforcement callers should pass `"deny"`.
1568
+ * @param options.clauses which half of each rule to evaluate. See
1569
+ * {@link PolicyClauses}; defaults to `"both"`.
1417
1570
  */
1418
1571
  function checkOperation(collection, authContext, entity, targetOperation, options) {
1419
1572
  const onUnknown = options?.onUnknown ?? "allow";
1420
- const securityRules = getDataSourceCapabilities(collection.engine).supportsRLS ? collection.securityRules : void 0;
1573
+ const clauses = options?.clauses ?? "both";
1574
+ const securityRules = collection.securityRules;
1421
1575
  if (!securityRules || securityRules.length === 0) return true;
1422
1576
  const applicableRules = securityRules.filter((r) => ruleApplies(r, targetOperation));
1423
1577
  if (applicableRules.length === 0) return false;
@@ -1431,7 +1585,7 @@ function checkOperation(collection, authContext, entity, targetOperation, option
1431
1585
  let hasPermissive = false;
1432
1586
  for (const rule of applicableRules) {
1433
1587
  const mode = rule.mode || "permissive";
1434
- const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
1588
+ const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation, clauses), onUnknown);
1435
1589
  if (mode === "restrictive") {
1436
1590
  if (!passed) {
1437
1591
  deniedByRestrictive = true;
@@ -1627,8 +1781,14 @@ var buildPropertyCallbacks = (properties) => {
1627
1781
  * Rebase's enforcement model is unified: authenticated (user-context) requests
1628
1782
  * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
1629
1783
  * statement — reads and writes. A collection's `securityRules` are the whole
1630
- * authorization model. The server context (auth flows, migrations,
1631
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
1784
+ * authorization model. The server context (auth flows, migrations, raw
1785
+ * `rebase.sql`) runs as the owner and bypasses RLS.
1786
+ *
1787
+ * `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
1788
+ * `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
1789
+ * other caller and clears the baseline below through the *admin* arm, not the
1790
+ * server arm. Which is why `disableDefaultPolicies` plus a lone
1791
+ * `policy.serverContext()` rule locks it out too.
1632
1792
  *
1633
1793
  * Because RLS default-denies, every collection is **locked by default**: with
1634
1794
  * no rules, only the server context and admins can touch it. The generator
@@ -1996,9 +2156,14 @@ function registerConditionOperations() {
1996
2156
  operationsRegistered = true;
1997
2157
  }
1998
2158
  /**
1999
- * Evaluate a JSON Logic rule against the given context.
2159
+ * Evaluate a condition against the given context.
2160
+ *
2161
+ * A condition may be stated as a literal instead of a rule — `hidden: true`
2162
+ * rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
2163
+ * answer, so it is returned rather than handed to the evaluator.
2000
2164
  */
2001
2165
  function evaluateCondition(rule, context) {
2166
+ if (typeof rule === "boolean") return rule;
2002
2167
  registerConditionOperations();
2003
2168
  return jsonLogic.apply(rule, context);
2004
2169
  }
@@ -2317,6 +2482,142 @@ function resolveStringColumnLength(prop) {
2317
2482
  return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 255;
2318
2483
  }
2319
2484
  //#endregion
2485
+ //#region src/util/internal-tables.ts
2486
+ /**
2487
+ * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
2488
+ * end-user role away from them.
2489
+ *
2490
+ * ## Why this exists
2491
+ *
2492
+ * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
2493
+ * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
2494
+ * in the schemas a project uses — including `rebase`, because a project's own
2495
+ * collections are allowed to live there (the scaffold puts `users` there). It
2496
+ * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
2497
+ * migrating role inherits the same grant.
2498
+ *
2499
+ * Every framework-internal table is created later: auth's tables come up during
2500
+ * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
2501
+ * job registers, `idempotency_keys` on the first request that carries a key. So
2502
+ * they all inherited full DML for the end-user role — and none of them enables
2503
+ * row-level security, because none of them is a collection with
2504
+ * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
2505
+ * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
2506
+ * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
2507
+ * `rebase.api_keys` (including its `admin` flag), and insert into
2508
+ * `rebase.app_config`.
2509
+ *
2510
+ * Nothing routes a user-context query at those tables today, so this was not
2511
+ * reachable over the API. That is the wrong thing to depend on: the documented
2512
+ * model is that RLS is the authorization boundary, and these tables sat outside
2513
+ * it. The boundary is now a privilege boundary instead — the role simply cannot
2514
+ * address them.
2515
+ *
2516
+ * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
2517
+ *
2518
+ * RLS with no policy denies every row, which is the same outcome, but it is the
2519
+ * *weaker* statement: it leaves the grant in place, so a later policy — or a
2520
+ * `FORCE` flag cleared by some future migration — reopens the table. There is no
2521
+ * row of `refresh_tokens` any end user should ever reach, so the honest encoding
2522
+ * is "this role has no privilege here at all". It also keeps the owner
2523
+ * connection (which auth actually runs on) completely unaffected.
2524
+ *
2525
+ * ## Keeping it true
2526
+ *
2527
+ * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
2528
+ * "platform" schema — and its `rls-disabled` check fires on exactly the
2529
+ * condition this module removes: RLS off *and* a DML grant to a reachable role.
2530
+ * So a table added here without a revoke is caught by `pnpm rls:check`, not by
2531
+ * someone re-reading this file.
2532
+ */
2533
+ /**
2534
+ * The Postgres role authenticated requests run as.
2535
+ *
2536
+ * Defined here rather than in the Postgres driver because both the driver (which
2537
+ * provisions the role) and this module (which revokes on its behalf) need it,
2538
+ * and a second spelling of a role name is a silent no-op waiting to happen.
2539
+ */
2540
+ var REBASE_USER_ROLE = "rebase_user";
2541
+ /**
2542
+ * Framework-internal table names, unqualified.
2543
+ *
2544
+ * Deliberately NOT including `users`: the auth user table is also a collection,
2545
+ * with `securityRules`, RLS enabled and policies applied. Users read their own
2546
+ * row through it — revoking there would break sign-in.
2547
+ *
2548
+ * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
2549
+ * because `db migrate apply` passes `--revisions-schema rebase`.
2550
+ */
2551
+ var REBASE_INTERNAL_TABLES = [
2552
+ "user_identities",
2553
+ "refresh_tokens",
2554
+ "password_reset_tokens",
2555
+ "magic_link_tokens",
2556
+ "mfa_factors",
2557
+ "mfa_challenges",
2558
+ "recovery_codes",
2559
+ "app_config",
2560
+ "schema_meta",
2561
+ "api_keys",
2562
+ "cron_logs",
2563
+ "cron_claims",
2564
+ "idempotency_keys",
2565
+ "entity_history",
2566
+ "branches",
2567
+ "channel_messages",
2568
+ "channel_cursors",
2569
+ "channel_presence",
2570
+ "atlas_schema_revisions"
2571
+ ];
2572
+ /** Postgres identifiers this module is willing to interpolate. */
2573
+ var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
2574
+ /**
2575
+ * A single statement that takes every privilege on `schema.table` away from the
2576
+ * end-user role.
2577
+ *
2578
+ * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
2579
+ * happen in practice:
2580
+ *
2581
+ * - the role does not exist when the connection is unprivileged (Rebase then
2582
+ * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
2583
+ * missing role is an error, not a no-op;
2584
+ * - the table may not exist yet — `cron_logs` never appears in a project with
2585
+ * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
2586
+ *
2587
+ * One command, so it is safe on handles that speak the extended query protocol
2588
+ * and reject multi-statement strings.
2589
+ */
2590
+ function revokeInternalTableSql(schema, table) {
2591
+ if (!SAFE_IDENTIFIER.test(schema)) throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
2592
+ if (!SAFE_IDENTIFIER.test(table)) throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
2593
+ const qualified = `"${schema}"."${table}"`;
2594
+ return `
2595
+ DO $rebase_revoke$
2596
+ BEGIN
2597
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
2598
+ AND to_regclass('${qualified}') IS NOT NULL THEN
2599
+ EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
2600
+ END IF;
2601
+ END
2602
+ $rebase_revoke$;
2603
+ `.trim();
2604
+ }
2605
+ /**
2606
+ * Revoke on every internal table in `schema`, one statement at a time.
2607
+ *
2608
+ * Best-effort per table: a connection that does not own one of them (a
2609
+ * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
2610
+ * that must not take down a boot. The caller decides how loud to be — `onError`
2611
+ * exists so the driver can warn without this module importing a logger.
2612
+ */
2613
+ async function revokeInternalTableAccess(execute, schema, options) {
2614
+ for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) try {
2615
+ await execute(revokeInternalTableSql(schema, table));
2616
+ } catch (error) {
2617
+ options?.onError?.(table, error);
2618
+ }
2619
+ }
2620
+ //#endregion
2320
2621
  //#region src/data/resolveDataSource.ts
2321
2622
  /**
2322
2623
  * Build a keyed registry from a list of {@link DataSourceDefinition}s.
@@ -2359,6 +2660,46 @@ function resolveDataSource(collection, registry) {
2359
2660
  capabilities: getDataSourceCapabilities(engine)
2360
2661
  };
2361
2662
  }
2663
+ /**
2664
+ * Does a SQL toolchain own this collection's storage?
2665
+ *
2666
+ * "Owns the storage" means: something generates a table for it, pushes that
2667
+ * table to a database, plans its RLS policies, and reports it as drifted when
2668
+ * the two disagree. That is true of a Postgres collection and false of a
2669
+ * Firestore or MongoDB one, whose documents live in a store Rebase never
2670
+ * migrates — and the two were never told apart. Every stage of the SQL
2671
+ * toolchain took "the collections" to mean *all* of them, so a Firestore
2672
+ * collection declared next to the Postgres ones got a `pgTable` in the
2673
+ * generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
2674
+ * `db push` include list — where its name shielding a same-named real table
2675
+ * from Atlas's exclude list is the one that can lose data.
2676
+ *
2677
+ * The answer is the resolved engine's {@link DataSourceCapabilities}, not a
2678
+ * name check: an engine registered through `registerDataSourceCapabilities`
2679
+ * gets the same treatment as the built-in ones.
2680
+ *
2681
+ * Deliberately answers **true** for an engine nobody has heard of. Build-time
2682
+ * tooling (the CLI, the schema generator) has no data-source registry to
2683
+ * resolve a `dataSource` key against, so an unknown key resolves to an unknown
2684
+ * engine — and the cost of the two mistakes is not symmetric. Wrongly
2685
+ * including a collection generates a table nothing writes to; wrongly excluding
2686
+ * one silently stops generating a table the app is serving from. Declare
2687
+ * `engine` on a collection that is not SQL-backed and this is exact.
2688
+ */
2689
+ function isRelationalCollection(collection, registry) {
2690
+ return getDataSourceCapabilities(collection?.engine ?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : void 0)).supportsRelations;
2691
+ }
2692
+ /**
2693
+ * The subset of `collections` a SQL toolchain owns — see
2694
+ * {@link isRelationalCollection}.
2695
+ *
2696
+ * Every stage that generates SQL from collections starts by calling this, so
2697
+ * the rule lives in one place rather than being re-decided per generator. It
2698
+ * keeps the input order.
2699
+ */
2700
+ function relationalCollections(collections, registry) {
2701
+ return collections.filter((collection) => isRelationalCollection(collection, registry));
2702
+ }
2362
2703
  //#endregion
2363
2704
  //#region src/collections/CollectionRegistry.ts
2364
2705
  var CollectionRegistry = class {
@@ -2788,8 +3129,24 @@ var QueryBuilder = class {
2788
3129
  /**
2789
3130
  * Set a free-text search string if supported by the backend.
2790
3131
  */
2791
- search(searchString) {
3132
+ search(searchString, options) {
2792
3133
  this.params.searchString = searchString;
3134
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
3135
+ return this;
3136
+ }
3137
+ /**
3138
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
3139
+ *
3140
+ * Postgres only, over a property declared as `type: "vector"`. Rows come
3141
+ * back with a `_distance`; `where` filters before the ordering.
3142
+ */
3143
+ vectorSearch(property, vector, options) {
3144
+ this.params.vectorSearch = {
3145
+ property,
3146
+ vector,
3147
+ ...options?.distance !== void 0 && { distance: options.distance },
3148
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
3149
+ };
2793
3150
  return this;
2794
3151
  }
2795
3152
  /**
@@ -2861,6 +3218,30 @@ var RebasePaginationError = class RebasePaginationError extends Error {
2861
3218
  Object.setPrototypeOf(this, RebasePaginationError.prototype);
2862
3219
  }
2863
3220
  };
3221
+ /**
3222
+ * Resolve `limit`/`offset`/`page` into the window a read will actually use.
3223
+ *
3224
+ * Lives here, next to the walk, for the reason at the top of this file: every
3225
+ * transport has to mean the same thing by "page two". Four of them did not —
3226
+ * the REST layer strode by {@link DEFAULT_LIST_LIMIT}, the local-first
3227
+ * evaluator by {@link DEFAULT_PAGE_SIZE}, the in-process accessor by 20, and
3228
+ * the published type documented a fourth number. Pages that overlap or skip
3229
+ * rows are the mildest of those outcomes.
3230
+ *
3231
+ * `page` wins over `offset`, as {@link FindParams} documents. `driverOffset`
3232
+ * is the value to hand a driver: it stays `undefined` when the caller named no
3233
+ * offset, because keyset pagination seeks with a `where` clause and must not
3234
+ * look like it is paging by offset.
3235
+ */
3236
+ function resolveFindWindow(params) {
3237
+ const limit = params?.limit ?? DEFAULT_LIST_LIMIT;
3238
+ const offset = params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0;
3239
+ return {
3240
+ limit,
3241
+ offset,
3242
+ driverOffset: params?.page != null ? offset : params?.offset
3243
+ };
3244
+ }
2864
3245
  function normalizePageSize(raw) {
2865
3246
  if (raw === void 0 || !Number.isFinite(raw)) return 200;
2866
3247
  return Math.max(1, Math.floor(raw));
@@ -2992,8 +3373,10 @@ function createPaginationHelpers(find, label) {
2992
3373
  * metadata, so type coercion is the responsibility of the server-side data
2993
3374
  * driver which has access to the collection schema.
2994
3375
  *
2995
- * Commas inside list values are backslash-escaped (`\,`), and literal
2996
- * backslashes are escaped as `\\`.
3376
+ * Structural characters inside a value are backslash-escaped: `,` → `\,`,
3377
+ * `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
3378
+ * deliberately conservative — only those four sequences are decoded, so a
3379
+ * backslash that arrives unescaped from an older client survives intact.
2997
3380
  *
2998
3381
  * @module
2999
3382
  */
@@ -3011,22 +3394,48 @@ function stringifyValue(value) {
3011
3394
  return String(value);
3012
3395
  }
3013
3396
  /**
3014
- * Escape a single list item for the wire format.
3015
- * `\` → `\\`, `,` → `\,`
3397
+ * Characters that carry structure in the wire format and must therefore be
3398
+ * escaped inside a value: the separator, the group delimiters, and the escape
3399
+ * character itself.
3400
+ *
3401
+ * Parentheses are here because `and(...)`/`or(...)` groups are parsed by
3402
+ * tracking paren depth. A value containing one is not merely ambiguous, it
3403
+ * moves where the parser thinks the group ends.
3404
+ */
3405
+ var WIRE_SPECIALS = /[\\,()]/g;
3406
+ /**
3407
+ * Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
3408
+ * `)` → `\)`.
3016
3409
  */
3017
- function escapeListItem(value) {
3018
- return value.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
3410
+ function escapeWireValue(value) {
3411
+ return value.replace(WIRE_SPECIALS, (ch) => `\\${ch}`);
3019
3412
  }
3020
3413
  /**
3021
- * Unescape a single list item from the wire format.
3022
- * `\\` → `\`, `\,` → `,`
3414
+ * Unescape a wire-format value.
3415
+ *
3416
+ * **Conservative**, and deliberately so: only the four sequences
3417
+ * {@link escapeWireValue} actually produces are decoded. A backslash followed
3418
+ * by anything else is left exactly as it is.
3419
+ *
3420
+ * This used to consume the backslash before *any* character, which is
3421
+ * indistinguishable for anything this codec emitted — it only ever emits those
3422
+ * four — but not for input arriving from elsewhere. A client on an older
3423
+ * release sends a Windows path or a LIKE pattern with a literal `C:\x`
3424
+ * unescaped, and greedy unescaping silently turned it into `C:x`, changing
3425
+ * which rows matched. Decoding only what the encoder can produce makes the two
3426
+ * directions agree across versions.
3023
3427
  */
3024
- function unescapeListItem(value) {
3428
+ function unescapeWireValue(value) {
3025
3429
  let result = "";
3026
- for (let i = 0; i < value.length; i++) if (value[i] === "\\" && i + 1 < value.length) {
3027
- result += value[i + 1];
3028
- i++;
3029
- } else result += value[i];
3430
+ for (let i = 0; i < value.length; i++) {
3431
+ const next = value[i + 1];
3432
+ if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
3433
+ result += next;
3434
+ i++;
3435
+ continue;
3436
+ }
3437
+ result += value[i];
3438
+ }
3030
3439
  return result;
3031
3440
  }
3032
3441
  /**
@@ -3044,12 +3453,42 @@ function splitListItems(inner) {
3044
3453
  current += inner[i] + inner[i + 1];
3045
3454
  i++;
3046
3455
  } else if (inner[i] === ",") {
3047
- items.push(unescapeListItem(current));
3456
+ items.push(unescapeWireValue(current));
3048
3457
  current = "";
3049
3458
  } else current += inner[i];
3050
- items.push(unescapeListItem(current));
3459
+ items.push(unescapeWireValue(current));
3051
3460
  return items;
3052
3461
  }
3462
+ /**
3463
+ * Split a group body on commas at paren depth 0, honouring escapes.
3464
+ *
3465
+ * The escape-awareness is the point. The splitter used to track only paren
3466
+ * depth, so a comma inside a scalar value ended a condition:
3467
+ * `or(name.eq.Doe, John,age.gte.18)` parsed as *three* conditions, the middle
3468
+ * one a fabricated `" John" == true`. On an `or` that widens the result set,
3469
+ * and nothing anywhere reports an error — the query simply stops meaning what
3470
+ * the caller wrote.
3471
+ */
3472
+ function splitGroupItems(inner) {
3473
+ const parts = [];
3474
+ let depth = 0;
3475
+ let start = 0;
3476
+ for (let i = 0; i < inner.length; i++) {
3477
+ const ch = inner[i];
3478
+ if (ch === "\\" && i + 1 < inner.length) {
3479
+ i++;
3480
+ continue;
3481
+ }
3482
+ if (ch === "(") depth++;
3483
+ else if (ch === ")") depth--;
3484
+ else if (ch === "," && depth === 0) {
3485
+ parts.push(inner.slice(start, i));
3486
+ start = i + 1;
3487
+ }
3488
+ }
3489
+ parts.push(inner.slice(start));
3490
+ return parts;
3491
+ }
3053
3492
  var REST_OP_LOOKUP = REST_TO_CANONICAL;
3054
3493
  var CANONICAL_OP_LOOKUP = CANONICAL_TO_REST;
3055
3494
  /**
@@ -3068,7 +3507,7 @@ function serializeTuple(tuple) {
3068
3507
  if (typeof op !== "string") throw new TypeError(`serializeTuple: operator must be a string, got ${typeof op}`);
3069
3508
  const restOp = CANONICAL_OP_LOOKUP[op];
3070
3509
  if (!restOp) throw new TypeError(`serializeTuple: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`);
3071
- if (Array.isArray(value)) return `${restOp}.(${value.map((v) => escapeListItem(stringifyValue(v))).join(",")})`;
3510
+ if (Array.isArray(value)) return `${restOp}.(${value.map((v) => escapeWireValue(stringifyValue(v))).join(",")})`;
3072
3511
  return `${restOp}.${stringifyValue(value)}`;
3073
3512
  }
3074
3513
  /**
@@ -3178,10 +3617,10 @@ function serializeLogicalCondition(cond) {
3178
3617
  }
3179
3618
  const restOp = CANONICAL_OP_LOOKUP[cond.operator] ?? "eq";
3180
3619
  if (Array.isArray(cond.value)) {
3181
- const items = cond.value.map((v) => escapeListItem(stringifyValue(v))).join(",");
3620
+ const items = cond.value.map((v) => escapeWireValue(stringifyValue(v))).join(",");
3182
3621
  return `${cond.column}.${restOp}.(${items})`;
3183
3622
  }
3184
- return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
3623
+ return `${cond.column}.${restOp}.${escapeWireValue(stringifyValue(cond.value))}`;
3185
3624
  }
3186
3625
  /**
3187
3626
  * Parse a logical condition wire-format string back into a
@@ -3194,24 +3633,29 @@ function serializeLogicalCondition(cond) {
3194
3633
  * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
3195
3634
  * // → { type: "or", conditions: [...] }
3196
3635
  */
3197
- function deserializeLogicalCondition(str) {
3636
+ /**
3637
+ * How deeply `or(...)`/`and(...)` groups may nest.
3638
+ *
3639
+ * This parser recurses once per level, on a value that arrives in a query
3640
+ * string. Unbounded, twenty thousand levels reached `RangeError: Maximum call
3641
+ * stack size exceeded`, which a caller sees as a 500 about the call stack
3642
+ * rather than a 400 about their filter. Node's 16 KB header cap keeps a GET
3643
+ * below that in practice, but "the HTTP layer happens to stop it" is not a
3644
+ * bound this parser should rely on.
3645
+ *
3646
+ * Thirty-two is far past anything a real filter expresses; the deepest in this
3647
+ * repository's own tests is three.
3648
+ */
3649
+ var MAX_LOGICAL_NESTING_DEPTH = 32;
3650
+ function deserializeLogicalCondition(str, nesting = 0) {
3651
+ if (nesting > 32) throw new Error(`Filter groups nest more than 32 levels deep. Flatten the condition — \`or(a,or(b,c))\` is \`or(a,b,c)\`.`);
3198
3652
  const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
3199
3653
  if (logicalMatch) {
3200
3654
  const type = logicalMatch[1];
3201
3655
  const innerStr = logicalMatch[2];
3202
- const conditions = [];
3203
- let depth = 0;
3204
- let start = 0;
3205
- for (let i = 0; i < innerStr.length; i++) if (innerStr[i] === "(") depth++;
3206
- else if (innerStr[i] === ")") depth--;
3207
- else if (innerStr[i] === "," && depth === 0) {
3208
- conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
3209
- start = i + 1;
3210
- }
3211
- conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
3212
3656
  return {
3213
3657
  type,
3214
- conditions
3658
+ conditions: splitGroupItems(innerStr).map((part) => deserializeLogicalCondition(part, nesting + 1))
3215
3659
  };
3216
3660
  }
3217
3661
  const firstDot = str.indexOf(".");
@@ -3226,7 +3670,7 @@ function deserializeLogicalCondition(str) {
3226
3670
  if (secondDot === -1) return {
3227
3671
  column,
3228
3672
  operator: "==",
3229
- value: rest
3673
+ value: unescapeWireValue(rest)
3230
3674
  };
3231
3675
  const opStr = rest.substring(0, secondDot);
3232
3676
  const valueStr = rest.substring(secondDot + 1);
@@ -3239,7 +3683,7 @@ function deserializeLogicalCondition(str) {
3239
3683
  return {
3240
3684
  column,
3241
3685
  operator,
3242
- value: valueStr
3686
+ value: unescapeWireValue(valueStr)
3243
3687
  };
3244
3688
  }
3245
3689
  //#endregion
@@ -3276,10 +3720,12 @@ function createPrimaryKeyResolver(options) {
3276
3720
  * than postgres still serve rows with one, and this keeps them working.
3277
3721
  */
3278
3722
  function rowToEntity(row, slug, primaryKeys = []) {
3723
+ const { _matches, ...values } = row;
3279
3724
  return {
3280
3725
  id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
3281
3726
  path: slug,
3282
- values: row
3727
+ values,
3728
+ ..._matches ? { searchMatches: _matches } : {}
3283
3729
  };
3284
3730
  }
3285
3731
  /**
@@ -3322,21 +3768,22 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3322
3768
  const accessor = {
3323
3769
  async find(params) {
3324
3770
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
3325
- const limit = params?.limit ?? 20;
3326
- const offset = params?.offset ?? 0;
3771
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
3327
3772
  const fetchService = driver.restFetchService;
3328
3773
  const rows = fetchService ? await fetchService.fetchCollectionForRest(slug, {
3329
3774
  filter,
3330
- limit: params?.limit,
3331
- offset: params?.offset,
3775
+ logical: params?.logical,
3776
+ limit,
3777
+ offset: driverOffset,
3332
3778
  orderBy: params?.orderBy?.[0],
3333
3779
  order: params?.orderBy?.[1],
3334
3780
  searchString: params?.searchString
3335
3781
  }, params?.include) : await driver.fetchCollection({
3336
3782
  path: slug,
3337
- limit: params?.limit,
3338
- offset: params?.offset,
3783
+ limit,
3784
+ offset: driverOffset,
3339
3785
  filter,
3786
+ logical: params?.logical,
3340
3787
  orderBy: params?.orderBy?.[0],
3341
3788
  order: params?.orderBy?.[1],
3342
3789
  searchString: params?.searchString
@@ -3346,7 +3793,9 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3346
3793
  if (driver.count) {
3347
3794
  total = await driver.count({
3348
3795
  path: slug,
3349
- filter
3796
+ filter,
3797
+ logical: params?.logical,
3798
+ searchString: params?.searchString
3350
3799
  });
3351
3800
  hasMore = offset + rows.length < total;
3352
3801
  }
@@ -3398,30 +3847,48 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3398
3847
  values: {}
3399
3848
  } });
3400
3849
  },
3850
+ updateMany: driver.updateMany ? async (updates) => {
3851
+ return (await driver.updateMany({
3852
+ path: slug,
3853
+ updates: updates.map((u) => ({
3854
+ id: u.id,
3855
+ values: u.data
3856
+ }))
3857
+ })).map((row) => rowToEntity(row, slug, getPks()));
3858
+ } : void 0,
3859
+ deleteMany: driver.deleteMany ? async (ids) => {
3860
+ await driver.deleteMany({
3861
+ path: slug,
3862
+ ids
3863
+ });
3864
+ } : void 0,
3401
3865
  count: driver.count ? async (params) => {
3402
3866
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
3403
3867
  return driver.count({
3404
3868
  path: slug,
3405
- filter
3869
+ filter,
3870
+ logical: params?.logical,
3871
+ searchString: params?.searchString
3406
3872
  });
3407
3873
  } : void 0,
3408
3874
  listen: driver.listenCollection ? (params, onUpdate, onError) => {
3409
- const limit = params?.limit ?? 20;
3410
- const offset = params?.offset ?? 0;
3875
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
3411
3876
  const normalize = driver.restFetchService ? inlineRelationRefs : (row) => row;
3412
3877
  return driver.listenCollection({
3413
3878
  path: slug,
3414
- limit: params?.limit,
3415
- offset: params?.offset,
3879
+ limit,
3880
+ offset: driverOffset,
3416
3881
  filter: params?.where,
3882
+ logical: params?.logical,
3417
3883
  orderBy: params?.orderBy?.[0],
3418
3884
  order: params?.orderBy?.[1],
3419
3885
  searchString: params?.searchString,
3886
+ searchExplain: params?.searchExplain,
3420
3887
  onUpdate: (entities) => {
3421
3888
  onUpdate({
3422
3889
  data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
3423
3890
  meta: {
3424
- total: entities.length,
3891
+ total: offset + entities.length,
3425
3892
  limit,
3426
3893
  offset,
3427
3894
  hasMore: entities.length >= limit
@@ -3454,8 +3921,11 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3454
3921
  offset(count) {
3455
3922
  return new QueryBuilder(accessor).offset(count);
3456
3923
  },
3457
- search(searchString) {
3458
- return new QueryBuilder(accessor).search(searchString);
3924
+ search(searchString, options) {
3925
+ return new QueryBuilder(accessor).search(searchString, options);
3926
+ },
3927
+ vectorSearch(property, vector, options) {
3928
+ return new QueryBuilder(accessor).vectorSearch(property, vector, options);
3459
3929
  },
3460
3930
  include(...relations) {
3461
3931
  return new QueryBuilder(accessor).include(...relations);
@@ -3543,8 +4013,18 @@ var SdkQueryBuilder = class {
3543
4013
  this.params.offset = count;
3544
4014
  return this;
3545
4015
  }
3546
- search(searchString) {
4016
+ search(searchString, options) {
3547
4017
  this.params.searchString = searchString;
4018
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
4019
+ return this;
4020
+ }
4021
+ vectorSearch(property, vector, options) {
4022
+ this.params.vectorSearch = {
4023
+ property,
4024
+ vector,
4025
+ ...options?.distance !== void 0 && { distance: options.distance },
4026
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
4027
+ };
3548
4028
  return this;
3549
4029
  }
3550
4030
  include(...relations) {
@@ -3598,9 +4078,24 @@ function toSdkCollectionClient(snap, slug = "collection") {
3598
4078
  async update(id, data) {
3599
4079
  return entityToRow(await snap.update(id, data));
3600
4080
  },
4081
+ async updateMany(updates) {
4082
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
4083
+ if (updates.length === 0) return [];
4084
+ if (!snap.updateMany) throw new Error("Bulk updates are not supported by this collection's data source. Fall back to update() per record.");
4085
+ return (await snap.updateMany(updates.map((u) => ({
4086
+ id: u.id,
4087
+ data: u.data
4088
+ })))).map(entityToRow);
4089
+ },
3601
4090
  delete(id) {
3602
4091
  return snap.delete(id);
3603
4092
  },
4093
+ async deleteMany(ids) {
4094
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
4095
+ if (ids.length === 0) return;
4096
+ if (!snap.deleteMany) throw new Error("Bulk deletes are not supported by this collection's data source. Fall back to delete() per record.");
4097
+ await snap.deleteMany(ids);
4098
+ },
3604
4099
  count: snap.count ? (params) => snap.count(params) : void 0,
3605
4100
  listen: snap.listen ? (params, onUpdate, onError) => snap.listen(params, (res) => onUpdate({
3606
4101
  data: res.data.map(entityToRow),
@@ -3616,6 +4111,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
3616
4111
  limit: (count) => new SdkQueryBuilder(client).limit(count),
3617
4112
  offset: (count) => new SdkQueryBuilder(client).offset(count),
3618
4113
  search: (searchString) => new SdkQueryBuilder(client).search(searchString),
4114
+ vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
3619
4115
  include: (...relations) => new SdkQueryBuilder(client).include(...relations)
3620
4116
  };
3621
4117
  return client;
@@ -3623,7 +4119,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
3623
4119
  /**
3624
4120
  * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
3625
4121
  * {@link CollectionAccessor}. Every returned row is re-wrapped into the
3626
- * `{ id, path, values }` view-model the admin admin renders.
4122
+ * `{ id, path, values }` view-model the admin panel renders.
3627
4123
  */
3628
4124
  function toEntityAccessor(sdk, slug, getPks = () => []) {
3629
4125
  const accessor = {
@@ -3641,6 +4137,9 @@ function toEntityAccessor(sdk, slug, getPks = () => []) {
3641
4137
  async create(data, id) {
3642
4138
  return rowToEntity(await sdk.create(data, id), slug, getPks());
3643
4139
  },
4140
+ createMany: sdk.createMany ? async (data, options) => {
4141
+ return (await sdk.createMany(data, options)).map((row) => rowToEntity(row, slug, getPks()));
4142
+ } : void 0,
3644
4143
  async update(id, data) {
3645
4144
  const row = await sdk.update(id, data);
3646
4145
  if (!row) throw new Error(`Update returned no data for id ${id}`);
@@ -3664,6 +4163,7 @@ function toEntityAccessor(sdk, slug, getPks = () => []) {
3664
4163
  limit: (count) => new QueryBuilder(accessor).limit(count),
3665
4164
  offset: (count) => new QueryBuilder(accessor).offset(count),
3666
4165
  search: (searchString) => new QueryBuilder(accessor).search(searchString),
4166
+ vectorSearch: (property, vector, options) => new QueryBuilder(accessor).vectorSearch(property, vector, options),
3667
4167
  include: (...relations) => new QueryBuilder(accessor).include(...relations)
3668
4168
  };
3669
4169
  return accessor;
@@ -3677,6 +4177,15 @@ function toEntityAccessor(sdk, slug, getPks = () => []) {
3677
4177
  * admin `RebaseDataContext` — without it the admin renders rows with only their
3678
4178
  * `id`.
3679
4179
  */
4180
+ /**
4181
+ * Only the by-slug accessor is asked for, so only that is required.
4182
+ *
4183
+ * Taking a whole `RebaseSdkData` meant taking `RebaseSdkData<unknown>`, whose
4184
+ * dynamic branch is an index signature — and no `RebaseSdkData<DB>` satisfies
4185
+ * it, because its own `collection` method is not a `SDKCollectionClient`. So a
4186
+ * caller holding a *typed* client could not pass it to a function that reads
4187
+ * one method off it, and that method is identical on every instantiation.
4188
+ */
3680
4189
  function wrapAsEntityData(sdkData, options) {
3681
4190
  const cache = /* @__PURE__ */ new Map();
3682
4191
  const primaryKeysFor = createPrimaryKeyResolver(options);
@@ -3779,6 +4288,27 @@ function buildRoutedRebaseData({ defaultData, sources, resolveKey }) {
3779
4288
  } });
3780
4289
  }
3781
4290
  //#endregion
4291
+ //#region src/data/filter-conditions.ts
4292
+ /**
4293
+ * Read one field's filter as the list of conditions it stands for.
4294
+ *
4295
+ * Accepts both declared shapes and normalises them to a list:
4296
+ *
4297
+ * ```ts
4298
+ * toFilterTuples(["==", "active"]) // [["==", "active"]]
4299
+ * toFilterTuples([[">=", 18], ["<", 65]]) // [[">=", 18], ["<", 65]]
4300
+ * ```
4301
+ *
4302
+ * A falsy, non-array or empty param has no conditions in it — the empty list,
4303
+ * so a caller iterating adds nothing rather than compiling a tuple of
4304
+ * `undefined`s and logging about an operator nobody sent.
4305
+ */
4306
+ function toFilterTuples(filterParam) {
4307
+ if (!filterParam || !Array.isArray(filterParam) || filterParam.length === 0) return [];
4308
+ if (Array.isArray(filterParam[0])) return filterParam;
4309
+ return [filterParam];
4310
+ }
4311
+ //#endregion
3782
4312
  //#region src/data/sort-dialect.ts
3783
4313
  /**
3784
4314
  * Sort-order wire codec.
@@ -3906,6 +4436,6 @@ async function detectJunctionTables(executeSql) {
3906
4436
  return junctionTables;
3907
4437
  }
3908
4438
  //#endregion
3909
- export { COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_STRING_COLUMN_LENGTH, JUNCTION_TABLES_SQL, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, RebasePaginationError, and, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, detectJunctionTables, embedParentExpression, enumToObjectEntries, evaluateCondition, evaluatePolicy, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getSubcollections, getTableName, getTableVarName, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, normalizeEmail, normalizeToEntityRelation, or, paginateFind, parseIdValues, policyToPostgres, registerConditionOperations, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortProperties, sqlToPolicy, stripCollectionPath, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
4439
+ export { COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_STRING_COLUMN_LENGTH, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RebasePaginationError, and, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, detectJunctionTables, embedParentExpression, enumToObjectEntries, evaluateCondition, evaluatePolicy, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationalCollection, normalizeEmail, normalizeToEntityRelation, or, paginateFind, parseIdValues, policyToPostgres, registerConditionOperations, relationalCollections, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortCollectionsBySlug, sortProperties, sqlToPolicy, stripCollectionPath, toFilterTuples, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
3910
4440
 
3911
4441
  //# sourceMappingURL=index.es.js.map