@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.1-canary.g7e666eb

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 (104) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +31 -0
  4. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-C-FDnL_e.js} +768 -76
  5. package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
  6. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-output.d.ts +34 -0
  9. package/dist/data-transformer.d.ts +7 -2
  10. package/dist/data_driver-ULAyJEi9.js +193 -0
  11. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  12. package/dist/ensure-collection-policies-BedO2aNX.js +124 -0
  13. package/dist/ensure-collection-policies-BedO2aNX.js.map +1 -0
  14. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-B1qKdXA4.js} +89 -10
  15. package/dist/ensure-collection-tables-B1qKdXA4.js.map +1 -0
  16. package/dist/index.es.js +1615 -1210
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  19. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  20. package/dist/rls-enforcement-gUNDfm7l.js +425 -0
  21. package/dist/rls-enforcement-gUNDfm7l.js.map +1 -0
  22. package/dist/schema/auth-schema.d.ts +102 -0
  23. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  24. package/dist/schema/doctor.d.ts +41 -25
  25. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  26. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  27. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  28. package/dist/schema/introspect-db-inference.d.ts +8 -1
  29. package/dist/schema/introspect-db-logic.d.ts +49 -0
  30. package/dist/schema/introspect-db-project.d.ts +21 -0
  31. package/dist/schema/search-column.d.ts +49 -0
  32. package/dist/security/policy-drift.d.ts +34 -0
  33. package/dist/security/rls-enforcement.d.ts +8 -3
  34. package/dist/services/FetchService.d.ts +63 -5
  35. package/dist/services/PersistService.d.ts +21 -17
  36. package/dist/services/RelationService.d.ts +11 -59
  37. package/dist/services/RelationWriteService.d.ts +82 -0
  38. package/dist/services/collection-helpers.d.ts +42 -0
  39. package/dist/services/dataService.d.ts +8 -4
  40. package/dist/services/junction-writes.d.ts +82 -0
  41. package/dist/services/realtimeService.d.ts +191 -12
  42. package/dist/services/write-denial.d.ts +36 -0
  43. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  44. package/dist/src-DCdn3Val.js.map +1 -0
  45. package/dist/utils/drizzle-conditions.d.ts +54 -1
  46. package/dist/{websocket-D0TBU3ia.js → websocket-D2jXv0Ds.js} +103 -19
  47. package/dist/websocket-D2jXv0Ds.js.map +1 -0
  48. package/package.json +6 -6
  49. package/src/PostgresBackendDriver.ts +14 -6
  50. package/src/PostgresBootstrapper.ts +95 -9
  51. package/src/auth/ensure-tables.ts +27 -5
  52. package/src/auth/services.ts +108 -10
  53. package/src/backup/backup-cli.ts +59 -57
  54. package/src/cli-errors.ts +6 -6
  55. package/src/cli-helpers.ts +4 -4
  56. package/src/cli-output.ts +43 -0
  57. package/src/cli.ts +155 -147
  58. package/src/collections/buildRegistry.ts +3 -1
  59. package/src/data-transformer.ts +111 -25
  60. package/src/history/ensure-history-table.ts +2 -2
  61. package/src/schema/auth-schema.ts +17 -1
  62. package/src/schema/doctor-cli.ts +14 -65
  63. package/src/schema/doctor-policy-checks.ts +105 -0
  64. package/src/schema/doctor.ts +149 -72
  65. package/src/schema/ensure-collection-policies.ts +99 -6
  66. package/src/schema/ensure-collection-tables.ts +214 -17
  67. package/src/schema/generate-drizzle-schema-logic.ts +140 -66
  68. package/src/schema/generate-drizzle-schema.ts +11 -10
  69. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  70. package/src/schema/generate-postgres-ddl.ts +14 -13
  71. package/src/schema/generated-schema-staleness.ts +7 -5
  72. package/src/schema/introspect-db-inference.ts +9 -2
  73. package/src/schema/introspect-db-logic.ts +251 -75
  74. package/src/schema/introspect-db-project.ts +78 -0
  75. package/src/schema/introspect-db.ts +42 -25
  76. package/src/schema/introspect-runtime.ts +14 -2
  77. package/src/schema/search-column.ts +85 -0
  78. package/src/security/policy-drift.test.ts +104 -3
  79. package/src/security/policy-drift.ts +129 -7
  80. package/src/security/rls-enforcement.ts +9 -4
  81. package/src/services/FetchService.ts +289 -69
  82. package/src/services/PersistService.ts +68 -42
  83. package/src/services/RelationService.ts +38 -698
  84. package/src/services/RelationWriteService.ts +653 -0
  85. package/src/services/cdc/trigger-cdc.ts +5 -1
  86. package/src/services/channel-history.ts +9 -3
  87. package/src/services/channel-presence.ts +10 -3
  88. package/src/services/collection-helpers.ts +89 -4
  89. package/src/services/dataService.ts +8 -4
  90. package/src/services/junction-writes.ts +295 -0
  91. package/src/services/pg-notify-listener.ts +15 -1
  92. package/src/services/realtimeService.ts +496 -121
  93. package/src/services/write-denial.ts +55 -0
  94. package/src/utils/drizzle-conditions.ts +211 -34
  95. package/src/utils/pg-error-utils.ts +8 -3
  96. package/src/websocket.ts +157 -17
  97. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  98. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  99. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  100. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  101. package/dist/policy-CPkCqVTz.js +0 -105
  102. package/dist/policy-CPkCqVTz.js.map +0 -1
  103. package/dist/src-C_wvdMnl.js.map +0 -1
  104. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -2,8 +2,9 @@ import { createRequire as __createRequire } from "module";
2
2
  import "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { l as __require, s as __commonJSMin } from "./connection-BuZ97wsr.js";
5
- import { a as rewriteLegacyRlsFunctions, c as isPostgresCollectionConfig, d as getDataSourceCapabilities, f as NULL_OPS, i as RLS_UID_SQL, l as isRelationalCollectionConfig, m as toCanonicalOp, p as REST_TO_CANONICAL, r as RLS_ROLES_SQL, s as getDeclaredSubcollections } from "./src-C_wvdMnl.js";
6
- import { n as ANONYMOUS_USER_IDS, r as policy, t as ANONYMOUS_USER_ID } from "./policy-CPkCqVTz.js";
5
+ import { a as policy, i as ANONYMOUS_USER_IDS, r as ANONYMOUS_USER_ID } from "./data_driver-ULAyJEi9.js";
6
+ import { a as rewriteLegacyRlsFunctions, c as isPostgresCollectionConfig, d as getDataSourceCapabilities, f as ALL_WHERE_FILTER_OPS, g as toCanonicalOp, h as REST_TO_CANONICAL, i as RLS_UID_SQL, l as isRelationalCollectionConfig, m as NULL_OPS, p as CANONICAL_TO_REST, r as RLS_ROLES_SQL, s as getDeclaredSubcollections } from "./src-DCdn3Val.js";
7
+ import { createHash } from "node:crypto";
7
8
  //#region ../types/src/types/entities.ts
8
9
  /**
9
10
  * Class used to create a reference to a entity in a different path
@@ -55,25 +56,6 @@ function hasForeignKeyOnTarget(relation) {
55
56
  function isManyToMany(relation) {
56
57
  return relation.kind === "manyToMany";
57
58
  }
58
- /**
59
- * Resolve a client-supplied list `limit` into a safe, always-defined value.
60
- *
61
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
62
- * so `0`, negatives, and absurd values can never bypass the cap.
63
- * - An absent / blank / non-numeric limit falls back to the mode default:
64
- * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
65
- *
66
- * The return is never `undefined` — no ingress that routes its client limit
67
- * through this can produce an unbounded read.
68
- */
69
- function resolveClientListLimit(rawLimit, opts = {}) {
70
- const maxLimit = opts.maxLimit ?? 1e3;
71
- if (rawLimit != null && String(rawLimit).trim() !== "") {
72
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
73
- if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
74
- }
75
- return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
76
- }
77
59
  //#endregion
78
60
  //#region ../common/src/util/common.ts
79
61
  var DEFAULT_ONE_OF_TYPE = "type";
@@ -1430,6 +1412,73 @@ function toPostgresIdentifier(name) {
1430
1412
  if (bytes.byteLength <= 63) return name;
1431
1413
  return new TextDecoder("utf-8").decode(bytes.subarray(0, 63)).replace(/�+$/, "");
1432
1414
  }
1415
+ /**
1416
+ * The API name a database column is served under.
1417
+ *
1418
+ * The wire name of a field is its property key, and Rebase's property keys are
1419
+ * camelCase — `displayName`, `createdAt`, `photoURL`. Columns are snake_case,
1420
+ * because an unquoted Postgres identifier folds to lower case and a camelCase
1421
+ * column is therefore reachable only as `"authorId"` forever: in hand-written
1422
+ * SQL, in psql, in an RLS policy body, in a dump, and in every third-party tool
1423
+ * that ever touches the database. So the two conventions are both right, and
1424
+ * this is the function that crosses between them.
1425
+ *
1426
+ * It exists because two sources of field names never crossed: a foreign key
1427
+ * derived from a relation (`author_id`) and a column read back by introspection
1428
+ * (`user_id`) both landed on the wire under their column name, while every
1429
+ * hand-authored collection next to them used camelCase. One API, two
1430
+ * conventions, and no rule a caller could infer from outside — those names are
1431
+ * also the `where` and `orderBy` keys, so it was not a matter of taste.
1432
+ *
1433
+ * Rules, in the order they matter:
1434
+ *
1435
+ * - **A name with no separator is returned unchanged.** `photoURL` stays
1436
+ * `photoURL` and `id` stays `id`. Lower-casing a single token is what makes
1437
+ * a "camelCase" helper destructive — `camelCase("photoURL")` is `photourl` —
1438
+ * and this function is applied to names that are *already* keys.
1439
+ * - **Each following segment keeps its own casing** apart from an upper-cased
1440
+ * first letter, so `photo_URL` → `photoURL` rather than `photoUrl`.
1441
+ * - **The result may still not be a JavaScript identifier.** `2fa_enabled`
1442
+ * becomes `2faEnabled`, which is a perfectly good object key and still needs
1443
+ * quoting where one is written into generated source.
1444
+ *
1445
+ * Not the inverse of {@link toSnakeCase}: `toSnakeCase` tokenises on case
1446
+ * boundaries and would turn `photoURL` into `photo_url`. Round-tripping is not
1447
+ * a property either function promises, which is why a column name that a
1448
+ * property maps explicitly is always read off `columnName` rather than derived.
1449
+ */
1450
+ function toWireKey(columnName) {
1451
+ if (!columnName) return columnName;
1452
+ const segments = columnName.split(/[-_ ]+/).filter(Boolean);
1453
+ if (segments.length <= 1) return columnName;
1454
+ return segments.map((segment, index) => index === 0 ? segment.charAt(0).toLowerCase() + segment.slice(1) : segment.charAt(0).toUpperCase() + segment.slice(1)).join("");
1455
+ }
1456
+ /**
1457
+ * The first candidate key not already used, or a numbered fallback.
1458
+ *
1459
+ * Introspection turns a set of column names into a set of object keys, and the
1460
+ * mapping is not injective: `user_id` and `userId` are two columns and one
1461
+ * {@link toWireKey}, and two foreign keys can strip to the same relation name.
1462
+ * A duplicate key in a generated object literal is a TypeScript error, so the
1463
+ * whole collection stops compiling — and a duplicate key in a `Record` built at
1464
+ * runtime is worse, because it silently drops a column instead.
1465
+ *
1466
+ * The numbered tail is what makes this total: a function that returns a key it
1467
+ * cannot guarantee is free has only moved the duplicate one line down.
1468
+ *
1469
+ * Structurally typed on `has` so a `Map` of emitted blocks and a `Set` of taken
1470
+ * names both satisfy it. Lives here, in the package both introspection
1471
+ * producers and the admin's table import can reach, because they must resolve a
1472
+ * collision the same way or one database describes itself three ways.
1473
+ */
1474
+ function firstFreeKey(candidates, taken) {
1475
+ for (const candidate of candidates) if (!taken.has(candidate)) return candidate;
1476
+ const base = candidates[candidates.length - 1];
1477
+ for (let suffix = 2;; suffix++) {
1478
+ const candidate = `${base}_${suffix}`;
1479
+ if (!taken.has(candidate)) return candidate;
1480
+ }
1481
+ }
1433
1482
  //#endregion
1434
1483
  //#region ../common/src/util/entities.ts
1435
1484
  /**
@@ -1539,6 +1588,25 @@ function createRelationRefWithData(id, path, data) {
1539
1588
  data
1540
1589
  };
1541
1590
  }
1591
+ //#endregion
1592
+ //#region ../common/src/util/collections.ts
1593
+ /**
1594
+ * A copy of `collections` ordered by slug.
1595
+ *
1596
+ * Every generator that turns collections into a file is order-dependent, and
1597
+ * every one of them is compared against its own output — `rebase doctor`
1598
+ * regenerates in memory and diffs, `generate-sdk && git diff --exit-code` gates
1599
+ * CI. While only the *writers* sorted, a project whose `readdirSync` order
1600
+ * differed from its slug order was reported permanently out of date, and the
1601
+ * fix the message printed rewrote the file in the order it was already in. The
1602
+ * generators sort themselves now, so no caller can get this wrong.
1603
+ *
1604
+ * A slug-less collection is left to the generator's own validation, which names
1605
+ * the offending collection; sorting must not throw first.
1606
+ */
1607
+ function sortCollectionsBySlug(collections) {
1608
+ return [...collections].sort((a, b) => (a.slug ?? "").localeCompare(b.slug ?? ""));
1609
+ }
1542
1610
  /** The eight-four-four-four-twelve shape of a UUID, any version. */
1543
1611
  var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1544
1612
  /** Whether one address part can be a value of the column it addresses. */
@@ -1875,6 +1943,48 @@ function getColumnName(fullColumn) {
1875
1943
  return fullColumn.includes(".") ? fullColumn.split(".").pop() : fullColumn;
1876
1944
  }
1877
1945
  /**
1946
+ * The field key a database column is served and addressed under.
1947
+ *
1948
+ * A column has two names and they are not the same name. `author_id` is what
1949
+ * Postgres stores; `authorId` is the key on the JSON row, the key in the
1950
+ * generated Drizzle table, and the key a caller writes in `where` and
1951
+ * `orderBy`. Every place that starts from a column and has to reach a row, a
1952
+ * Drizzle table or a payload goes through here, so there is one answer rather
1953
+ * than one per call site — the two that disagreed put `displayName` and
1954
+ * `author_id` on the same API.
1955
+ *
1956
+ * A declared property is the authority when there is one, because its key *is*
1957
+ * the wire name and `columnName` is the only thing that ever renamed the
1958
+ * column:
1959
+ *
1960
+ * 1. an explicit `columnName` equal to this column;
1961
+ * 2. a property whose key is literally the column (an author who wrote
1962
+ * `author_id:` meant `author_id` on the wire, and gets it);
1963
+ * 3. a property whose key snake-cases to the column, which is the default
1964
+ * mapping — `authorId` → `author_id`.
1965
+ *
1966
+ * With no property in the way — a foreign key derived from a relation, which
1967
+ * usually has none — the name is derived: {@link toWireKey}.
1968
+ *
1969
+ * Note the fallback is *not* the column verbatim. That was the old behaviour
1970
+ * and it is precisely the defect: a derived foreign key reached the wire under
1971
+ * its column name while every hand-authored field beside it was camelCase.
1972
+ */
1973
+ function fieldKeyForColumn(collection, column) {
1974
+ const properties = collection?.properties;
1975
+ if (properties) {
1976
+ for (const [key, prop] of Object.entries(properties)) {
1977
+ const columnName = prop?.columnName;
1978
+ if (typeof columnName === "string" && columnName === column) return key;
1979
+ }
1980
+ for (const key of Object.keys(properties)) {
1981
+ if (key === column) return key;
1982
+ if (toSnakeCase(key) === column) return key;
1983
+ }
1984
+ }
1985
+ return toWireKey(column);
1986
+ }
1987
+ /**
1878
1988
  * Look up a relation by key with forgiving normalization.
1879
1989
  *
1880
1990
  * `resolveCollectionRelations` stores each relation under a single canonical
@@ -2114,11 +2224,32 @@ function sqlToPolicy(sql) {
2114
2224
  * is the more dangerous spelling, because it inverts a rule instead of
2115
2225
  * emptying a table.
2116
2226
  */
2117
- var FOREIGN_CONVENTION_UIDS = {
2118
- anon: "Supabase",
2119
- authenticated: "Supabase",
2120
- service_role: "Supabase"
2121
- };
2227
+ /**
2228
+ * A `Map`, not an object literal.
2229
+ *
2230
+ * As `Record<string, string>` this was indexed with a literal taken straight
2231
+ * out of a policy, so every key on `Object.prototype` answered: a rule
2232
+ * comparing `rebase.uid()` to `"valueOf"`, `"toString"`, `"constructor"` or
2233
+ * `"hasOwnProperty"` found a truthy "platform" and reported an anonymous-grant
2234
+ * risk that does not exist — with the matched function interpolated into the
2235
+ * explanation as the platform's name. A security warning that fires on
2236
+ * innocent input is worse than none: it is what teaches people to skip the
2237
+ * warnings that are real.
2238
+ *
2239
+ * Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,
2240
+ * `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain
2241
+ * object. Found by a property test, on the input `"valueOf"`.
2242
+ */
2243
+ var FOREIGN_CONVENTION_UIDS = /* @__PURE__ */ new Map([
2244
+ ["anon", "Supabase"],
2245
+ ["authenticated", "Supabase"],
2246
+ ["service_role", "Supabase"]
2247
+ ]);
2248
+ /**
2249
+ * The same foreign literals, as a pattern for SQL that could not be parsed
2250
+ * back into structure.
2251
+ */
2252
+ var FOREIGN_UID_LITERAL_SQL = new RegExp(String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`, "i");
2122
2253
  /**
2123
2254
  * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
2124
2255
  *
@@ -2165,17 +2296,27 @@ function findAnonymousGrants(expr) {
2165
2296
  case "existsIn":
2166
2297
  visit(e.where);
2167
2298
  return;
2168
- case "raw":
2299
+ case "raw": {
2169
2300
  if (UID_NOT_NULL.test(e.sql)) found.push({
2170
2301
  pattern: "uid-not-null",
2171
2302
  detail: e.sql,
2172
2303
  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".`
2173
2304
  });
2305
+ const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);
2306
+ if (foreign) {
2307
+ const literal = foreign[1];
2308
+ found.push({
2309
+ pattern: "foreign-uid-literal",
2310
+ detail: literal,
2311
+ 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".`
2312
+ });
2313
+ }
2174
2314
  return;
2315
+ }
2175
2316
  case "compare": {
2176
2317
  const literal = [e.left, e.right].find((o) => o.kind === "literal");
2177
2318
  if (!(e.left.kind === "authUid" || e.right.kind === "authUid") || typeof literal?.value !== "string") return;
2178
- const platform = FOREIGN_CONVENTION_UIDS[literal.value];
2319
+ const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);
2179
2320
  if (!platform) return;
2180
2321
  found.push({
2181
2322
  pattern: "foreign-uid-literal",
@@ -2191,12 +2332,43 @@ function findAnonymousGrants(expr) {
2191
2332
  return found;
2192
2333
  }
2193
2334
  function parseOperand(str) {
2194
- if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /rebase\.uid\(\)/i.test(str)) return policy.authUid();
2195
- const stringMatch = str.match(/^'(.+)'$/);
2196
- if (stringMatch) return policy.literal(stringMatch[1]);
2197
- if (/^\w+$/.test(str)) return policy.field(str);
2335
+ if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
2336
+ const literal = parseSingleQuoted(str);
2337
+ if (literal !== null) return policy.literal(literal);
2338
+ if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
2339
+ if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
2340
+ if (/^true$/i.test(str)) return policy.literal(true);
2341
+ if (/^false$/i.test(str)) return policy.literal(false);
2342
+ if (/^null$/i.test(str)) return policy.literal(null);
2343
+ if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
2198
2344
  return null;
2199
2345
  }
2346
+ /**
2347
+ * Decode a single-quoted SQL literal, or null when `str` is not exactly one.
2348
+ *
2349
+ * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
2350
+ * operator, not one literal whose body contains a quote, and a regex anchored
2351
+ * on the outer quotes would happily read it as the latter. Every interior quote
2352
+ * must therefore be part of a `''` pair.
2353
+ */
2354
+ function parseSingleQuoted(str) {
2355
+ if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
2356
+ const body = str.slice(1, -1);
2357
+ let out = "";
2358
+ for (let i = 0; i < body.length; i++) {
2359
+ if (body[i] !== "'") {
2360
+ out += body[i];
2361
+ continue;
2362
+ }
2363
+ if (body[i + 1] === "'") {
2364
+ out += "'";
2365
+ i++;
2366
+ continue;
2367
+ }
2368
+ return null;
2369
+ }
2370
+ return out;
2371
+ }
2200
2372
  //#endregion
2201
2373
  //#region ../common/src/util/policy/securityRuleToConditions.ts
2202
2374
  /**
@@ -2333,8 +2505,147 @@ function schemaOf(collection) {
2333
2505
  }
2334
2506
  function resolveColumnName(propName, collection) {
2335
2507
  const prop = collection?.properties?.[propName];
2336
- if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
2337
- return toSnakeCase(propName);
2508
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") return quoteColumnIdentifier(prop.columnName);
2509
+ return quoteColumnIdentifier(toSnakeCase(propName));
2510
+ }
2511
+ /**
2512
+ * Every PostgreSQL keyword that cannot stand as a bare column reference.
2513
+ * Appendix C's two reserved categories — plain "reserved", and "reserved (can
2514
+ * be function or type name)" — since neither may name a column unquoted.
2515
+ */
2516
+ var RESERVED_SQL_WORDS = /* @__PURE__ */ new Set([
2517
+ "all",
2518
+ "analyse",
2519
+ "analyze",
2520
+ "and",
2521
+ "any",
2522
+ "array",
2523
+ "as",
2524
+ "asc",
2525
+ "asymmetric",
2526
+ "authorization",
2527
+ "binary",
2528
+ "both",
2529
+ "case",
2530
+ "cast",
2531
+ "check",
2532
+ "collate",
2533
+ "collation",
2534
+ "column",
2535
+ "concurrently",
2536
+ "constraint",
2537
+ "create",
2538
+ "cross",
2539
+ "current_catalog",
2540
+ "current_date",
2541
+ "current_role",
2542
+ "current_schema",
2543
+ "current_time",
2544
+ "current_timestamp",
2545
+ "current_user",
2546
+ "default",
2547
+ "deferrable",
2548
+ "desc",
2549
+ "distinct",
2550
+ "do",
2551
+ "else",
2552
+ "end",
2553
+ "except",
2554
+ "false",
2555
+ "fetch",
2556
+ "for",
2557
+ "foreign",
2558
+ "freeze",
2559
+ "from",
2560
+ "full",
2561
+ "grant",
2562
+ "group",
2563
+ "having",
2564
+ "ilike",
2565
+ "in",
2566
+ "initially",
2567
+ "inner",
2568
+ "intersect",
2569
+ "into",
2570
+ "is",
2571
+ "isnull",
2572
+ "join",
2573
+ "lateral",
2574
+ "leading",
2575
+ "left",
2576
+ "like",
2577
+ "limit",
2578
+ "localtime",
2579
+ "localtimestamp",
2580
+ "natural",
2581
+ "not",
2582
+ "notnull",
2583
+ "null",
2584
+ "offset",
2585
+ "on",
2586
+ "only",
2587
+ "or",
2588
+ "order",
2589
+ "outer",
2590
+ "overlaps",
2591
+ "placing",
2592
+ "primary",
2593
+ "references",
2594
+ "returning",
2595
+ "right",
2596
+ "select",
2597
+ "session_user",
2598
+ "similar",
2599
+ "some",
2600
+ "symmetric",
2601
+ "system_user",
2602
+ "table",
2603
+ "tablesample",
2604
+ "then",
2605
+ "to",
2606
+ "trailing",
2607
+ "true",
2608
+ "union",
2609
+ "unique",
2610
+ "user",
2611
+ "using",
2612
+ "variadic",
2613
+ "verbose",
2614
+ "when",
2615
+ "where",
2616
+ "window",
2617
+ "with"
2618
+ ]);
2619
+ /** An identifier Postgres reads back unchanged without quotes. */
2620
+ var BARE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/;
2621
+ /**
2622
+ * Quote a column reference when Postgres would not read the bare name as that
2623
+ * column — and only then.
2624
+ *
2625
+ * Three ways a bare name goes wrong, in ascending order of how long it takes to
2626
+ * notice:
2627
+ *
2628
+ * - **Case.** `columnName` is used verbatim, and `rebase schema introspect`
2629
+ * populates it from a live database, so a legacy `"createdAt"` column arrives
2630
+ * spelled exactly that way. Unquoted, Postgres folds it to `createdat` and
2631
+ * `CREATE POLICY` fails with "column does not exist" — the collection keeps
2632
+ * RLS enabled with no policy, which denies every row.
2633
+ * - **Syntax.** A column named `order` or `default` is a syntax error mid-clause.
2634
+ * - **Silent rebinding.** `user`, `current_user`, `session_user`, `current_date`
2635
+ * and friends are *valid bare expressions*, so the policy compiles, applies,
2636
+ * and is reported as a success — while comparing against the connected role
2637
+ * or the wall clock instead of the column. Under RLS every request runs as the
2638
+ * same `rebase_user` role, so `USING (user = rebase.uid())` is a constant: it
2639
+ * denies everything, and its negation admits everything.
2640
+ *
2641
+ * Only the names that need it are quoted, so an ordinary snake_case policy body
2642
+ * is emitted byte-for-byte as before. That keeps generated artifacts and the
2643
+ * policies already stored in shipped databases stable — this fix reaches the
2644
+ * clauses that were broken and no others.
2645
+ */
2646
+ function quoteColumnIdentifier(name) {
2647
+ if (BARE_IDENTIFIER.test(name) && !RESERVED_SQL_WORDS.has(name)) return name;
2648
+ return `"${name.replace(/"/g, "\"\"")}"`;
2338
2649
  }
2339
2650
  function quoteLiteral(value) {
2340
2651
  if (value === null) return "NULL";
@@ -2354,8 +2665,14 @@ function rolesArraySql(roles) {
2354
2665
  * Rebase's enforcement model is unified: authenticated (user-context) requests
2355
2666
  * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
2356
2667
  * statement — reads and writes. A collection's `securityRules` are the whole
2357
- * authorization model. The server context (auth flows, migrations,
2358
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
2668
+ * authorization model. The server context (auth flows, migrations, raw
2669
+ * `rebase.sql`) runs as the owner and bypasses RLS.
2670
+ *
2671
+ * `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
2672
+ * `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
2673
+ * other caller and clears the baseline below through the *admin* arm, not the
2674
+ * server arm. Which is why `disableDefaultPolicies` plus a lone
2675
+ * `policy.serverContext()` rule locks it out too.
2359
2676
  *
2360
2677
  * Because RLS default-denies, every collection is **locked by default**: with
2361
2678
  * no rules, only the server context and admins can touch it. The generator
@@ -2413,11 +2730,28 @@ function getIdPropertyName$1(collection) {
2413
2730
  *
2414
2731
  * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
2415
2732
  */
2733
+ /**
2734
+ * The restrictive write gate for an auth collection.
2735
+ *
2736
+ * Restrictive, so it is ANDed with everything else: whatever an author's
2737
+ * permissive rules allow, a write to this table still has to satisfy this too.
2738
+ * It is the only thing standing between "users may edit their own row" and
2739
+ * "users may grant themselves any role".
2740
+ */
2741
+ function adminWriteGate(tableName) {
2742
+ return {
2743
+ name: `${tableName}_require_admin_write`,
2744
+ mode: "restrictive",
2745
+ operations: [...DEFAULT_GUARDED_OPS],
2746
+ condition: SERVER_OR_ADMIN_EXPR$1,
2747
+ check: SERVER_OR_ADMIN_EXPR$1
2748
+ };
2749
+ }
2416
2750
  function getEffectiveSecurityRules(collection) {
2417
2751
  const explicit = [...collection.securityRules ?? []];
2418
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return explicit;
2419
2752
  const tableName = getTableName(collection);
2420
2753
  const injected = [];
2754
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection$1(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
2421
2755
  injected.push({
2422
2756
  name: `${tableName}_default_admin_read`,
2423
2757
  operations: ["select"],
@@ -2435,13 +2769,7 @@ function getEffectiveSecurityRules(collection) {
2435
2769
  operations: ["select"],
2436
2770
  condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
2437
2771
  });
2438
- injected.push({
2439
- name: `${tableName}_require_admin_write`,
2440
- mode: "restrictive",
2441
- operations: [...DEFAULT_GUARDED_OPS],
2442
- condition: SERVER_OR_ADMIN_EXPR$1,
2443
- check: SERVER_OR_ADMIN_EXPR$1
2444
- });
2772
+ injected.push(adminWriteGate(tableName));
2445
2773
  }
2446
2774
  return [...explicit, ...injected];
2447
2775
  }
@@ -3678,6 +4006,90 @@ var CollectionRegistry = class {
3678
4006
  }
3679
4007
  };
3680
4008
  //#endregion
4009
+ //#region ../common/src/data/sort-dialect.ts
4010
+ /**
4011
+ * Sort-order wire codec.
4012
+ *
4013
+ * This is the ONLY module that knows about the colon-delimited wire format
4014
+ * (`"field:direction"`) used in HTTP query parameters, and about the JSON-array
4015
+ * form that carries a multi-column sort over the same parameter.
4016
+ * Everything else speaks {@link OrderByTuple} exclusively.
4017
+ *
4018
+ * Mirrors the filter architecture in `filter-dialect.ts`.
4019
+ *
4020
+ * @module
4021
+ */
4022
+ /**
4023
+ * Collapse the one-key and many-key spellings of a sort into the list form.
4024
+ *
4025
+ * `["a", "desc"]` and `[["a", "desc"]]` mean the same thing and normalize to
4026
+ * the same value; the two are told apart by whether the first element is
4027
+ * itself an array, which no field name ever is.
4028
+ *
4029
+ * @returns The keys in order of significance, or `undefined` for no sort. An
4030
+ * empty list also returns `undefined` — "sort by nothing" is no sort, and
4031
+ * letting `[]` through would have every layer below re-deciding what it meant.
4032
+ */
4033
+ function normalizeOrderBy(orderBy) {
4034
+ if (!orderBy || orderBy.length === 0) return void 0;
4035
+ const list = Array.isArray(orderBy[0]) ? orderBy : [orderBy];
4036
+ return list.length > 0 ? list : void 0;
4037
+ }
4038
+ /**
4039
+ * Collapse the driver-level `{orderBy, order}` pair into the list form.
4040
+ *
4041
+ * The driver contract spells a single-column sort as a field name plus a
4042
+ * separate direction, and a multi-column one as a list of tuples that leaves
4043
+ * `order` meaningless. Every driver reads both through here so neither
4044
+ * spelling has to be handled twice.
4045
+ *
4046
+ * An absent direction means ascending — the same thing a bare `?orderBy=name`
4047
+ * has always meant over HTTP. The Postgres driver used to read the same pair as
4048
+ * *descending* while Mongo read it as ascending, so one field name and no
4049
+ * direction described two different queries depending on which database was
4050
+ * underneath. Neither had a caller: every path in the workspace passes a
4051
+ * direction, which is why the disagreement went unnoticed rather than being
4052
+ * load-bearing.
4053
+ */
4054
+ function normalizeDriverOrderBy(orderBy, order) {
4055
+ if (!orderBy) return void 0;
4056
+ if (typeof orderBy === "string") return [[orderBy, order === "desc" ? "desc" : "asc"]];
4057
+ return orderBy.length > 0 ? orderBy : void 0;
4058
+ }
4059
+ /** A sort whose *shape* is unusable, as opposed to one naming a field that does not exist. */
4060
+ var OrderBySpecError = class extends Error {
4061
+ code = "INVALID_ORDER_BY";
4062
+ constructor(detail) {
4063
+ super(`Invalid \`orderBy\`: ${detail}. Expected a field name, or a list of [field, direction] pairs like [["roles","asc"],["created_at","desc"]]`);
4064
+ this.name = "OrderBySpecError";
4065
+ }
4066
+ };
4067
+ /**
4068
+ * Validate an `orderBy` that arrived from outside this process — a WebSocket
4069
+ * subscribe frame, a driver call from untyped JavaScript — and return it in the
4070
+ * list form.
4071
+ *
4072
+ * Strict on purpose, in the same way the REST `parseOrderByParam` is: the
4073
+ * failure mode for a shape nobody checks is not a crash but a *silently
4074
+ * different query*. A malformed entry read as a field name resolves to no
4075
+ * column, and under the lenient unknown-field mode the sort is then dropped and
4076
+ * the rows come back in whatever order the database pleased — sorted, as far as
4077
+ * the subscriber can tell, by whatever they asked for.
4078
+ */
4079
+ function parseOrderBySpecStrict(raw, order) {
4080
+ if (raw === void 0 || raw === null || raw === "") return void 0;
4081
+ if (typeof raw === "string") return normalizeDriverOrderBy(raw, order);
4082
+ if (!Array.isArray(raw) || raw.length === 0) throw new OrderBySpecError(`${typeof raw} is not a field name or a list of sort keys`);
4083
+ if (typeof raw[0] === "string") return [toStrictTuple(raw, 0)];
4084
+ return raw.map(toStrictTuple);
4085
+ }
4086
+ function toStrictTuple(raw, index) {
4087
+ if (!Array.isArray(raw) || typeof raw[0] !== "string" || raw[0].trim() === "") throw new OrderBySpecError(`entry ${index} has no field name`);
4088
+ const direction = raw[1];
4089
+ if (direction !== void 0 && direction !== "asc" && direction !== "desc") throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
4090
+ return [raw[0], direction ?? "asc"];
4091
+ }
4092
+ //#endregion
3681
4093
  //#region ../common/src/data/query_builder.ts
3682
4094
  var QueryBuilder = class {
3683
4095
  collection;
@@ -3706,11 +4118,18 @@ var QueryBuilder = class {
3706
4118
  }
3707
4119
  /**
3708
4120
  * Order the results by a specific column.
4121
+ *
4122
+ * Called again, this adds a tie-breaker rather than replacing the sort:
4123
+ * keys apply in the order they were added.
4124
+ *
3709
4125
  * @example
3710
4126
  * client.collection('users').orderBy('createdAt', 'desc').find()
4127
+ * @example
4128
+ * client.collection('users').orderBy('roles').orderBy('createdAt', 'desc').find()
3711
4129
  */
3712
4130
  orderBy(column, direction = "asc") {
3713
- this.params.orderBy = [column, direction];
4131
+ const existing = normalizeOrderBy(this.params.orderBy) ?? [];
4132
+ this.params.orderBy = [...existing, [column, direction]];
3714
4133
  return this;
3715
4134
  }
3716
4135
  /**
@@ -3884,9 +4303,10 @@ async function* paginateFind(find, params, label = "collection") {
3884
4303
  const requestedDirection = typeof cursor === "object" && cursor !== null ? cursor.direction : void 0;
3885
4304
  let direction = "asc";
3886
4305
  if (cursorField) {
3887
- const orderBy = findParams.orderBy;
3888
- if (orderBy && orderBy[0] !== cursorField) throw new RebasePaginationError("cursor-order-mismatch", `Cannot seek on "${cursorField}" while ordering "${label}" by "${orderBy[0]}": keyset pagination only advances along the column the query is sorted by. Order by "${cursorField}", or drop the cursor and page by offset.`);
3889
- direction = requestedDirection ?? orderBy?.[1] ?? "asc";
4306
+ const orderBy = normalizeOrderBy(findParams.orderBy);
4307
+ if (orderBy && orderBy.length > 1) throw new RebasePaginationError("cursor-order-mismatch", `Cannot seek on "${cursorField}" while ordering "${label}" by ${orderBy.map(([field]) => `"${field}"`).join(", ")}: keyset pagination advances along a single column. Order by "${cursorField}" alone, or drop the cursor and page by offset.`);
4308
+ if (orderBy && orderBy[0][0] !== cursorField) throw new RebasePaginationError("cursor-order-mismatch", `Cannot seek on "${cursorField}" while ordering "${label}" by "${orderBy[0][0]}": keyset pagination only advances along the column the query is sorted by. Order by "${cursorField}", or drop the cursor and page by offset.`);
4309
+ direction = requestedDirection ?? orderBy?.[0][1] ?? "asc";
3890
4310
  findParams.orderBy = [cursorField, direction];
3891
4311
  }
3892
4312
  const seekOp = direction === "desc" ? "<" : ">";
@@ -3947,21 +4367,50 @@ async function collectAllPages(find, params, label = "collection") {
3947
4367
  * metadata, so type coercion is the responsibility of the server-side data
3948
4368
  * driver which has access to the collection schema.
3949
4369
  *
3950
- * Commas inside list values are backslash-escaped (`\,`), and literal
3951
- * backslashes are escaped as `\\`.
4370
+ * Structural characters inside a value are backslash-escaped: `,` → `\,`,
4371
+ * `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
4372
+ * deliberately conservative — only those four sequences are decoded, so a
4373
+ * backslash that arrives unescaped from an older client survives intact.
3952
4374
  *
3953
4375
  * @module
3954
4376
  */
3955
4377
  /**
3956
- * Unescape a single list item from the wire format.
3957
- * `\\` → `\`, `\,` → `,`
4378
+ * Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
4379
+ * `)` → `\)`.
4380
+ */
4381
+ /**
4382
+ * The wire spelling of an empty list.
4383
+ *
4384
+ * A lone backslash: unproducible by {@link escapeWireValue}, which doubles
4385
+ * every backslash it emits, so it cannot collide with any real item.
4386
+ */
4387
+ var EMPTY_LIST_TOKEN = "\\";
4388
+ /**
4389
+ * Unescape a wire-format value.
4390
+ *
4391
+ * **Conservative**, and deliberately so: only the four sequences
4392
+ * {@link escapeWireValue} actually produces are decoded. A backslash followed
4393
+ * by anything else is left exactly as it is.
4394
+ *
4395
+ * This used to consume the backslash before *any* character, which is
4396
+ * indistinguishable for anything this codec emitted — it only ever emits those
4397
+ * four — but not for input arriving from elsewhere. A client on an older
4398
+ * release sends a Windows path or a LIKE pattern with a literal `C:\x`
4399
+ * unescaped, and greedy unescaping silently turned it into `C:x`, changing
4400
+ * which rows matched. Decoding only what the encoder can produce makes the two
4401
+ * directions agree across versions.
3958
4402
  */
3959
- function unescapeListItem(value) {
4403
+ function unescapeWireValue(value) {
3960
4404
  let result = "";
3961
- for (let i = 0; i < value.length; i++) if (value[i] === "\\" && i + 1 < value.length) {
3962
- result += value[i + 1];
3963
- i++;
3964
- } else result += value[i];
4405
+ for (let i = 0; i < value.length; i++) {
4406
+ const next = value[i + 1];
4407
+ if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
4408
+ result += next;
4409
+ i++;
4410
+ continue;
4411
+ }
4412
+ result += value[i];
4413
+ }
3965
4414
  return result;
3966
4415
  }
3967
4416
  /**
@@ -3979,13 +4428,205 @@ function splitListItems(inner) {
3979
4428
  current += inner[i] + inner[i + 1];
3980
4429
  i++;
3981
4430
  } else if (inner[i] === ",") {
3982
- items.push(unescapeListItem(current));
4431
+ items.push(unescapeWireValue(current));
3983
4432
  current = "";
3984
4433
  } else current += inner[i];
3985
- items.push(unescapeListItem(current));
4434
+ items.push(unescapeWireValue(current));
3986
4435
  return items;
3987
4436
  }
3988
- var REST_OP_LOOKUP = REST_TO_CANONICAL;
4437
+ /**
4438
+ * Operator tables as `Map`s, because the key comes off the wire.
4439
+ *
4440
+ * Indexed as plain objects, every `Object.prototype` member answered: a query
4441
+ * string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
4442
+ * and `deserializeTuple` returned it *as the operator*, so a function object
4443
+ * travelled on into the compilers in place of a `WhereFilterOp`. The guard one
4444
+ * line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
4445
+ * and does not: `Object.prototype` is not unknown to a plain object.
4446
+ *
4447
+ * Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
4448
+ * `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
4449
+ */
4450
+ var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
4451
+ new Map(Object.entries(CANONICAL_TO_REST));
4452
+ /** The operator spellings a rejection lists back to the caller. */
4453
+ var VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
4454
+ /**
4455
+ * A filter condition named an operator this dialect does not have.
4456
+ *
4457
+ * ## Why this throws, rather than returning a typed rejection
4458
+ *
4459
+ * `deserializeFilter` is the *shared* codec: the REST ingress
4460
+ * (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
4461
+ * admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
4462
+ * follow.
4463
+ *
4464
+ * - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
4465
+ * depend on `@rebasepro/server` (the dependency runs the other way), and a
4466
+ * browser client has no error handler to render an `ApiError` with. So the
4467
+ * rejection is this plain `Error` subclass, whose `message` reads correctly
4468
+ * wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
4469
+ * - It cannot be a returned rejection *value*. Every caller assigns the result
4470
+ * straight into a query it is about to run; a sentinel that none of them
4471
+ * check would be ignored, which is exactly the silently-wrong-filter failure
4472
+ * this exists to stop. Throwing is also what this file already does for the
4473
+ * sibling cases — `serializeTuple` on an unknown canonical operator,
4474
+ * `deserializeLogicalCondition` past the nesting bound — and the REST parser
4475
+ * already converts the latter into a 400.
4476
+ *
4477
+ * `statusCode`, `code` and `details` are carried as fields because the server's
4478
+ * Hono error handler duck-types those off any thrown error: a decode path that
4479
+ * forgets to convert still answers 400 with the canonical envelope instead of a
4480
+ * 500 that says "An unexpected error occurred". `query-parser.ts` converts
4481
+ * explicitly all the same — that is the path the contract is stated on, and an
4482
+ * incidental 400 is not a contract.
4483
+ */
4484
+ var UnknownFilterOperatorError = class extends Error {
4485
+ /** The field the condition was written against. */
4486
+ field;
4487
+ /** The operator string as it arrived, verbatim. */
4488
+ operator;
4489
+ /** Every operator this dialect accepts, in canonical spelling. */
4490
+ validOperators = ALL_WHERE_FILTER_OPS;
4491
+ /** See the class docblock: read by the server's error handler. */
4492
+ statusCode = 400;
4493
+ code = "UNKNOWN_FILTER_OPERATOR";
4494
+ details;
4495
+ constructor(field, operator) {
4496
+ super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
4497
+ this.name = "UnknownFilterOperatorError";
4498
+ this.field = field;
4499
+ this.operator = operator;
4500
+ this.details = {
4501
+ field,
4502
+ operator,
4503
+ validOperators: ALL_WHERE_FILTER_OPS
4504
+ };
4505
+ }
4506
+ };
4507
+ /**
4508
+ * Two to three characters of ASCII punctuation and nothing else — the shape
4509
+ * every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
4510
+ * one a column value effectively never has.
4511
+ *
4512
+ * Two characters minimum on purpose. A *single* punctuation character is a
4513
+ * perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
4514
+ * condition — and the only single-character operator anyone actually mistypes
4515
+ * is `=`, which is named separately below. `<` and `>` need no special case:
4516
+ * they are real operators and resolve.
4517
+ */
4518
+ var SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
4519
+ /** Lowercase, strip everything that is not a letter or digit. */
4520
+ function normalizeOperatorName(op) {
4521
+ return op.toLowerCase().replace(/[^a-z0-9]/g, "");
4522
+ }
4523
+ /**
4524
+ * Every real operator name with its case and separators removed, so a
4525
+ * respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
4526
+ * recognised as an attempt at an operator rather than read as a value.
4527
+ *
4528
+ * These are rejected rather than accepted: admitting a second spelling of an
4529
+ * operator would leave two wire spellings of one thing, and the rejection
4530
+ * message names the one that works.
4531
+ */
4532
+ var RESPELLED_OPERATORS = new Set([...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName));
4533
+ /**
4534
+ * Operator names *other* query dialects use, which this one does not have.
4535
+ *
4536
+ * This list is curated, and deliberately so. For a word-shaped string there is
4537
+ * no rule that separates "an operator the caller guessed" from "a value that
4538
+ * happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
4539
+ * `in` list, so the codec cannot simply refuse every unrecognised word in
4540
+ * position 0. The line is therefore drawn by name, and only around names whose
4541
+ * use as an operator is far more likely than their use as one of two sibling
4542
+ * values. `contains` is the motivating case — the first thing a developer
4543
+ * reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
4544
+ *
4545
+ * Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
4546
+ * are left off: as operators they are rare, and as enum values they are common.
4547
+ * Everywhere else the tie goes to *rejecting*, because a 400 naming the
4548
+ * supported set costs the caller one round trip, and the alternative — which is
4549
+ * what every name on this list used to produce — is a query that runs, returns
4550
+ * rows, and is wrong.
4551
+ */
4552
+ var NEAR_MISS_OPERATORS = /* @__PURE__ */ new Set([
4553
+ "contains",
4554
+ "notcontains",
4555
+ "doesnotcontain",
4556
+ "doesnotcontains",
4557
+ "includes",
4558
+ "notincludes",
4559
+ "startswith",
4560
+ "notstartswith",
4561
+ "beginswith",
4562
+ "startingwith",
4563
+ "endswith",
4564
+ "notendswith",
4565
+ "matches",
4566
+ "notmatches",
4567
+ "regex",
4568
+ "regexp",
4569
+ "between",
4570
+ "notbetween",
4571
+ "equals",
4572
+ "notequals",
4573
+ "equalto",
4574
+ "isequalto",
4575
+ "isnotequalto",
4576
+ "greaterthan",
4577
+ "greaterthanorequal",
4578
+ "greaterthanorequalto",
4579
+ "lessthan",
4580
+ "lessthanorequal",
4581
+ "lessthanorequalto",
4582
+ "isempty",
4583
+ "isnotempty",
4584
+ "oneof",
4585
+ "noneof",
4586
+ "anyof",
4587
+ "allof",
4588
+ "null",
4589
+ "isnullorempty"
4590
+ ]);
4591
+ /**
4592
+ * Was this string *meant* as an operator?
4593
+ *
4594
+ * Only consulted after {@link toCanonicalOp} has already failed to resolve it,
4595
+ * so a `true` here is always a rejection.
4596
+ */
4597
+ function isOperatorShaped(op) {
4598
+ if (op === "=") return true;
4599
+ if (SYMBOLIC_OPERATOR.test(op)) return true;
4600
+ const normalized = normalizeOperatorName(op);
4601
+ if (!normalized) return false;
4602
+ return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
4603
+ }
4604
+ /**
4605
+ * Read a `[op, value]` tuple, if that is what this is.
4606
+ *
4607
+ * Three outcomes, and the middle one is the defect this function exists for:
4608
+ *
4609
+ * - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
4610
+ * - the operator does not resolve but was plainly meant as one → throw;
4611
+ * - it does not look like an operator at all → `undefined`, and the caller
4612
+ * falls back to reading the array as a list of values.
4613
+ *
4614
+ * The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
4615
+ * only, with *everything else* — including every REST short-code — dropping
4616
+ * through to `["in", raw]`. So the operator string itself became a value in a
4617
+ * membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
4618
+ * which matches, and the caller got back rows their filter was written to
4619
+ * exclude. `["eq", "active"]` had the same shape of failure.
4620
+ */
4621
+ function readTuple(field, raw) {
4622
+ if (!Array.isArray(raw) || raw.length !== 2) return void 0;
4623
+ const [op, value] = raw;
4624
+ if (typeof op !== "string") return void 0;
4625
+ const canonical = toCanonicalOp(op);
4626
+ if (canonical) return [canonical, value];
4627
+ if (op.includes(".")) return void 0;
4628
+ if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
4629
+ }
3989
4630
  /**
3990
4631
  * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
3991
4632
  *
@@ -4002,10 +4643,13 @@ function deserializeSingle(raw) {
4002
4643
  if (dotIndex === -1) return ["==", raw];
4003
4644
  const prefix = raw.substring(0, dotIndex);
4004
4645
  const rest = raw.substring(dotIndex + 1);
4005
- const canonicalOp = REST_OP_LOOKUP[prefix];
4646
+ const canonicalOp = REST_OP_LOOKUP.get(prefix);
4006
4647
  if (!canonicalOp) return ["==", raw];
4007
4648
  if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
4008
- if (rest.startsWith("(") && rest.endsWith(")")) return [canonicalOp, splitListItems(rest.slice(1, -1))];
4649
+ if (rest.startsWith("(") && rest.endsWith(")")) {
4650
+ const inner = rest.slice(1, -1);
4651
+ return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
4652
+ }
4009
4653
  return [canonicalOp, rest];
4010
4654
  }
4011
4655
  /**
@@ -4020,20 +4664,27 @@ function deserializeSingle(raw) {
4020
4664
  *
4021
4665
  * deserializeFilter({ age: ["gte.18", "lt.65"] })
4022
4666
  * // → { age: [[">=", "18"], ["<", "65"]] }
4667
+ *
4668
+ * @throws {UnknownFilterOperatorError} when a condition names an operator this
4669
+ * dialect does not have. See that class for why a rejection here is a throw.
4023
4670
  */
4024
4671
  function deserializeFilter(query) {
4025
4672
  const result = {};
4026
4673
  for (const [field, raw] of Object.entries(query)) {
4027
4674
  if (raw === void 0) continue;
4028
- if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
4029
- result[field] = raw;
4675
+ const tuple = readTuple(field, raw);
4676
+ if (tuple) {
4677
+ result[field] = tuple;
4030
4678
  continue;
4031
4679
  }
4032
4680
  if (Array.isArray(raw)) {
4033
4681
  if (raw.length === 0) continue;
4034
- if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
4035
- result[field] = raw;
4036
- continue;
4682
+ if (Array.isArray(raw[0])) {
4683
+ const tuples = raw.map((item) => readTuple(field, item));
4684
+ if (tuples.every((t) => t !== void 0)) {
4685
+ result[field] = tuples;
4686
+ continue;
4687
+ }
4037
4688
  }
4038
4689
  if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
4039
4690
  else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
@@ -4132,8 +4783,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4132
4783
  logical: params?.logical,
4133
4784
  limit,
4134
4785
  offset: driverOffset,
4135
- orderBy: params?.orderBy?.[0],
4136
- order: params?.orderBy?.[1],
4786
+ orderBy: normalizeOrderBy(params?.orderBy),
4137
4787
  searchString: params?.searchString
4138
4788
  }, params?.include) : await driver.fetchCollection({
4139
4789
  path: slug,
@@ -4141,8 +4791,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4141
4791
  offset: driverOffset,
4142
4792
  filter,
4143
4793
  logical: params?.logical,
4144
- orderBy: params?.orderBy?.[0],
4145
- order: params?.orderBy?.[1],
4794
+ orderBy: normalizeOrderBy(params?.orderBy),
4146
4795
  searchString: params?.searchString
4147
4796
  });
4148
4797
  let total = rows.length + offset;
@@ -4237,8 +4886,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4237
4886
  offset: driverOffset,
4238
4887
  filter: params?.where,
4239
4888
  logical: params?.logical,
4240
- orderBy: params?.orderBy?.[0],
4241
- order: params?.orderBy?.[1],
4889
+ orderBy: normalizeOrderBy(params?.orderBy),
4242
4890
  searchString: params?.searchString,
4243
4891
  searchExplain: params?.searchExplain,
4244
4892
  onUpdate: (entities) => {
@@ -4358,8 +5006,10 @@ var SdkQueryBuilder = class {
4358
5006
  }
4359
5007
  return this;
4360
5008
  }
5009
+ /** Called again, this adds a tie-breaker rather than replacing the sort. */
4361
5010
  orderBy(column, direction = "asc") {
4362
- this.params.orderBy = [column, direction];
5011
+ const existing = normalizeOrderBy(this.params.orderBy) ?? [];
5012
+ this.params.orderBy = [...existing, [column, direction]];
4363
5013
  return this;
4364
5014
  }
4365
5015
  limit(count) {
@@ -4792,6 +5442,48 @@ var searchIndexStatements = (spec) => {
4792
5442
  return statements;
4793
5443
  };
4794
5444
  /**
5445
+ * Marker on the comment of every generated search column this module creates.
5446
+ *
5447
+ * Versioned because the fingerprint below is only comparable against itself: a
5448
+ * future change to how it is computed has to read as "not stamped by this
5449
+ * version" rather than as drift on every existing column.
5450
+ */
5451
+ var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
5452
+ /**
5453
+ * A stable fingerprint of one generated column's expression.
5454
+ *
5455
+ * Why a stamp rather than reading the expression back: Postgres stores a
5456
+ * generated column's expression *parsed*, and hands it back deparsed — casts
5457
+ * made explicit, identifiers requoted, schema qualifications added or dropped
5458
+ * according to `search_path`. Comparing that text to the text we generated
5459
+ * would report drift on wording, and this comparison decides whether a boot
5460
+ * refuses, so a false positive is an outage. The stamp is written by the same
5461
+ * code that writes the column, so equality means what it says.
5462
+ */
5463
+ var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
5464
+ /**
5465
+ * The stamps for a spec's generated columns — one per column, never shared.
5466
+ *
5467
+ * Per column on purpose: turning `fuzzy` on adds a second column and changes
5468
+ * nothing about the first, and a spec-wide fingerprint would report the
5469
+ * untouched `tsvector` column as drifted and refuse a boot over a change that
5470
+ * is purely additive.
5471
+ */
5472
+ var searchColumnStamps = (spec) => {
5473
+ const stamp = (column, expression) => {
5474
+ const fingerprint = searchExpressionFingerprint(expression);
5475
+ return {
5476
+ column,
5477
+ expression,
5478
+ fingerprint,
5479
+ sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
5480
+ };
5481
+ };
5482
+ const stamps = [stamp(spec.column, spec.expression)];
5483
+ if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
5484
+ return stamps;
5485
+ };
5486
+ /**
4795
5487
  * The generated column names a collection's search block adds, if any.
4796
5488
  *
4797
5489
  * These are physical columns on the table, so `SELECT *` returns them. They are
@@ -4968,6 +5660,6 @@ function isAuthCollection(collection) {
4968
5660
  return typeof auth === "object" && auth !== null && auth.enabled === true;
4969
5661
  }
4970
5662
  //#endregion
4971
- export { resolveCollectionRelations as A, legacyForeignKeyName as B, securityRuleToConditions as C, getEnumVarName as D, getColumnName as E, createRelationRef as F, camelCase as G, getPolicyNamesForRule as H, createRelationRefWithData as I, DEFAULT_ONE_OF_VALUE as J, toSnakeCase as K, normalizeToEntityRelation as L, getDeclaredPrimaryKeys as M, isAddressableId as N, getTableName as O, parseIdValues as P, Vector as Q, updateDateAutoValues as R, policyToPostgres as S, findRelation as T, isPrototypePollutingKey as U, toPostgresIdentifier as V, mergeDeep as W, hasForeignKeyOnTarget as X, resolveClientListLimit as Y, isManyToMany as Z, resolveStringColumnLength as _, SEARCH_TEXT_FN as a, resolveJunctionSpecs as b, buildSearchColumnSpec as c, searchHelperFunctions as d, searchIndexStatements as f, relationalCollections as g, CollectionRegistry as h, isAuthCollection as i, buildCompositeId as j, getTableVarName as k, hiddenColumnsOption as l, buildSdkData as m, authUsersColumnDefinition as n, SEARCH_UNACCENT_FN as o, visibleColumnProjection as p, DEFAULT_ONE_OF_TYPE as q, authUsersColumnSql as r, assertSearchIsPostgresOnly as s, AUTH_USERS_COLUMNS as t, searchExtensionStatements as u, getJunctionCollectionConfig as v, findAnonymousGrants as w, getEffectiveSecurityRules as x, getJunctionSecurityRules as y, generateForeignKeyName as z };
5663
+ export { mergeDeep as $, fieldKeyForColumn as A, parseIdValues as B, getJunctionCollectionConfig as C, policyToPostgres as D, getEffectiveSecurityRules as E, getTableVarName as F, updateDateAutoValues as G, createRelationRef as H, resolveCollectionRelations as I, legacyForeignKeyName as J, firstFreeKey as K, buildCompositeId as L, getColumnName as M, getEnumVarName as N, securityRuleToConditions as O, getTableName as P, isPrototypePollutingKey as Q, getDeclaredPrimaryKeys as R, resolveStringColumnLength as S, resolveJunctionSpecs as T, createRelationRefWithData as U, sortCollectionsBySlug as V, normalizeToEntityRelation as W, toWireKey as X, toPostgresIdentifier as Y, getPolicyNamesForRule as Z, OrderBySpecError as _, SEARCH_STAMP_PREFIX as a, isManyToMany as at, CollectionRegistry as b, assertSearchIsPostgresOnly as c, searchColumnStamps as d, camelCase as et, searchExtensionStatements as f, buildSdkData as g, visibleColumnProjection as h, isAuthCollection as i, hasForeignKeyOnTarget as it, findRelation as j, findAnonymousGrants as k, buildSearchColumnSpec as l, searchIndexStatements as m, authUsersColumnDefinition as n, DEFAULT_ONE_OF_TYPE as nt, SEARCH_TEXT_FN as o, Vector as ot, searchHelperFunctions as p, generateForeignKeyName as q, authUsersColumnSql as r, DEFAULT_ONE_OF_VALUE as rt, SEARCH_UNACCENT_FN as s, AUTH_USERS_COLUMNS as t, toSnakeCase as tt, hiddenColumnsOption as u, normalizeDriverOrderBy as v, getJunctionSecurityRules as w, relationalCollections as x, parseOrderBySpecStrict as y, isAddressableId as z };
4972
5664
 
4973
- //# sourceMappingURL=auth-users-columns-Dt9g712t.js.map
5665
+ //# sourceMappingURL=auth-users-columns-C-FDnL_e.js.map