@rebasepro/common 0.13.0 → 0.13.1-canary.g1822133

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 (43) 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 +70 -4
  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 +951 -123
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/util/auth-default-policies.d.ts +0 -8
  11. package/dist/util/builders.d.ts +2 -2
  12. package/dist/util/collections.d.ts +17 -0
  13. package/dist/util/conditions.d.ts +7 -3
  14. package/dist/util/entities.d.ts +8 -1
  15. package/dist/util/index.d.ts +1 -0
  16. package/dist/util/internal-tables.d.ts +95 -0
  17. package/dist/util/permissions.d.ts +30 -0
  18. package/dist/util/policy/sqlToPolicy.d.ts +4 -4
  19. package/dist/util/relations.d.ts +47 -1
  20. package/dist/util/resolutions.d.ts +31 -0
  21. package/package.json +5 -4
  22. package/src/data/buildRebaseData.ts +161 -27
  23. package/src/data/filter-conditions.ts +46 -0
  24. package/src/data/filter-dialect.ts +380 -52
  25. package/src/data/paginate.ts +30 -0
  26. package/src/data/query_builder.ts +26 -4
  27. package/src/data/resolveDataSource.ts +56 -0
  28. package/src/index.ts +1 -0
  29. package/src/util/auth-default-policies.ts +56 -15
  30. package/src/util/builders.ts +3 -3
  31. package/src/util/collections.ts +17 -1
  32. package/src/util/conditions.ts +8 -3
  33. package/src/util/entities.ts +15 -1
  34. package/src/util/index.ts +1 -0
  35. package/src/util/internal-tables.ts +154 -0
  36. package/src/util/permissions.test.ts +23 -2
  37. package/src/util/permissions.ts +43 -6
  38. package/src/util/pg-column-to-property.ts +26 -4
  39. package/src/util/policy/evaluatePolicy.ts +24 -2
  40. package/src/util/policy/policyToPostgres.ts +23 -10
  41. package/src/util/policy/sqlToPolicy.ts +180 -33
  42. package/src/util/relations.ts +75 -1
  43. package/src/util/resolutions.ts +77 -5
package/dist/index.es.js CHANGED
@@ -1,5 +1,5 @@
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";
2
- import { deepClone, generateForeignKeyName, getIn, getPolicyNamesForRules, getPolicyOperations, isDefaultFieldConfigId, mergeDeep, prettifyIdentifier, randomString, removeFunctions, toSnakeCase } from "@rebasepro/utils";
1
+ import { ALL_WHERE_FILTER_OPS, 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
+ import { deepClone, firstFreeKey, generateForeignKeyName, getIn, getPolicyNamesForRules, getPolicyOperations, isDefaultFieldConfigId, mergeDeep, prettifyIdentifier, randomString, removeFunctions, toSnakeCase, toWireKey } from "@rebasepro/utils";
3
3
  import jsonLogic from "json-logic-js";
4
4
  import { deepEqual } from "fast-equals";
5
5
  //#region src/util/common.ts
@@ -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);
@@ -622,6 +677,48 @@ function getColumnName(fullColumn) {
622
677
  return fullColumn.includes(".") ? fullColumn.split(".").pop() : fullColumn;
623
678
  }
624
679
  /**
680
+ * The field key a database column is served and addressed under.
681
+ *
682
+ * A column has two names and they are not the same name. `author_id` is what
683
+ * Postgres stores; `authorId` is the key on the JSON row, the key in the
684
+ * generated Drizzle table, and the key a caller writes in `where` and
685
+ * `orderBy`. Every place that starts from a column and has to reach a row, a
686
+ * Drizzle table or a payload goes through here, so there is one answer rather
687
+ * than one per call site — the two that disagreed put `displayName` and
688
+ * `author_id` on the same API.
689
+ *
690
+ * A declared property is the authority when there is one, because its key *is*
691
+ * the wire name and `columnName` is the only thing that ever renamed the
692
+ * column:
693
+ *
694
+ * 1. an explicit `columnName` equal to this column;
695
+ * 2. a property whose key is literally the column (an author who wrote
696
+ * `author_id:` meant `author_id` on the wire, and gets it);
697
+ * 3. a property whose key snake-cases to the column, which is the default
698
+ * mapping — `authorId` → `author_id`.
699
+ *
700
+ * With no property in the way — a foreign key derived from a relation, which
701
+ * usually has none — the name is derived: {@link toWireKey}.
702
+ *
703
+ * Note the fallback is *not* the column verbatim. That was the old behaviour
704
+ * and it is precisely the defect: a derived foreign key reached the wire under
705
+ * its column name while every hand-authored field beside it was camelCase.
706
+ */
707
+ function fieldKeyForColumn(collection, column) {
708
+ const properties = collection?.properties;
709
+ if (properties) {
710
+ for (const [key, prop] of Object.entries(properties)) {
711
+ const columnName = prop?.columnName;
712
+ if (typeof columnName === "string" && columnName === column) return key;
713
+ }
714
+ for (const key of Object.keys(properties)) {
715
+ if (key === column) return key;
716
+ if (toSnakeCase(key) === column) return key;
717
+ }
718
+ }
719
+ return toWireKey(column);
720
+ }
721
+ /**
625
722
  * Look up a relation by key with forgiving normalization.
626
723
  *
627
724
  * `resolveCollectionRelations` stores each relation under a single canonical
@@ -882,6 +979,59 @@ function getEntityChildViews(collection) {
882
979
  return views;
883
980
  }
884
981
  /**
982
+ * Each of `collection`'s tabs paired with the property that declared it, when a
983
+ * property declared it: child view key → property key.
984
+ *
985
+ * A many-relation can only be declared as a property — that is the documented
986
+ * and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So
987
+ * one declaration reaches the panel twice, and neither surface knew about the
988
+ * other. The form rendered a relation picker beside the tab, and the collection
989
+ * table rendered *two* columns under one heading: the relation's own column,
990
+ * showing the child rows, and a jump-to-tab button carrying the same name.
991
+ *
992
+ * The pairing is what lets each surface decide which half is redundant, and it
993
+ * has to be a pairing rather than two sets because the two keys differ whenever
994
+ * a relation is named. The match is on the resolved `relationName` — the
995
+ * identity `getEntityChildViews` itself dedupes on — so a relation declared in
996
+ * `relations` and pointed at by a differently-named property is recognised too.
997
+ *
998
+ * A relation with no property of its own is absent here, which is the point: it
999
+ * has exactly one surface already, and nothing to weigh it against.
1000
+ *
1001
+ * Only top-level properties: a relation nested inside a `map` gets no tab.
1002
+ */
1003
+ function getChildViewDeclaringProperties(collection) {
1004
+ const pairs = /* @__PURE__ */ new Map();
1005
+ const relationProperties = Object.entries(collection.properties ?? {}).filter(([, property]) => property?.type === "relation");
1006
+ if (relationProperties.length === 0) return pairs;
1007
+ const relationViews = getEntityChildViews(collection).filter((view) => view.source.kind === "relation");
1008
+ if (relationViews.length === 0) return pairs;
1009
+ const resolvedRelations = resolveCollectionRelations(collection);
1010
+ const identityOf = (relationKey) => resolvedRelations[relationKey]?.relationName ?? relationKey;
1011
+ const declaringPropertyByIdentity = /* @__PURE__ */ new Map();
1012
+ for (const [propertyKey, property] of relationProperties) {
1013
+ const relation = property.resolvedRelation ?? resolvedRelations[propertyKey];
1014
+ if (relation?.cardinality !== "many") continue;
1015
+ const identity = relation.relationName ?? propertyKey;
1016
+ if (!declaringPropertyByIdentity.has(identity)) declaringPropertyByIdentity.set(identity, propertyKey);
1017
+ }
1018
+ for (const view of relationViews) {
1019
+ const propertyKey = declaringPropertyByIdentity.get(identityOf(view.source.relationKey));
1020
+ if (propertyKey) pairs.set(view.key, propertyKey);
1021
+ }
1022
+ return pairs;
1023
+ }
1024
+ /**
1025
+ * The property keys of `collection` whose relation is already one of its tabs.
1026
+ *
1027
+ * What a form asks: the tab is the treatment for a list of child rows, so the
1028
+ * picker beside it is the redundant half. See
1029
+ * {@link getChildViewDeclaringProperties}.
1030
+ */
1031
+ function getChildViewRelationPropertyKeys(collection) {
1032
+ return new Set(getChildViewDeclaringProperties(collection).values());
1033
+ }
1034
+ /**
885
1035
  * The child views of `collection` as bare collections.
886
1036
  *
887
1037
  * The flattened view of {@link getEntityChildViews}, for navigation code that
@@ -931,9 +1081,9 @@ function isKeywordAt(upper, i, keyword) {
931
1081
  *
932
1082
  * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
933
1083
  * `AND` inside
934
- * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
1084
+ * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
935
1085
  * split the expression, and re-emitting the halves produced
936
- * `(EXISTS (...) AND m.user_id = auth.uid())`
1086
+ * `(EXISTS (...) AND m.user_id = rebase.uid())`
937
1087
  * where `m` is no longer in scope — SQL that Postgres rejects outright with
938
1088
  * "missing FROM-clause entry for table". Returning null instead keeps such a
939
1089
  * clause as a `raw` expression, which round-trips verbatim.
@@ -1007,15 +1157,15 @@ function stripOuterParens(sql) {
1007
1157
  }
1008
1158
  }
1009
1159
  function sqlToPolicy(sql) {
1010
- const trimmed = stripOuterParens(sql.trim());
1160
+ const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
1011
1161
  if (trimmed.toLowerCase() === "true") return policy.true();
1012
1162
  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);
1163
+ const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
1014
1164
  if (overlapMatch) {
1015
1165
  const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
1016
1166
  return policy.rolesOverlap(roles);
1017
1167
  }
1018
- const containMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
1168
+ const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
1019
1169
  if (containMatch) {
1020
1170
  const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
1021
1171
  return policy.rolesContain(roles);
@@ -1031,32 +1181,60 @@ function sqlToPolicy(sql) {
1031
1181
  const right = parseOperand(rightStr.trim());
1032
1182
  if (left && right) return policy.compare(left, op === "=" ? "eq" : "neq", right);
1033
1183
  }
1034
- return policy.raw(sql);
1184
+ return policy.raw(trimmed);
1035
1185
  }
1036
1186
  /**
1037
- * Literals from other BaaS platforms that people compare `auth.uid()` against
1187
+ * Literals from other BaaS platforms that people compare `rebase.uid()` against
1038
1188
  * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
1039
1189
  * `pgRoles`, one surface over: the same muscle memory inside a `using:` string
1040
1190
  * is the more dangerous spelling, because it inverts a rule instead of
1041
1191
  * emptying a table.
1042
1192
  */
1043
- var FOREIGN_CONVENTION_UIDS = {
1044
- anon: "Supabase",
1045
- authenticated: "Supabase",
1046
- service_role: "Supabase"
1047
- };
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;
1193
+ /**
1194
+ * A `Map`, not an object literal.
1195
+ *
1196
+ * As `Record<string, string>` this was indexed with a literal taken straight
1197
+ * out of a policy, so every key on `Object.prototype` answered: a rule
1198
+ * comparing `rebase.uid()` to `"valueOf"`, `"toString"`, `"constructor"` or
1199
+ * `"hasOwnProperty"` found a truthy "platform" and reported an anonymous-grant
1200
+ * risk that does not exist — with the matched function interpolated into the
1201
+ * explanation as the platform's name. A security warning that fires on
1202
+ * innocent input is worse than none: it is what teaches people to skip the
1203
+ * warnings that are real.
1204
+ *
1205
+ * Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,
1206
+ * `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain
1207
+ * object. Found by a property test, on the input `"valueOf"`.
1208
+ */
1209
+ var FOREIGN_CONVENTION_UIDS = /* @__PURE__ */ new Map([
1210
+ ["anon", "Supabase"],
1211
+ ["authenticated", "Supabase"],
1212
+ ["service_role", "Supabase"]
1213
+ ]);
1214
+ /**
1215
+ * The same foreign literals, as a pattern for SQL that could not be parsed
1216
+ * back into structure.
1217
+ */
1218
+ var FOREIGN_UID_LITERAL_SQL = new RegExp(String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`, "i");
1219
+ /**
1220
+ * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
1221
+ *
1222
+ * Both schema spellings, because this runs over policy bodies read back from a
1223
+ * database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
1224
+ * A security check that stops recognising a dangerous clause because the
1225
+ * framework renamed a function is a check that silently turns off.
1226
+ */
1227
+ var UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
1050
1228
  /**
1051
1229
  * Find clauses that read as "signed-in users only" but admit anonymous callers.
1052
1230
  *
1053
- * Both spellings come from the same place — Supabase, where `auth.uid()` really
1054
- * is NULL for an anonymous request. Rebase substitutes
1231
+ * Both spellings come from the same place — Supabase, where its own `auth.uid()`
1232
+ * really is NULL for an anonymous request. Rebase substitutes
1055
1233
  * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
1056
1234
  * is how the trusted *server* context is recognised), so:
1057
1235
  *
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
1236
+ * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
1237
+ * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
1060
1238
  * other. This one is not hypothetical and was not only a foreign habit:
1061
1239
  * rebase's own request path reported `'anon'` while everything that compiled
1062
1240
  * or checked a policy used `'anonymous'`, so whichever literal an author
@@ -1084,17 +1262,27 @@ function findAnonymousGrants(expr) {
1084
1262
  case "existsIn":
1085
1263
  visit(e.where);
1086
1264
  return;
1087
- case "raw":
1265
+ case "raw": {
1088
1266
  if (UID_NOT_NULL.test(e.sql)) found.push({
1089
1267
  pattern: "uid-not-null",
1090
1268
  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".`
1269
+ 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
1270
  });
1271
+ const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);
1272
+ if (foreign) {
1273
+ const literal = foreign[1];
1274
+ found.push({
1275
+ pattern: "foreign-uid-literal",
1276
+ detail: literal,
1277
+ explanation: `'${literal}' is a ${FOREIGN_CONVENTION_UIDS.get(literal)} convention. Rebase reports an anonymous request as '${ANONYMOUS_USER_ID}', so comparing against '${literal}' passes for every caller. Use \`condition: policy.authenticated()\` to mean "signed in".`
1278
+ });
1279
+ }
1093
1280
  return;
1281
+ }
1094
1282
  case "compare": {
1095
1283
  const literal = [e.left, e.right].find((o) => o.kind === "literal");
1096
1284
  if (!(e.left.kind === "authUid" || e.right.kind === "authUid") || typeof literal?.value !== "string") return;
1097
- const platform = FOREIGN_CONVENTION_UIDS[literal.value];
1285
+ const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);
1098
1286
  if (!platform) return;
1099
1287
  found.push({
1100
1288
  pattern: "foreign-uid-literal",
@@ -1110,12 +1298,43 @@ function findAnonymousGrants(expr) {
1110
1298
  return found;
1111
1299
  }
1112
1300
  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);
1301
+ if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
1302
+ const literal = parseSingleQuoted(str);
1303
+ if (literal !== null) return policy.literal(literal);
1304
+ if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
1305
+ if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
1306
+ if (/^true$/i.test(str)) return policy.literal(true);
1307
+ if (/^false$/i.test(str)) return policy.literal(false);
1308
+ if (/^null$/i.test(str)) return policy.literal(null);
1309
+ if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
1117
1310
  return null;
1118
1311
  }
1312
+ /**
1313
+ * Decode a single-quoted SQL literal, or null when `str` is not exactly one.
1314
+ *
1315
+ * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
1316
+ * operator, not one literal whose body contains a quote, and a regex anchored
1317
+ * on the outer quotes would happily read it as the latter. Every interior quote
1318
+ * must therefore be part of a `''` pair.
1319
+ */
1320
+ function parseSingleQuoted(str) {
1321
+ if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
1322
+ const body = str.slice(1, -1);
1323
+ let out = "";
1324
+ for (let i = 0; i < body.length; i++) {
1325
+ if (body[i] !== "'") {
1326
+ out += body[i];
1327
+ continue;
1328
+ }
1329
+ if (body[i + 1] === "'") {
1330
+ out += "'";
1331
+ i++;
1332
+ continue;
1333
+ }
1334
+ return null;
1335
+ }
1336
+ return out;
1337
+ }
1119
1338
  //#endregion
1120
1339
  //#region src/util/policy/securityRuleToConditions.ts
1121
1340
  /**
@@ -1192,12 +1411,12 @@ function compile(expr, scope) {
1192
1411
  const rightSql = castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
1193
1412
  return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
1194
1413
  }
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";
1414
+ case "rolesOverlap": return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
1415
+ case "rolesContain": return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
1416
+ case "authenticated": return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
1417
+ case "serverContext": return `${RLS_UID_SQL} IS NULL`;
1199
1418
  case "existsIn": return compileExistsIn(expr, scope);
1200
- case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
1419
+ case "raw": return rewriteLegacyRlsFunctions(expr.sql).replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
1201
1420
  }
1202
1421
  }
1203
1422
  /**
@@ -1234,8 +1453,8 @@ function operandToSql(operand, scope) {
1234
1453
  case "field": return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
1235
1454
  case "outerField": return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
1236
1455
  case "literal": return quoteLiteral(operand.value);
1237
- case "authUid": return "auth.uid()";
1238
- case "authRoles": return "string_to_array(auth.roles(), ',')";
1456
+ case "authUid": return RLS_UID_SQL;
1457
+ case "authRoles": return `string_to_array(${RLS_ROLES_SQL}, ',')`;
1239
1458
  }
1240
1459
  }
1241
1460
  /**
@@ -1340,11 +1559,7 @@ function evaluateCompare(op, left, right, ctx) {
1340
1559
  if (!l.known || !r.known) return "unknown";
1341
1560
  const a = l.value;
1342
1561
  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
- }
1562
+ if (a === null || b === null) return "unknown";
1348
1563
  if (op === "eq") return a === b;
1349
1564
  if (op === "neq") return a !== b;
1350
1565
  if (typeof a === "string" && typeof b === "string") {
@@ -1391,11 +1606,11 @@ function ruleApplies(rule, targetOperation) {
1391
1606
  * in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
1392
1607
  * INSERT/UPDATE; both must pass for UPDATE.
1393
1608
  */
1394
- function evaluateRuleForOperation(rule, ctx, targetOperation) {
1609
+ function evaluateRuleForOperation(rule, ctx, targetOperation, clauses = "both") {
1395
1610
  const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
1396
1611
  const clause = (expr) => expr === null ? false : evaluatePolicy(expr, ctx);
1397
- const needsUsing = targetOperation !== "insert";
1398
- const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
1612
+ const needsUsing = targetOperation !== "insert" && clauses !== "withCheck";
1613
+ const needsWithCheck = (targetOperation === "insert" || targetOperation === "update") && clauses !== "using";
1399
1614
  const results = [];
1400
1615
  if (needsUsing) results.push(clause(usingExpr));
1401
1616
  if (needsWithCheck) results.push(clause(withCheckExpr));
@@ -1411,13 +1626,25 @@ function resolveTriState(value, onUnknown) {
1411
1626
  * the same model compiled to Postgres RLS DDL, so the decision matches database
1412
1627
  * enforcement for every non-raw rule.
1413
1628
  *
1629
+ * Engine-independent by design. `securityRules` are a declaration about the
1630
+ * data, not about Postgres: the engine decides *who* enforces them (Postgres
1631
+ * compiles them to RLS DDL, a document driver applies them in-process), never
1632
+ * *whether* they hold. Gating this function on the engine's `supportsRLS`
1633
+ * capability is what made every `{ onUnknown: "deny" }` call site in the Mongo
1634
+ * driver return `true` before it evaluated anything — and it did so only for
1635
+ * collections that spelled their engine out, so declaring `engine: "mongodb"`
1636
+ * was what switched authorization off.
1637
+ *
1414
1638
  * @param options.onUnknown how to treat rules that cannot be decided
1415
1639
  * client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
1416
1640
  * for optimistic UI gating; enforcement callers should pass `"deny"`.
1641
+ * @param options.clauses which half of each rule to evaluate. See
1642
+ * {@link PolicyClauses}; defaults to `"both"`.
1417
1643
  */
1418
1644
  function checkOperation(collection, authContext, entity, targetOperation, options) {
1419
1645
  const onUnknown = options?.onUnknown ?? "allow";
1420
- const securityRules = getDataSourceCapabilities(collection.engine).supportsRLS ? collection.securityRules : void 0;
1646
+ const clauses = options?.clauses ?? "both";
1647
+ const securityRules = collection.securityRules;
1421
1648
  if (!securityRules || securityRules.length === 0) return true;
1422
1649
  const applicableRules = securityRules.filter((r) => ruleApplies(r, targetOperation));
1423
1650
  if (applicableRules.length === 0) return false;
@@ -1431,7 +1658,7 @@ function checkOperation(collection, authContext, entity, targetOperation, option
1431
1658
  let hasPermissive = false;
1432
1659
  for (const rule of applicableRules) {
1433
1660
  const mode = rule.mode || "permissive";
1434
- const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
1661
+ const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation, clauses), onUnknown);
1435
1662
  if (mode === "restrictive") {
1436
1663
  if (!passed) {
1437
1664
  deniedByRestrictive = true;
@@ -1627,8 +1854,14 @@ var buildPropertyCallbacks = (properties) => {
1627
1854
  * Rebase's enforcement model is unified: authenticated (user-context) requests
1628
1855
  * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
1629
1856
  * 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.
1857
+ * authorization model. The server context (auth flows, migrations, raw
1858
+ * `rebase.sql`) runs as the owner and bypasses RLS.
1859
+ *
1860
+ * `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
1861
+ * `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
1862
+ * other caller and clears the baseline below through the *admin* arm, not the
1863
+ * server arm. Which is why `disableDefaultPolicies` plus a lone
1864
+ * `policy.serverContext()` rule locks it out too.
1632
1865
  *
1633
1866
  * Because RLS default-denies, every collection is **locked by default**: with
1634
1867
  * no rules, only the server context and admins can touch it. The generator
@@ -1686,11 +1919,28 @@ function getIdPropertyName$1(collection) {
1686
1919
  *
1687
1920
  * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
1688
1921
  */
1922
+ /**
1923
+ * The restrictive write gate for an auth collection.
1924
+ *
1925
+ * Restrictive, so it is ANDed with everything else: whatever an author's
1926
+ * permissive rules allow, a write to this table still has to satisfy this too.
1927
+ * It is the only thing standing between "users may edit their own row" and
1928
+ * "users may grant themselves any role".
1929
+ */
1930
+ function adminWriteGate(tableName) {
1931
+ return {
1932
+ name: `${tableName}_require_admin_write`,
1933
+ mode: "restrictive",
1934
+ operations: [...DEFAULT_GUARDED_OPS],
1935
+ condition: SERVER_OR_ADMIN_EXPR$1,
1936
+ check: SERVER_OR_ADMIN_EXPR$1
1937
+ };
1938
+ }
1689
1939
  function getEffectiveSecurityRules(collection) {
1690
1940
  const explicit = [...collection.securityRules ?? []];
1691
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return explicit;
1692
1941
  const tableName = getTableName(collection);
1693
1942
  const injected = [];
1943
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
1694
1944
  injected.push({
1695
1945
  name: `${tableName}_default_admin_read`,
1696
1946
  operations: ["select"],
@@ -1708,13 +1958,7 @@ function getEffectiveSecurityRules(collection) {
1708
1958
  operations: ["select"],
1709
1959
  condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
1710
1960
  });
1711
- injected.push({
1712
- name: `${tableName}_require_admin_write`,
1713
- mode: "restrictive",
1714
- operations: [...DEFAULT_GUARDED_OPS],
1715
- condition: SERVER_OR_ADMIN_EXPR$1,
1716
- check: SERVER_OR_ADMIN_EXPR$1
1717
- });
1961
+ injected.push(adminWriteGate(tableName));
1718
1962
  }
1719
1963
  return [...explicit, ...injected];
1720
1964
  }
@@ -1730,7 +1974,7 @@ function getEffectiveSecurityRules(collection) {
1730
1974
  * DDL, which policies are injected and how to take them off.
1731
1975
  */
1732
1976
  function getInjectedSecurityRules(collection) {
1733
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return [];
1977
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection(collection) ? [adminWriteGate(getTableName(collection))] : [];
1734
1978
  const explicitCount = (collection.securityRules ?? []).length;
1735
1979
  return getEffectiveSecurityRules(collection).slice(explicitCount);
1736
1980
  }
@@ -1996,9 +2240,14 @@ function registerConditionOperations() {
1996
2240
  operationsRegistered = true;
1997
2241
  }
1998
2242
  /**
1999
- * Evaluate a JSON Logic rule against the given context.
2243
+ * Evaluate a condition against the given context.
2244
+ *
2245
+ * A condition may be stated as a literal instead of a rule — `hidden: true`
2246
+ * rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
2247
+ * answer, so it is returned rather than handed to the evaluator.
2000
2248
  */
2001
2249
  function evaluateCondition(rule, context) {
2250
+ if (typeof rule === "boolean") return rule;
2002
2251
  registerConditionOperations();
2003
2252
  return jsonLogic.apply(rule, context);
2004
2253
  }
@@ -2216,12 +2465,14 @@ function buildCollectionFromTableMetadata(tableName, metadata) {
2216
2465
  if (property) {
2217
2466
  const propRecord = property;
2218
2467
  Object.keys(propRecord).forEach((key) => propRecord[key] === void 0 && delete propRecord[key]);
2219
- properties[column.column_name] = property;
2220
- propertiesOrder.push(column.column_name);
2468
+ const key = firstFreeKey([toWireKey(column.column_name), column.column_name], { has: (candidate) => candidate in properties });
2469
+ if (key !== column.column_name) propRecord.columnName = column.column_name;
2470
+ properties[key] = property;
2471
+ propertiesOrder.push(key);
2221
2472
  }
2222
2473
  }
2223
2474
  if (metadata.foreignKeys) for (const fk of metadata.foreignKeys) {
2224
- const relName = fk.column_name.endsWith("_id") ? fk.column_name.substring(0, fk.column_name.length - 3) : fk.column_name;
2475
+ const relName = toWireKey(fk.column_name.endsWith("_id") ? fk.column_name.substring(0, fk.column_name.length - 3) : fk.column_name);
2225
2476
  relations.push({
2226
2477
  id: fk.column_name,
2227
2478
  relationName: relName,
@@ -2317,6 +2568,142 @@ function resolveStringColumnLength(prop) {
2317
2568
  return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 255;
2318
2569
  }
2319
2570
  //#endregion
2571
+ //#region src/util/internal-tables.ts
2572
+ /**
2573
+ * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
2574
+ * end-user role away from them.
2575
+ *
2576
+ * ## Why this exists
2577
+ *
2578
+ * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
2579
+ * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
2580
+ * in the schemas a project uses — including `rebase`, because a project's own
2581
+ * collections are allowed to live there (the scaffold puts `users` there). It
2582
+ * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
2583
+ * migrating role inherits the same grant.
2584
+ *
2585
+ * Every framework-internal table is created later: auth's tables come up during
2586
+ * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
2587
+ * job registers, `idempotency_keys` on the first request that carries a key. So
2588
+ * they all inherited full DML for the end-user role — and none of them enables
2589
+ * row-level security, because none of them is a collection with
2590
+ * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
2591
+ * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
2592
+ * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
2593
+ * `rebase.api_keys` (including its `admin` flag), and insert into
2594
+ * `rebase.app_config`.
2595
+ *
2596
+ * Nothing routes a user-context query at those tables today, so this was not
2597
+ * reachable over the API. That is the wrong thing to depend on: the documented
2598
+ * model is that RLS is the authorization boundary, and these tables sat outside
2599
+ * it. The boundary is now a privilege boundary instead — the role simply cannot
2600
+ * address them.
2601
+ *
2602
+ * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
2603
+ *
2604
+ * RLS with no policy denies every row, which is the same outcome, but it is the
2605
+ * *weaker* statement: it leaves the grant in place, so a later policy — or a
2606
+ * `FORCE` flag cleared by some future migration — reopens the table. There is no
2607
+ * row of `refresh_tokens` any end user should ever reach, so the honest encoding
2608
+ * is "this role has no privilege here at all". It also keeps the owner
2609
+ * connection (which auth actually runs on) completely unaffected.
2610
+ *
2611
+ * ## Keeping it true
2612
+ *
2613
+ * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
2614
+ * "platform" schema — and its `rls-disabled` check fires on exactly the
2615
+ * condition this module removes: RLS off *and* a DML grant to a reachable role.
2616
+ * So a table added here without a revoke is caught by `pnpm rls:check`, not by
2617
+ * someone re-reading this file.
2618
+ */
2619
+ /**
2620
+ * The Postgres role authenticated requests run as.
2621
+ *
2622
+ * Defined here rather than in the Postgres driver because both the driver (which
2623
+ * provisions the role) and this module (which revokes on its behalf) need it,
2624
+ * and a second spelling of a role name is a silent no-op waiting to happen.
2625
+ */
2626
+ var REBASE_USER_ROLE = "rebase_user";
2627
+ /**
2628
+ * Framework-internal table names, unqualified.
2629
+ *
2630
+ * Deliberately NOT including `users`: the auth user table is also a collection,
2631
+ * with `securityRules`, RLS enabled and policies applied. Users read their own
2632
+ * row through it — revoking there would break sign-in.
2633
+ *
2634
+ * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
2635
+ * because `db migrate apply` passes `--revisions-schema rebase`.
2636
+ */
2637
+ var REBASE_INTERNAL_TABLES = [
2638
+ "user_identities",
2639
+ "refresh_tokens",
2640
+ "password_reset_tokens",
2641
+ "magic_link_tokens",
2642
+ "mfa_factors",
2643
+ "mfa_challenges",
2644
+ "recovery_codes",
2645
+ "app_config",
2646
+ "schema_meta",
2647
+ "api_keys",
2648
+ "cron_logs",
2649
+ "cron_claims",
2650
+ "idempotency_keys",
2651
+ "entity_history",
2652
+ "branches",
2653
+ "channel_messages",
2654
+ "channel_cursors",
2655
+ "channel_presence",
2656
+ "atlas_schema_revisions"
2657
+ ];
2658
+ /** Postgres identifiers this module is willing to interpolate. */
2659
+ var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
2660
+ /**
2661
+ * A single statement that takes every privilege on `schema.table` away from the
2662
+ * end-user role.
2663
+ *
2664
+ * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
2665
+ * happen in practice:
2666
+ *
2667
+ * - the role does not exist when the connection is unprivileged (Rebase then
2668
+ * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
2669
+ * missing role is an error, not a no-op;
2670
+ * - the table may not exist yet — `cron_logs` never appears in a project with
2671
+ * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
2672
+ *
2673
+ * One command, so it is safe on handles that speak the extended query protocol
2674
+ * and reject multi-statement strings.
2675
+ */
2676
+ function revokeInternalTableSql(schema, table) {
2677
+ if (!SAFE_IDENTIFIER.test(schema)) throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
2678
+ if (!SAFE_IDENTIFIER.test(table)) throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
2679
+ const qualified = `"${schema}"."${table}"`;
2680
+ return `
2681
+ DO $rebase_revoke$
2682
+ BEGIN
2683
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
2684
+ AND to_regclass('${qualified}') IS NOT NULL THEN
2685
+ EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
2686
+ END IF;
2687
+ END
2688
+ $rebase_revoke$;
2689
+ `.trim();
2690
+ }
2691
+ /**
2692
+ * Revoke on every internal table in `schema`, one statement at a time.
2693
+ *
2694
+ * Best-effort per table: a connection that does not own one of them (a
2695
+ * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
2696
+ * that must not take down a boot. The caller decides how loud to be — `onError`
2697
+ * exists so the driver can warn without this module importing a logger.
2698
+ */
2699
+ async function revokeInternalTableAccess(execute, schema, options) {
2700
+ for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) try {
2701
+ await execute(revokeInternalTableSql(schema, table));
2702
+ } catch (error) {
2703
+ options?.onError?.(table, error);
2704
+ }
2705
+ }
2706
+ //#endregion
2320
2707
  //#region src/data/resolveDataSource.ts
2321
2708
  /**
2322
2709
  * Build a keyed registry from a list of {@link DataSourceDefinition}s.
@@ -2359,6 +2746,46 @@ function resolveDataSource(collection, registry) {
2359
2746
  capabilities: getDataSourceCapabilities(engine)
2360
2747
  };
2361
2748
  }
2749
+ /**
2750
+ * Does a SQL toolchain own this collection's storage?
2751
+ *
2752
+ * "Owns the storage" means: something generates a table for it, pushes that
2753
+ * table to a database, plans its RLS policies, and reports it as drifted when
2754
+ * the two disagree. That is true of a Postgres collection and false of a
2755
+ * Firestore or MongoDB one, whose documents live in a store Rebase never
2756
+ * migrates — and the two were never told apart. Every stage of the SQL
2757
+ * toolchain took "the collections" to mean *all* of them, so a Firestore
2758
+ * collection declared next to the Postgres ones got a `pgTable` in the
2759
+ * generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
2760
+ * `db push` include list — where its name shielding a same-named real table
2761
+ * from Atlas's exclude list is the one that can lose data.
2762
+ *
2763
+ * The answer is the resolved engine's {@link DataSourceCapabilities}, not a
2764
+ * name check: an engine registered through `registerDataSourceCapabilities`
2765
+ * gets the same treatment as the built-in ones.
2766
+ *
2767
+ * Deliberately answers **true** for an engine nobody has heard of. Build-time
2768
+ * tooling (the CLI, the schema generator) has no data-source registry to
2769
+ * resolve a `dataSource` key against, so an unknown key resolves to an unknown
2770
+ * engine — and the cost of the two mistakes is not symmetric. Wrongly
2771
+ * including a collection generates a table nothing writes to; wrongly excluding
2772
+ * one silently stops generating a table the app is serving from. Declare
2773
+ * `engine` on a collection that is not SQL-backed and this is exact.
2774
+ */
2775
+ function isRelationalCollection(collection, registry) {
2776
+ return getDataSourceCapabilities(collection?.engine ?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : void 0)).supportsRelations;
2777
+ }
2778
+ /**
2779
+ * The subset of `collections` a SQL toolchain owns — see
2780
+ * {@link isRelationalCollection}.
2781
+ *
2782
+ * Every stage that generates SQL from collections starts by calling this, so
2783
+ * the rule lives in one place rather than being re-decided per generator. It
2784
+ * keeps the input order.
2785
+ */
2786
+ function relationalCollections(collections, registry) {
2787
+ return collections.filter((collection) => isRelationalCollection(collection, registry));
2788
+ }
2362
2789
  //#endregion
2363
2790
  //#region src/collections/CollectionRegistry.ts
2364
2791
  var CollectionRegistry = class {
@@ -2788,8 +3215,24 @@ var QueryBuilder = class {
2788
3215
  /**
2789
3216
  * Set a free-text search string if supported by the backend.
2790
3217
  */
2791
- search(searchString) {
3218
+ search(searchString, options) {
2792
3219
  this.params.searchString = searchString;
3220
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
3221
+ return this;
3222
+ }
3223
+ /**
3224
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
3225
+ *
3226
+ * Postgres only, over a property declared as `type: "vector"`. Rows come
3227
+ * back with a `_distance`; `where` filters before the ordering.
3228
+ */
3229
+ vectorSearch(property, vector, options) {
3230
+ this.params.vectorSearch = {
3231
+ property,
3232
+ vector,
3233
+ ...options?.distance !== void 0 && { distance: options.distance },
3234
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
3235
+ };
2793
3236
  return this;
2794
3237
  }
2795
3238
  /**
@@ -2861,6 +3304,30 @@ var RebasePaginationError = class RebasePaginationError extends Error {
2861
3304
  Object.setPrototypeOf(this, RebasePaginationError.prototype);
2862
3305
  }
2863
3306
  };
3307
+ /**
3308
+ * Resolve `limit`/`offset`/`page` into the window a read will actually use.
3309
+ *
3310
+ * Lives here, next to the walk, for the reason at the top of this file: every
3311
+ * transport has to mean the same thing by "page two". Four of them did not —
3312
+ * the REST layer strode by {@link DEFAULT_LIST_LIMIT}, the local-first
3313
+ * evaluator by {@link DEFAULT_PAGE_SIZE}, the in-process accessor by 20, and
3314
+ * the published type documented a fourth number. Pages that overlap or skip
3315
+ * rows are the mildest of those outcomes.
3316
+ *
3317
+ * `page` wins over `offset`, as {@link FindParams} documents. `driverOffset`
3318
+ * is the value to hand a driver: it stays `undefined` when the caller named no
3319
+ * offset, because keyset pagination seeks with a `where` clause and must not
3320
+ * look like it is paging by offset.
3321
+ */
3322
+ function resolveFindWindow(params) {
3323
+ const limit = params?.limit ?? DEFAULT_LIST_LIMIT;
3324
+ const offset = params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0;
3325
+ return {
3326
+ limit,
3327
+ offset,
3328
+ driverOffset: params?.page != null ? offset : params?.offset
3329
+ };
3330
+ }
2864
3331
  function normalizePageSize(raw) {
2865
3332
  if (raw === void 0 || !Number.isFinite(raw)) return 200;
2866
3333
  return Math.max(1, Math.floor(raw));
@@ -2992,8 +3459,10 @@ function createPaginationHelpers(find, label) {
2992
3459
  * metadata, so type coercion is the responsibility of the server-side data
2993
3460
  * driver which has access to the collection schema.
2994
3461
  *
2995
- * Commas inside list values are backslash-escaped (`\,`), and literal
2996
- * backslashes are escaped as `\\`.
3462
+ * Structural characters inside a value are backslash-escaped: `,` → `\,`,
3463
+ * `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
3464
+ * deliberately conservative — only those four sequences are decoded, so a
3465
+ * backslash that arrives unescaped from an older client survives intact.
2997
3466
  *
2998
3467
  * @module
2999
3468
  */
@@ -3011,22 +3480,55 @@ function stringifyValue(value) {
3011
3480
  return String(value);
3012
3481
  }
3013
3482
  /**
3014
- * Escape a single list item for the wire format.
3015
- * `\` → `\\`, `,` → `\,`
3483
+ * Characters that carry structure in the wire format and must therefore be
3484
+ * escaped inside a value: the separator, the group delimiters, and the escape
3485
+ * character itself.
3486
+ *
3487
+ * Parentheses are here because `and(...)`/`or(...)` groups are parsed by
3488
+ * tracking paren depth. A value containing one is not merely ambiguous, it
3489
+ * moves where the parser thinks the group ends.
3490
+ */
3491
+ var WIRE_SPECIALS = /[\\,()]/g;
3492
+ /**
3493
+ * Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
3494
+ * `)` → `\)`.
3495
+ */
3496
+ /**
3497
+ * The wire spelling of an empty list.
3498
+ *
3499
+ * A lone backslash: unproducible by {@link escapeWireValue}, which doubles
3500
+ * every backslash it emits, so it cannot collide with any real item.
3016
3501
  */
3017
- function escapeListItem(value) {
3018
- return value.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
3502
+ var EMPTY_LIST_TOKEN = "\\";
3503
+ function escapeWireValue(value) {
3504
+ return value.replace(WIRE_SPECIALS, (ch) => `\\${ch}`);
3019
3505
  }
3020
3506
  /**
3021
- * Unescape a single list item from the wire format.
3022
- * `\\` → `\`, `\,` → `,`
3507
+ * Unescape a wire-format value.
3508
+ *
3509
+ * **Conservative**, and deliberately so: only the four sequences
3510
+ * {@link escapeWireValue} actually produces are decoded. A backslash followed
3511
+ * by anything else is left exactly as it is.
3512
+ *
3513
+ * This used to consume the backslash before *any* character, which is
3514
+ * indistinguishable for anything this codec emitted — it only ever emits those
3515
+ * four — but not for input arriving from elsewhere. A client on an older
3516
+ * release sends a Windows path or a LIKE pattern with a literal `C:\x`
3517
+ * unescaped, and greedy unescaping silently turned it into `C:x`, changing
3518
+ * which rows matched. Decoding only what the encoder can produce makes the two
3519
+ * directions agree across versions.
3023
3520
  */
3024
- function unescapeListItem(value) {
3521
+ function unescapeWireValue(value) {
3025
3522
  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];
3523
+ for (let i = 0; i < value.length; i++) {
3524
+ const next = value[i + 1];
3525
+ if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
3526
+ result += next;
3527
+ i++;
3528
+ continue;
3529
+ }
3530
+ result += value[i];
3531
+ }
3030
3532
  return result;
3031
3533
  }
3032
3534
  /**
@@ -3044,14 +3546,235 @@ function splitListItems(inner) {
3044
3546
  current += inner[i] + inner[i + 1];
3045
3547
  i++;
3046
3548
  } else if (inner[i] === ",") {
3047
- items.push(unescapeListItem(current));
3549
+ items.push(unescapeWireValue(current));
3048
3550
  current = "";
3049
3551
  } else current += inner[i];
3050
- items.push(unescapeListItem(current));
3552
+ items.push(unescapeWireValue(current));
3051
3553
  return items;
3052
3554
  }
3053
- var REST_OP_LOOKUP = REST_TO_CANONICAL;
3054
- var CANONICAL_OP_LOOKUP = CANONICAL_TO_REST;
3555
+ /**
3556
+ * Split a group body on commas at paren depth 0, honouring escapes.
3557
+ *
3558
+ * The escape-awareness is the point. The splitter used to track only paren
3559
+ * depth, so a comma inside a scalar value ended a condition:
3560
+ * `or(name.eq.Doe, John,age.gte.18)` parsed as *three* conditions, the middle
3561
+ * one a fabricated `" John" == true`. On an `or` that widens the result set,
3562
+ * and nothing anywhere reports an error — the query simply stops meaning what
3563
+ * the caller wrote.
3564
+ */
3565
+ function splitGroupItems(inner) {
3566
+ const parts = [];
3567
+ let depth = 0;
3568
+ let start = 0;
3569
+ for (let i = 0; i < inner.length; i++) {
3570
+ const ch = inner[i];
3571
+ if (ch === "\\" && i + 1 < inner.length) {
3572
+ i++;
3573
+ continue;
3574
+ }
3575
+ if (ch === "(") depth++;
3576
+ else if (ch === ")") depth--;
3577
+ else if (ch === "," && depth === 0) {
3578
+ parts.push(inner.slice(start, i));
3579
+ start = i + 1;
3580
+ }
3581
+ }
3582
+ parts.push(inner.slice(start));
3583
+ return parts;
3584
+ }
3585
+ /**
3586
+ * Operator tables as `Map`s, because the key comes off the wire.
3587
+ *
3588
+ * Indexed as plain objects, every `Object.prototype` member answered: a query
3589
+ * string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
3590
+ * and `deserializeTuple` returned it *as the operator*, so a function object
3591
+ * travelled on into the compilers in place of a `WhereFilterOp`. The guard one
3592
+ * line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
3593
+ * and does not: `Object.prototype` is not unknown to a plain object.
3594
+ *
3595
+ * Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
3596
+ * `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
3597
+ */
3598
+ var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
3599
+ var CANONICAL_OP_LOOKUP = new Map(Object.entries(CANONICAL_TO_REST));
3600
+ /** The operator spellings a rejection lists back to the caller. */
3601
+ var VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
3602
+ /**
3603
+ * A filter condition named an operator this dialect does not have.
3604
+ *
3605
+ * ## Why this throws, rather than returning a typed rejection
3606
+ *
3607
+ * `deserializeFilter` is the *shared* codec: the REST ingress
3608
+ * (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
3609
+ * admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
3610
+ * follow.
3611
+ *
3612
+ * - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
3613
+ * depend on `@rebasepro/server` (the dependency runs the other way), and a
3614
+ * browser client has no error handler to render an `ApiError` with. So the
3615
+ * rejection is this plain `Error` subclass, whose `message` reads correctly
3616
+ * wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
3617
+ * - It cannot be a returned rejection *value*. Every caller assigns the result
3618
+ * straight into a query it is about to run; a sentinel that none of them
3619
+ * check would be ignored, which is exactly the silently-wrong-filter failure
3620
+ * this exists to stop. Throwing is also what this file already does for the
3621
+ * sibling cases — `serializeTuple` on an unknown canonical operator,
3622
+ * `deserializeLogicalCondition` past the nesting bound — and the REST parser
3623
+ * already converts the latter into a 400.
3624
+ *
3625
+ * `statusCode`, `code` and `details` are carried as fields because the server's
3626
+ * Hono error handler duck-types those off any thrown error: a decode path that
3627
+ * forgets to convert still answers 400 with the canonical envelope instead of a
3628
+ * 500 that says "An unexpected error occurred". `query-parser.ts` converts
3629
+ * explicitly all the same — that is the path the contract is stated on, and an
3630
+ * incidental 400 is not a contract.
3631
+ */
3632
+ var UnknownFilterOperatorError = class extends Error {
3633
+ /** The field the condition was written against. */
3634
+ field;
3635
+ /** The operator string as it arrived, verbatim. */
3636
+ operator;
3637
+ /** Every operator this dialect accepts, in canonical spelling. */
3638
+ validOperators = ALL_WHERE_FILTER_OPS;
3639
+ /** See the class docblock: read by the server's error handler. */
3640
+ statusCode = 400;
3641
+ code = "UNKNOWN_FILTER_OPERATOR";
3642
+ details;
3643
+ constructor(field, operator) {
3644
+ super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
3645
+ this.name = "UnknownFilterOperatorError";
3646
+ this.field = field;
3647
+ this.operator = operator;
3648
+ this.details = {
3649
+ field,
3650
+ operator,
3651
+ validOperators: ALL_WHERE_FILTER_OPS
3652
+ };
3653
+ }
3654
+ };
3655
+ /**
3656
+ * Two to three characters of ASCII punctuation and nothing else — the shape
3657
+ * every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
3658
+ * one a column value effectively never has.
3659
+ *
3660
+ * Two characters minimum on purpose. A *single* punctuation character is a
3661
+ * perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
3662
+ * condition — and the only single-character operator anyone actually mistypes
3663
+ * is `=`, which is named separately below. `<` and `>` need no special case:
3664
+ * they are real operators and resolve.
3665
+ */
3666
+ var SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
3667
+ /** Lowercase, strip everything that is not a letter or digit. */
3668
+ function normalizeOperatorName(op) {
3669
+ return op.toLowerCase().replace(/[^a-z0-9]/g, "");
3670
+ }
3671
+ /**
3672
+ * Every real operator name with its case and separators removed, so a
3673
+ * respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
3674
+ * recognised as an attempt at an operator rather than read as a value.
3675
+ *
3676
+ * These are rejected rather than accepted: admitting a second spelling of an
3677
+ * operator would leave two wire spellings of one thing, and the rejection
3678
+ * message names the one that works.
3679
+ */
3680
+ var RESPELLED_OPERATORS = new Set([...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName));
3681
+ /**
3682
+ * Operator names *other* query dialects use, which this one does not have.
3683
+ *
3684
+ * This list is curated, and deliberately so. For a word-shaped string there is
3685
+ * no rule that separates "an operator the caller guessed" from "a value that
3686
+ * happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
3687
+ * `in` list, so the codec cannot simply refuse every unrecognised word in
3688
+ * position 0. The line is therefore drawn by name, and only around names whose
3689
+ * use as an operator is far more likely than their use as one of two sibling
3690
+ * values. `contains` is the motivating case — the first thing a developer
3691
+ * reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
3692
+ *
3693
+ * Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
3694
+ * are left off: as operators they are rare, and as enum values they are common.
3695
+ * Everywhere else the tie goes to *rejecting*, because a 400 naming the
3696
+ * supported set costs the caller one round trip, and the alternative — which is
3697
+ * what every name on this list used to produce — is a query that runs, returns
3698
+ * rows, and is wrong.
3699
+ */
3700
+ var NEAR_MISS_OPERATORS = /* @__PURE__ */ new Set([
3701
+ "contains",
3702
+ "notcontains",
3703
+ "doesnotcontain",
3704
+ "doesnotcontains",
3705
+ "includes",
3706
+ "notincludes",
3707
+ "startswith",
3708
+ "notstartswith",
3709
+ "beginswith",
3710
+ "startingwith",
3711
+ "endswith",
3712
+ "notendswith",
3713
+ "matches",
3714
+ "notmatches",
3715
+ "regex",
3716
+ "regexp",
3717
+ "between",
3718
+ "notbetween",
3719
+ "equals",
3720
+ "notequals",
3721
+ "equalto",
3722
+ "isequalto",
3723
+ "isnotequalto",
3724
+ "greaterthan",
3725
+ "greaterthanorequal",
3726
+ "greaterthanorequalto",
3727
+ "lessthan",
3728
+ "lessthanorequal",
3729
+ "lessthanorequalto",
3730
+ "isempty",
3731
+ "isnotempty",
3732
+ "oneof",
3733
+ "noneof",
3734
+ "anyof",
3735
+ "allof",
3736
+ "null",
3737
+ "isnullorempty"
3738
+ ]);
3739
+ /**
3740
+ * Was this string *meant* as an operator?
3741
+ *
3742
+ * Only consulted after {@link toCanonicalOp} has already failed to resolve it,
3743
+ * so a `true` here is always a rejection.
3744
+ */
3745
+ function isOperatorShaped(op) {
3746
+ if (op === "=") return true;
3747
+ if (SYMBOLIC_OPERATOR.test(op)) return true;
3748
+ const normalized = normalizeOperatorName(op);
3749
+ if (!normalized) return false;
3750
+ return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
3751
+ }
3752
+ /**
3753
+ * Read a `[op, value]` tuple, if that is what this is.
3754
+ *
3755
+ * Three outcomes, and the middle one is the defect this function exists for:
3756
+ *
3757
+ * - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
3758
+ * - the operator does not resolve but was plainly meant as one → throw;
3759
+ * - it does not look like an operator at all → `undefined`, and the caller
3760
+ * falls back to reading the array as a list of values.
3761
+ *
3762
+ * The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
3763
+ * only, with *everything else* — including every REST short-code — dropping
3764
+ * through to `["in", raw]`. So the operator string itself became a value in a
3765
+ * membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
3766
+ * which matches, and the caller got back rows their filter was written to
3767
+ * exclude. `["eq", "active"]` had the same shape of failure.
3768
+ */
3769
+ function readTuple(field, raw) {
3770
+ if (!Array.isArray(raw) || raw.length !== 2) return void 0;
3771
+ const [op, value] = raw;
3772
+ if (typeof op !== "string") return void 0;
3773
+ const canonical = toCanonicalOp(op);
3774
+ if (canonical) return [canonical, value];
3775
+ if (op.includes(".")) return void 0;
3776
+ if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
3777
+ }
3055
3778
  /**
3056
3779
  * Serialize a single canonical condition tuple to a PostgREST dot-string.
3057
3780
  *
@@ -3066,9 +3789,13 @@ function serializeTuple(tuple) {
3066
3789
  if (!Array.isArray(tuple) || tuple.length !== 2) throw new TypeError(`serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`);
3067
3790
  const [op, value] = tuple;
3068
3791
  if (typeof op !== "string") throw new TypeError(`serializeTuple: operator must be a string, got ${typeof op}`);
3069
- const restOp = CANONICAL_OP_LOOKUP[op];
3792
+ const restOp = CANONICAL_OP_LOOKUP.get(op);
3070
3793
  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(",")})`;
3794
+ if (value === null && (op === "==" || op === "!=")) return op === "==" ? "isnull.null" : "notnull.null";
3795
+ if (Array.isArray(value)) {
3796
+ if (value.length === 0) return `${restOp}.(${EMPTY_LIST_TOKEN})`;
3797
+ return `${restOp}.(${value.map((v) => escapeWireValue(stringifyValue(v))).join(",")})`;
3798
+ }
3072
3799
  return `${restOp}.${stringifyValue(value)}`;
3073
3800
  }
3074
3801
  /**
@@ -3120,10 +3847,13 @@ function deserializeSingle(raw) {
3120
3847
  if (dotIndex === -1) return ["==", raw];
3121
3848
  const prefix = raw.substring(0, dotIndex);
3122
3849
  const rest = raw.substring(dotIndex + 1);
3123
- const canonicalOp = REST_OP_LOOKUP[prefix];
3850
+ const canonicalOp = REST_OP_LOOKUP.get(prefix);
3124
3851
  if (!canonicalOp) return ["==", raw];
3125
3852
  if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
3126
- if (rest.startsWith("(") && rest.endsWith(")")) return [canonicalOp, splitListItems(rest.slice(1, -1))];
3853
+ if (rest.startsWith("(") && rest.endsWith(")")) {
3854
+ const inner = rest.slice(1, -1);
3855
+ return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
3856
+ }
3127
3857
  return [canonicalOp, rest];
3128
3858
  }
3129
3859
  /**
@@ -3138,20 +3868,27 @@ function deserializeSingle(raw) {
3138
3868
  *
3139
3869
  * deserializeFilter({ age: ["gte.18", "lt.65"] })
3140
3870
  * // → { age: [[">=", "18"], ["<", "65"]] }
3871
+ *
3872
+ * @throws {UnknownFilterOperatorError} when a condition names an operator this
3873
+ * dialect does not have. See that class for why a rejection here is a throw.
3141
3874
  */
3142
3875
  function deserializeFilter(query) {
3143
3876
  const result = {};
3144
3877
  for (const [field, raw] of Object.entries(query)) {
3145
3878
  if (raw === void 0) continue;
3146
- if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
3147
- result[field] = raw;
3879
+ const tuple = readTuple(field, raw);
3880
+ if (tuple) {
3881
+ result[field] = tuple;
3148
3882
  continue;
3149
3883
  }
3150
3884
  if (Array.isArray(raw)) {
3151
3885
  if (raw.length === 0) continue;
3152
- if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
3153
- result[field] = raw;
3154
- continue;
3886
+ if (Array.isArray(raw[0])) {
3887
+ const tuples = raw.map((item) => readTuple(field, item));
3888
+ if (tuples.every((t) => t !== void 0)) {
3889
+ result[field] = tuples;
3890
+ continue;
3891
+ }
3155
3892
  }
3156
3893
  if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
3157
3894
  else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
@@ -3176,12 +3913,12 @@ function serializeLogicalCondition(cond) {
3176
3913
  const inner = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
3177
3914
  return `${cond.type}(${inner})`;
3178
3915
  }
3179
- const restOp = CANONICAL_OP_LOOKUP[cond.operator] ?? "eq";
3916
+ const restOp = CANONICAL_OP_LOOKUP.get(cond.operator) ?? "eq";
3180
3917
  if (Array.isArray(cond.value)) {
3181
- const items = cond.value.map((v) => escapeListItem(stringifyValue(v))).join(",");
3918
+ const items = cond.value.map((v) => escapeWireValue(stringifyValue(v))).join(",");
3182
3919
  return `${cond.column}.${restOp}.(${items})`;
3183
3920
  }
3184
- return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
3921
+ return `${cond.column}.${restOp}.${escapeWireValue(stringifyValue(cond.value))}`;
3185
3922
  }
3186
3923
  /**
3187
3924
  * Parse a logical condition wire-format string back into a
@@ -3194,24 +3931,29 @@ function serializeLogicalCondition(cond) {
3194
3931
  * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
3195
3932
  * // → { type: "or", conditions: [...] }
3196
3933
  */
3197
- function deserializeLogicalCondition(str) {
3934
+ /**
3935
+ * How deeply `or(...)`/`and(...)` groups may nest.
3936
+ *
3937
+ * This parser recurses once per level, on a value that arrives in a query
3938
+ * string. Unbounded, twenty thousand levels reached `RangeError: Maximum call
3939
+ * stack size exceeded`, which a caller sees as a 500 about the call stack
3940
+ * rather than a 400 about their filter. Node's 16 KB header cap keeps a GET
3941
+ * below that in practice, but "the HTTP layer happens to stop it" is not a
3942
+ * bound this parser should rely on.
3943
+ *
3944
+ * Thirty-two is far past anything a real filter expresses; the deepest in this
3945
+ * repository's own tests is three.
3946
+ */
3947
+ var MAX_LOGICAL_NESTING_DEPTH = 32;
3948
+ function deserializeLogicalCondition(str, nesting = 0) {
3949
+ 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
3950
  const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
3199
3951
  if (logicalMatch) {
3200
3952
  const type = logicalMatch[1];
3201
3953
  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
3954
  return {
3213
3955
  type,
3214
- conditions
3956
+ conditions: splitGroupItems(innerStr).map((part) => deserializeLogicalCondition(part, nesting + 1))
3215
3957
  };
3216
3958
  }
3217
3959
  const firstDot = str.indexOf(".");
@@ -3226,7 +3968,7 @@ function deserializeLogicalCondition(str) {
3226
3968
  if (secondDot === -1) return {
3227
3969
  column,
3228
3970
  operator: "==",
3229
- value: rest
3971
+ value: unescapeWireValue(rest)
3230
3972
  };
3231
3973
  const opStr = rest.substring(0, secondDot);
3232
3974
  const valueStr = rest.substring(secondDot + 1);
@@ -3239,7 +3981,7 @@ function deserializeLogicalCondition(str) {
3239
3981
  return {
3240
3982
  column,
3241
3983
  operator,
3242
- value: valueStr
3984
+ value: unescapeWireValue(valueStr)
3243
3985
  };
3244
3986
  }
3245
3987
  //#endregion
@@ -3276,10 +4018,12 @@ function createPrimaryKeyResolver(options) {
3276
4018
  * than postgres still serve rows with one, and this keeps them working.
3277
4019
  */
3278
4020
  function rowToEntity(row, slug, primaryKeys = []) {
4021
+ const { _matches, ...values } = row;
3279
4022
  return {
3280
4023
  id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
3281
4024
  path: slug,
3282
- values: row
4025
+ values,
4026
+ ..._matches ? { searchMatches: _matches } : {}
3283
4027
  };
3284
4028
  }
3285
4029
  /**
@@ -3322,21 +4066,22 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3322
4066
  const accessor = {
3323
4067
  async find(params) {
3324
4068
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
3325
- const limit = params?.limit ?? 20;
3326
- const offset = params?.offset ?? 0;
4069
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
3327
4070
  const fetchService = driver.restFetchService;
3328
4071
  const rows = fetchService ? await fetchService.fetchCollectionForRest(slug, {
3329
4072
  filter,
3330
- limit: params?.limit,
3331
- offset: params?.offset,
4073
+ logical: params?.logical,
4074
+ limit,
4075
+ offset: driverOffset,
3332
4076
  orderBy: params?.orderBy?.[0],
3333
4077
  order: params?.orderBy?.[1],
3334
4078
  searchString: params?.searchString
3335
4079
  }, params?.include) : await driver.fetchCollection({
3336
4080
  path: slug,
3337
- limit: params?.limit,
3338
- offset: params?.offset,
4081
+ limit,
4082
+ offset: driverOffset,
3339
4083
  filter,
4084
+ logical: params?.logical,
3340
4085
  orderBy: params?.orderBy?.[0],
3341
4086
  order: params?.orderBy?.[1],
3342
4087
  searchString: params?.searchString
@@ -3346,7 +4091,9 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3346
4091
  if (driver.count) {
3347
4092
  total = await driver.count({
3348
4093
  path: slug,
3349
- filter
4094
+ filter,
4095
+ logical: params?.logical,
4096
+ searchString: params?.searchString
3350
4097
  });
3351
4098
  hasMore = offset + rows.length < total;
3352
4099
  }
@@ -3398,30 +4145,48 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3398
4145
  values: {}
3399
4146
  } });
3400
4147
  },
4148
+ updateMany: driver.updateMany ? async (updates) => {
4149
+ return (await driver.updateMany({
4150
+ path: slug,
4151
+ updates: updates.map((u) => ({
4152
+ id: u.id,
4153
+ values: u.data
4154
+ }))
4155
+ })).map((row) => rowToEntity(row, slug, getPks()));
4156
+ } : void 0,
4157
+ deleteMany: driver.deleteMany ? async (ids) => {
4158
+ await driver.deleteMany({
4159
+ path: slug,
4160
+ ids
4161
+ });
4162
+ } : void 0,
3401
4163
  count: driver.count ? async (params) => {
3402
4164
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
3403
4165
  return driver.count({
3404
4166
  path: slug,
3405
- filter
4167
+ filter,
4168
+ logical: params?.logical,
4169
+ searchString: params?.searchString
3406
4170
  });
3407
4171
  } : void 0,
3408
4172
  listen: driver.listenCollection ? (params, onUpdate, onError) => {
3409
- const limit = params?.limit ?? 20;
3410
- const offset = params?.offset ?? 0;
4173
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
3411
4174
  const normalize = driver.restFetchService ? inlineRelationRefs : (row) => row;
3412
4175
  return driver.listenCollection({
3413
4176
  path: slug,
3414
- limit: params?.limit,
3415
- offset: params?.offset,
4177
+ limit,
4178
+ offset: driverOffset,
3416
4179
  filter: params?.where,
4180
+ logical: params?.logical,
3417
4181
  orderBy: params?.orderBy?.[0],
3418
4182
  order: params?.orderBy?.[1],
3419
4183
  searchString: params?.searchString,
4184
+ searchExplain: params?.searchExplain,
3420
4185
  onUpdate: (entities) => {
3421
4186
  onUpdate({
3422
4187
  data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
3423
4188
  meta: {
3424
- total: entities.length,
4189
+ total: offset + entities.length,
3425
4190
  limit,
3426
4191
  offset,
3427
4192
  hasMore: entities.length >= limit
@@ -3454,8 +4219,11 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3454
4219
  offset(count) {
3455
4220
  return new QueryBuilder(accessor).offset(count);
3456
4221
  },
3457
- search(searchString) {
3458
- return new QueryBuilder(accessor).search(searchString);
4222
+ search(searchString, options) {
4223
+ return new QueryBuilder(accessor).search(searchString, options);
4224
+ },
4225
+ vectorSearch(property, vector, options) {
4226
+ return new QueryBuilder(accessor).vectorSearch(property, vector, options);
3459
4227
  },
3460
4228
  include(...relations) {
3461
4229
  return new QueryBuilder(accessor).include(...relations);
@@ -3543,8 +4311,18 @@ var SdkQueryBuilder = class {
3543
4311
  this.params.offset = count;
3544
4312
  return this;
3545
4313
  }
3546
- search(searchString) {
4314
+ search(searchString, options) {
3547
4315
  this.params.searchString = searchString;
4316
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
4317
+ return this;
4318
+ }
4319
+ vectorSearch(property, vector, options) {
4320
+ this.params.vectorSearch = {
4321
+ property,
4322
+ vector,
4323
+ ...options?.distance !== void 0 && { distance: options.distance },
4324
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
4325
+ };
3548
4326
  return this;
3549
4327
  }
3550
4328
  include(...relations) {
@@ -3598,9 +4376,24 @@ function toSdkCollectionClient(snap, slug = "collection") {
3598
4376
  async update(id, data) {
3599
4377
  return entityToRow(await snap.update(id, data));
3600
4378
  },
4379
+ async updateMany(updates) {
4380
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
4381
+ if (updates.length === 0) return [];
4382
+ if (!snap.updateMany) throw new Error("Bulk updates are not supported by this collection's data source. Fall back to update() per record.");
4383
+ return (await snap.updateMany(updates.map((u) => ({
4384
+ id: u.id,
4385
+ data: u.data
4386
+ })))).map(entityToRow);
4387
+ },
3601
4388
  delete(id) {
3602
4389
  return snap.delete(id);
3603
4390
  },
4391
+ async deleteMany(ids) {
4392
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
4393
+ if (ids.length === 0) return;
4394
+ if (!snap.deleteMany) throw new Error("Bulk deletes are not supported by this collection's data source. Fall back to delete() per record.");
4395
+ await snap.deleteMany(ids);
4396
+ },
3604
4397
  count: snap.count ? (params) => snap.count(params) : void 0,
3605
4398
  listen: snap.listen ? (params, onUpdate, onError) => snap.listen(params, (res) => onUpdate({
3606
4399
  data: res.data.map(entityToRow),
@@ -3616,6 +4409,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
3616
4409
  limit: (count) => new SdkQueryBuilder(client).limit(count),
3617
4410
  offset: (count) => new SdkQueryBuilder(client).offset(count),
3618
4411
  search: (searchString) => new SdkQueryBuilder(client).search(searchString),
4412
+ vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
3619
4413
  include: (...relations) => new SdkQueryBuilder(client).include(...relations)
3620
4414
  };
3621
4415
  return client;
@@ -3623,7 +4417,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
3623
4417
  /**
3624
4418
  * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
3625
4419
  * {@link CollectionAccessor}. Every returned row is re-wrapped into the
3626
- * `{ id, path, values }` view-model the admin admin renders.
4420
+ * `{ id, path, values }` view-model the admin panel renders.
3627
4421
  */
3628
4422
  function toEntityAccessor(sdk, slug, getPks = () => []) {
3629
4423
  const accessor = {
@@ -3641,6 +4435,9 @@ function toEntityAccessor(sdk, slug, getPks = () => []) {
3641
4435
  async create(data, id) {
3642
4436
  return rowToEntity(await sdk.create(data, id), slug, getPks());
3643
4437
  },
4438
+ createMany: sdk.createMany ? async (data, options) => {
4439
+ return (await sdk.createMany(data, options)).map((row) => rowToEntity(row, slug, getPks()));
4440
+ } : void 0,
3644
4441
  async update(id, data) {
3645
4442
  const row = await sdk.update(id, data);
3646
4443
  if (!row) throw new Error(`Update returned no data for id ${id}`);
@@ -3664,6 +4461,7 @@ function toEntityAccessor(sdk, slug, getPks = () => []) {
3664
4461
  limit: (count) => new QueryBuilder(accessor).limit(count),
3665
4462
  offset: (count) => new QueryBuilder(accessor).offset(count),
3666
4463
  search: (searchString) => new QueryBuilder(accessor).search(searchString),
4464
+ vectorSearch: (property, vector, options) => new QueryBuilder(accessor).vectorSearch(property, vector, options),
3667
4465
  include: (...relations) => new QueryBuilder(accessor).include(...relations)
3668
4466
  };
3669
4467
  return accessor;
@@ -3677,6 +4475,15 @@ function toEntityAccessor(sdk, slug, getPks = () => []) {
3677
4475
  * admin `RebaseDataContext` — without it the admin renders rows with only their
3678
4476
  * `id`.
3679
4477
  */
4478
+ /**
4479
+ * Only the by-slug accessor is asked for, so only that is required.
4480
+ *
4481
+ * Taking a whole `RebaseSdkData` meant taking `RebaseSdkData<unknown>`, whose
4482
+ * dynamic branch is an index signature — and no `RebaseSdkData<DB>` satisfies
4483
+ * it, because its own `collection` method is not a `SDKCollectionClient`. So a
4484
+ * caller holding a *typed* client could not pass it to a function that reads
4485
+ * one method off it, and that method is identical on every instantiation.
4486
+ */
3680
4487
  function wrapAsEntityData(sdkData, options) {
3681
4488
  const cache = /* @__PURE__ */ new Map();
3682
4489
  const primaryKeysFor = createPrimaryKeyResolver(options);
@@ -3779,6 +4586,27 @@ function buildRoutedRebaseData({ defaultData, sources, resolveKey }) {
3779
4586
  } });
3780
4587
  }
3781
4588
  //#endregion
4589
+ //#region src/data/filter-conditions.ts
4590
+ /**
4591
+ * Read one field's filter as the list of conditions it stands for.
4592
+ *
4593
+ * Accepts both declared shapes and normalises them to a list:
4594
+ *
4595
+ * ```ts
4596
+ * toFilterTuples(["==", "active"]) // [["==", "active"]]
4597
+ * toFilterTuples([[">=", 18], ["<", 65]]) // [[">=", 18], ["<", 65]]
4598
+ * ```
4599
+ *
4600
+ * A falsy, non-array or empty param has no conditions in it — the empty list,
4601
+ * so a caller iterating adds nothing rather than compiling a tuple of
4602
+ * `undefined`s and logging about an operator nobody sent.
4603
+ */
4604
+ function toFilterTuples(filterParam) {
4605
+ if (!filterParam || !Array.isArray(filterParam) || filterParam.length === 0) return [];
4606
+ if (Array.isArray(filterParam[0])) return filterParam;
4607
+ return [filterParam];
4608
+ }
4609
+ //#endregion
3782
4610
  //#region src/data/sort-dialect.ts
3783
4611
  /**
3784
4612
  * Sort-order wire codec.
@@ -3906,6 +4734,6 @@ async function detectJunctionTables(executeSql) {
3906
4734
  return junctionTables;
3907
4735
  }
3908
4736
  //#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 };
4737
+ 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, UnknownFilterOperatorError, 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, fieldKeyForColumn, 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
4738
 
3911
4739
  //# sourceMappingURL=index.es.js.map