@rebasepro/server-postgres 0.13.1-canary.g18cfeb7 → 0.13.1-canary.g1bf9303

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 (88) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Do9mw5Y5.js → auth-users-columns-BxT6FRyu.js} +819 -68
  4. package/dist/auth-users-columns-BxT6FRyu.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-helpers.d.ts +56 -0
  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-CP5_YttS.js +124 -0
  13. package/dist/ensure-collection-policies-CP5_YttS.js.map +1 -0
  14. package/dist/{ensure-collection-tables-DSIxvLLD.js → ensure-collection-tables-DWVISfcC.js} +119 -13
  15. package/dist/ensure-collection-tables-DWVISfcC.js.map +1 -0
  16. package/dist/index.es.js +998 -600
  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-Ct9ab42o.js +425 -0
  21. package/dist/rls-enforcement-Ct9ab42o.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/generate-postgres-ddl-logic.d.ts +48 -0
  29. package/dist/schema/search-column.d.ts +248 -0
  30. package/dist/security/rls-enforcement.d.ts +8 -3
  31. package/dist/services/FetchService.d.ts +24 -0
  32. package/dist/services/RelationService.d.ts +30 -0
  33. package/dist/services/collection-helpers.d.ts +26 -0
  34. package/dist/services/dataService.d.ts +3 -0
  35. package/dist/services/realtimeService.d.ts +139 -2
  36. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  37. package/dist/src-DCdn3Val.js.map +1 -0
  38. package/dist/utils/drizzle-conditions.d.ts +124 -2
  39. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  40. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  41. package/package.json +6 -6
  42. package/src/PostgresBackendDriver.ts +7 -3
  43. package/src/PostgresBootstrapper.ts +95 -9
  44. package/src/auth/ensure-tables.ts +27 -5
  45. package/src/auth/services.ts +82 -5
  46. package/src/backup/backup-cli.ts +59 -57
  47. package/src/cli-errors.ts +6 -6
  48. package/src/cli-helpers.ts +110 -4
  49. package/src/cli-output.ts +43 -0
  50. package/src/cli.ts +210 -145
  51. package/src/collections/buildRegistry.ts +3 -1
  52. package/src/data-transformer.ts +107 -14
  53. package/src/history/ensure-history-table.ts +2 -2
  54. package/src/schema/auth-schema.ts +17 -1
  55. package/src/schema/doctor-cli.ts +14 -65
  56. package/src/schema/doctor-policy-checks.ts +105 -0
  57. package/src/schema/doctor.ts +149 -72
  58. package/src/schema/ensure-collection-policies.ts +99 -6
  59. package/src/schema/ensure-collection-tables.ts +286 -13
  60. package/src/schema/generate-drizzle-schema-logic.ts +126 -42
  61. package/src/schema/generate-drizzle-schema.ts +11 -10
  62. package/src/schema/generate-postgres-ddl-logic.ts +204 -10
  63. package/src/schema/generate-postgres-ddl.ts +38 -14
  64. package/src/schema/introspect-db-logic.ts +65 -33
  65. package/src/schema/introspect-db.ts +24 -24
  66. package/src/schema/search-column.ts +643 -0
  67. package/src/security/rls-enforcement.ts +9 -4
  68. package/src/services/FetchService.ts +229 -15
  69. package/src/services/PersistService.ts +24 -1
  70. package/src/services/RelationService.ts +119 -69
  71. package/src/services/cdc/trigger-cdc.ts +5 -1
  72. package/src/services/channel-history.ts +9 -3
  73. package/src/services/channel-presence.ts +10 -3
  74. package/src/services/collection-helpers.ts +54 -1
  75. package/src/services/dataService.ts +3 -0
  76. package/src/services/pg-notify-listener.ts +1 -1
  77. package/src/services/realtimeService.ts +347 -86
  78. package/src/utils/drizzle-conditions.ts +365 -23
  79. package/src/utils/pg-error-utils.ts +8 -3
  80. package/src/websocket.ts +113 -16
  81. package/dist/auth-users-columns-Do9mw5Y5.js.map +0 -1
  82. package/dist/ensure-collection-policies-DMUdRQdM.js +0 -57
  83. package/dist/ensure-collection-policies-DMUdRQdM.js.map +0 -1
  84. package/dist/ensure-collection-tables-DSIxvLLD.js.map +0 -1
  85. package/dist/policy-CPkCqVTz.js +0 -105
  86. package/dist/policy-CPkCqVTz.js.map +0 -1
  87. package/dist/src-C_wvdMnl.js.map +0 -1
  88. 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";
@@ -1408,6 +1390,28 @@ function legacyForeignKeyName(name) {
1408
1390
  const snake = toSnakeCase(name);
1409
1391
  return `${snake.endsWith("s") ? snake.slice(0, -1) : snake}_id`;
1410
1392
  }
1393
+ /**
1394
+ * Truncate an identifier to what Postgres will actually store.
1395
+ *
1396
+ * Postgres silently truncates identifiers at NAMEDATALEN-1 = 63 **bytes**, so a
1397
+ * name generated longer than that is not the name the database ends up holding.
1398
+ * Anything that later looks the object up by the name it generated then misses.
1399
+ *
1400
+ * Byte length, not string length: NAMEDATALEN is a byte bound, and a multi-byte
1401
+ * character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
1402
+ *
1403
+ * `TextEncoder` rather than `Buffer`, which is not a matter of taste: `Buffer`
1404
+ * is a Node global, and this package is imported by browser-facing ones. It
1405
+ * typechecked only where `@types/node` happened to be in scope, so
1406
+ * `packages/codegen` — whose tsconfig is `lib: ["ESNext", "dom"]` — could not
1407
+ * compile the file at all, and both of its suites failed to run. `TextEncoder`
1408
+ * and `TextDecoder` are standard in both runtimes and need no ambient types.
1409
+ */
1410
+ function toPostgresIdentifier(name) {
1411
+ const bytes = new TextEncoder().encode(name);
1412
+ if (bytes.byteLength <= 63) return name;
1413
+ return new TextDecoder("utf-8").decode(bytes.subarray(0, 63)).replace(/�+$/, "");
1414
+ }
1411
1415
  //#endregion
1412
1416
  //#region ../common/src/util/entities.ts
1413
1417
  /**
@@ -1517,6 +1521,25 @@ function createRelationRefWithData(id, path, data) {
1517
1521
  data
1518
1522
  };
1519
1523
  }
1524
+ //#endregion
1525
+ //#region ../common/src/util/collections.ts
1526
+ /**
1527
+ * A copy of `collections` ordered by slug.
1528
+ *
1529
+ * Every generator that turns collections into a file is order-dependent, and
1530
+ * every one of them is compared against its own output — `rebase doctor`
1531
+ * regenerates in memory and diffs, `generate-sdk && git diff --exit-code` gates
1532
+ * CI. While only the *writers* sorted, a project whose `readdirSync` order
1533
+ * differed from its slug order was reported permanently out of date, and the
1534
+ * fix the message printed rewrote the file in the order it was already in. The
1535
+ * generators sort themselves now, so no caller can get this wrong.
1536
+ *
1537
+ * A slug-less collection is left to the generator's own validation, which names
1538
+ * the offending collection; sorting must not throw first.
1539
+ */
1540
+ function sortCollectionsBySlug(collections) {
1541
+ return [...collections].sort((a, b) => (a.slug ?? "").localeCompare(b.slug ?? ""));
1542
+ }
1520
1543
  /** The eight-four-four-four-twelve shape of a UUID, any version. */
1521
1544
  var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1522
1545
  /** Whether one address part can be a value of the column it addresses. */
@@ -2092,11 +2115,32 @@ function sqlToPolicy(sql) {
2092
2115
  * is the more dangerous spelling, because it inverts a rule instead of
2093
2116
  * emptying a table.
2094
2117
  */
2095
- var FOREIGN_CONVENTION_UIDS = {
2096
- anon: "Supabase",
2097
- authenticated: "Supabase",
2098
- service_role: "Supabase"
2099
- };
2118
+ /**
2119
+ * A `Map`, not an object literal.
2120
+ *
2121
+ * As `Record<string, string>` this was indexed with a literal taken straight
2122
+ * out of a policy, so every key on `Object.prototype` answered: a rule
2123
+ * comparing `rebase.uid()` to `"valueOf"`, `"toString"`, `"constructor"` or
2124
+ * `"hasOwnProperty"` found a truthy "platform" and reported an anonymous-grant
2125
+ * risk that does not exist — with the matched function interpolated into the
2126
+ * explanation as the platform's name. A security warning that fires on
2127
+ * innocent input is worse than none: it is what teaches people to skip the
2128
+ * warnings that are real.
2129
+ *
2130
+ * Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,
2131
+ * `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain
2132
+ * object. Found by a property test, on the input `"valueOf"`.
2133
+ */
2134
+ var FOREIGN_CONVENTION_UIDS = /* @__PURE__ */ new Map([
2135
+ ["anon", "Supabase"],
2136
+ ["authenticated", "Supabase"],
2137
+ ["service_role", "Supabase"]
2138
+ ]);
2139
+ /**
2140
+ * The same foreign literals, as a pattern for SQL that could not be parsed
2141
+ * back into structure.
2142
+ */
2143
+ var FOREIGN_UID_LITERAL_SQL = new RegExp(String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`, "i");
2100
2144
  /**
2101
2145
  * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
2102
2146
  *
@@ -2143,17 +2187,27 @@ function findAnonymousGrants(expr) {
2143
2187
  case "existsIn":
2144
2188
  visit(e.where);
2145
2189
  return;
2146
- case "raw":
2190
+ case "raw": {
2147
2191
  if (UID_NOT_NULL.test(e.sql)) found.push({
2148
2192
  pattern: "uid-not-null",
2149
2193
  detail: e.sql,
2150
2194
  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".`
2151
2195
  });
2196
+ const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);
2197
+ if (foreign) {
2198
+ const literal = foreign[1];
2199
+ found.push({
2200
+ pattern: "foreign-uid-literal",
2201
+ detail: literal,
2202
+ 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".`
2203
+ });
2204
+ }
2152
2205
  return;
2206
+ }
2153
2207
  case "compare": {
2154
2208
  const literal = [e.left, e.right].find((o) => o.kind === "literal");
2155
2209
  if (!(e.left.kind === "authUid" || e.right.kind === "authUid") || typeof literal?.value !== "string") return;
2156
- const platform = FOREIGN_CONVENTION_UIDS[literal.value];
2210
+ const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);
2157
2211
  if (!platform) return;
2158
2212
  found.push({
2159
2213
  pattern: "foreign-uid-literal",
@@ -2169,12 +2223,43 @@ function findAnonymousGrants(expr) {
2169
2223
  return found;
2170
2224
  }
2171
2225
  function parseOperand(str) {
2172
- if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /rebase\.uid\(\)/i.test(str)) return policy.authUid();
2173
- const stringMatch = str.match(/^'(.+)'$/);
2174
- if (stringMatch) return policy.literal(stringMatch[1]);
2175
- if (/^\w+$/.test(str)) return policy.field(str);
2226
+ if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
2227
+ const literal = parseSingleQuoted(str);
2228
+ if (literal !== null) return policy.literal(literal);
2229
+ if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
2230
+ if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
2231
+ if (/^true$/i.test(str)) return policy.literal(true);
2232
+ if (/^false$/i.test(str)) return policy.literal(false);
2233
+ if (/^null$/i.test(str)) return policy.literal(null);
2234
+ if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
2176
2235
  return null;
2177
2236
  }
2237
+ /**
2238
+ * Decode a single-quoted SQL literal, or null when `str` is not exactly one.
2239
+ *
2240
+ * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
2241
+ * operator, not one literal whose body contains a quote, and a regex anchored
2242
+ * on the outer quotes would happily read it as the latter. Every interior quote
2243
+ * must therefore be part of a `''` pair.
2244
+ */
2245
+ function parseSingleQuoted(str) {
2246
+ if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
2247
+ const body = str.slice(1, -1);
2248
+ let out = "";
2249
+ for (let i = 0; i < body.length; i++) {
2250
+ if (body[i] !== "'") {
2251
+ out += body[i];
2252
+ continue;
2253
+ }
2254
+ if (body[i + 1] === "'") {
2255
+ out += "'";
2256
+ i++;
2257
+ continue;
2258
+ }
2259
+ return null;
2260
+ }
2261
+ return out;
2262
+ }
2178
2263
  //#endregion
2179
2264
  //#region ../common/src/util/policy/securityRuleToConditions.ts
2180
2265
  /**
@@ -2332,8 +2417,14 @@ function rolesArraySql(roles) {
2332
2417
  * Rebase's enforcement model is unified: authenticated (user-context) requests
2333
2418
  * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
2334
2419
  * statement — reads and writes. A collection's `securityRules` are the whole
2335
- * authorization model. The server context (auth flows, migrations,
2336
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
2420
+ * authorization model. The server context (auth flows, migrations, raw
2421
+ * `rebase.sql`) runs as the owner and bypasses RLS.
2422
+ *
2423
+ * `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
2424
+ * `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
2425
+ * other caller and clears the baseline below through the *admin* arm, not the
2426
+ * server arm. Which is why `disableDefaultPolicies` plus a lone
2427
+ * `policy.serverContext()` rule locks it out too.
2337
2428
  *
2338
2429
  * Because RLS default-denies, every collection is **locked by default**: with
2339
2430
  * no rules, only the server context and admins can touch it. The generator
@@ -2391,11 +2482,28 @@ function getIdPropertyName$1(collection) {
2391
2482
  *
2392
2483
  * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
2393
2484
  */
2485
+ /**
2486
+ * The restrictive write gate for an auth collection.
2487
+ *
2488
+ * Restrictive, so it is ANDed with everything else: whatever an author's
2489
+ * permissive rules allow, a write to this table still has to satisfy this too.
2490
+ * It is the only thing standing between "users may edit their own row" and
2491
+ * "users may grant themselves any role".
2492
+ */
2493
+ function adminWriteGate(tableName) {
2494
+ return {
2495
+ name: `${tableName}_require_admin_write`,
2496
+ mode: "restrictive",
2497
+ operations: [...DEFAULT_GUARDED_OPS],
2498
+ condition: SERVER_OR_ADMIN_EXPR$1,
2499
+ check: SERVER_OR_ADMIN_EXPR$1
2500
+ };
2501
+ }
2394
2502
  function getEffectiveSecurityRules(collection) {
2395
2503
  const explicit = [...collection.securityRules ?? []];
2396
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return explicit;
2397
2504
  const tableName = getTableName(collection);
2398
2505
  const injected = [];
2506
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection$1(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
2399
2507
  injected.push({
2400
2508
  name: `${tableName}_default_admin_read`,
2401
2509
  operations: ["select"],
@@ -2413,13 +2521,7 @@ function getEffectiveSecurityRules(collection) {
2413
2521
  operations: ["select"],
2414
2522
  condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
2415
2523
  });
2416
- injected.push({
2417
- name: `${tableName}_require_admin_write`,
2418
- mode: "restrictive",
2419
- operations: [...DEFAULT_GUARDED_OPS],
2420
- condition: SERVER_OR_ADMIN_EXPR$1,
2421
- check: SERVER_OR_ADMIN_EXPR$1
2422
- });
2524
+ injected.push(adminWriteGate(tableName));
2423
2525
  }
2424
2526
  return [...explicit, ...injected];
2425
2527
  }
@@ -3708,8 +3810,24 @@ var QueryBuilder = class {
3708
3810
  /**
3709
3811
  * Set a free-text search string if supported by the backend.
3710
3812
  */
3711
- search(searchString) {
3813
+ search(searchString, options) {
3712
3814
  this.params.searchString = searchString;
3815
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
3816
+ return this;
3817
+ }
3818
+ /**
3819
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
3820
+ *
3821
+ * Postgres only, over a property declared as `type: "vector"`. Rows come
3822
+ * back with a `_distance`; `where` filters before the ordering.
3823
+ */
3824
+ vectorSearch(property, vector, options) {
3825
+ this.params.vectorSearch = {
3826
+ property,
3827
+ vector,
3828
+ ...options?.distance !== void 0 && { distance: options.distance },
3829
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
3830
+ };
3713
3831
  return this;
3714
3832
  }
3715
3833
  /**
@@ -3909,21 +4027,50 @@ async function collectAllPages(find, params, label = "collection") {
3909
4027
  * metadata, so type coercion is the responsibility of the server-side data
3910
4028
  * driver which has access to the collection schema.
3911
4029
  *
3912
- * Commas inside list values are backslash-escaped (`\,`), and literal
3913
- * backslashes are escaped as `\\`.
4030
+ * Structural characters inside a value are backslash-escaped: `,` → `\,`,
4031
+ * `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
4032
+ * deliberately conservative — only those four sequences are decoded, so a
4033
+ * backslash that arrives unescaped from an older client survives intact.
3914
4034
  *
3915
4035
  * @module
3916
4036
  */
3917
4037
  /**
3918
- * Unescape a single list item from the wire format.
3919
- * `\\` → `\`, `\,` → `,`
4038
+ * Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
4039
+ * `)` → `\)`.
3920
4040
  */
3921
- function unescapeListItem(value) {
4041
+ /**
4042
+ * The wire spelling of an empty list.
4043
+ *
4044
+ * A lone backslash: unproducible by {@link escapeWireValue}, which doubles
4045
+ * every backslash it emits, so it cannot collide with any real item.
4046
+ */
4047
+ var EMPTY_LIST_TOKEN = "\\";
4048
+ /**
4049
+ * Unescape a wire-format value.
4050
+ *
4051
+ * **Conservative**, and deliberately so: only the four sequences
4052
+ * {@link escapeWireValue} actually produces are decoded. A backslash followed
4053
+ * by anything else is left exactly as it is.
4054
+ *
4055
+ * This used to consume the backslash before *any* character, which is
4056
+ * indistinguishable for anything this codec emitted — it only ever emits those
4057
+ * four — but not for input arriving from elsewhere. A client on an older
4058
+ * release sends a Windows path or a LIKE pattern with a literal `C:\x`
4059
+ * unescaped, and greedy unescaping silently turned it into `C:x`, changing
4060
+ * which rows matched. Decoding only what the encoder can produce makes the two
4061
+ * directions agree across versions.
4062
+ */
4063
+ function unescapeWireValue(value) {
3922
4064
  let result = "";
3923
- for (let i = 0; i < value.length; i++) if (value[i] === "\\" && i + 1 < value.length) {
3924
- result += value[i + 1];
3925
- i++;
3926
- } else result += value[i];
4065
+ for (let i = 0; i < value.length; i++) {
4066
+ const next = value[i + 1];
4067
+ if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
4068
+ result += next;
4069
+ i++;
4070
+ continue;
4071
+ }
4072
+ result += value[i];
4073
+ }
3927
4074
  return result;
3928
4075
  }
3929
4076
  /**
@@ -3941,13 +4088,205 @@ function splitListItems(inner) {
3941
4088
  current += inner[i] + inner[i + 1];
3942
4089
  i++;
3943
4090
  } else if (inner[i] === ",") {
3944
- items.push(unescapeListItem(current));
4091
+ items.push(unescapeWireValue(current));
3945
4092
  current = "";
3946
4093
  } else current += inner[i];
3947
- items.push(unescapeListItem(current));
4094
+ items.push(unescapeWireValue(current));
3948
4095
  return items;
3949
4096
  }
3950
- var REST_OP_LOOKUP = REST_TO_CANONICAL;
4097
+ /**
4098
+ * Operator tables as `Map`s, because the key comes off the wire.
4099
+ *
4100
+ * Indexed as plain objects, every `Object.prototype` member answered: a query
4101
+ * string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
4102
+ * and `deserializeTuple` returned it *as the operator*, so a function object
4103
+ * travelled on into the compilers in place of a `WhereFilterOp`. The guard one
4104
+ * line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
4105
+ * and does not: `Object.prototype` is not unknown to a plain object.
4106
+ *
4107
+ * Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
4108
+ * `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
4109
+ */
4110
+ var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
4111
+ new Map(Object.entries(CANONICAL_TO_REST));
4112
+ /** The operator spellings a rejection lists back to the caller. */
4113
+ var VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
4114
+ /**
4115
+ * A filter condition named an operator this dialect does not have.
4116
+ *
4117
+ * ## Why this throws, rather than returning a typed rejection
4118
+ *
4119
+ * `deserializeFilter` is the *shared* codec: the REST ingress
4120
+ * (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
4121
+ * admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
4122
+ * follow.
4123
+ *
4124
+ * - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
4125
+ * depend on `@rebasepro/server` (the dependency runs the other way), and a
4126
+ * browser client has no error handler to render an `ApiError` with. So the
4127
+ * rejection is this plain `Error` subclass, whose `message` reads correctly
4128
+ * wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
4129
+ * - It cannot be a returned rejection *value*. Every caller assigns the result
4130
+ * straight into a query it is about to run; a sentinel that none of them
4131
+ * check would be ignored, which is exactly the silently-wrong-filter failure
4132
+ * this exists to stop. Throwing is also what this file already does for the
4133
+ * sibling cases — `serializeTuple` on an unknown canonical operator,
4134
+ * `deserializeLogicalCondition` past the nesting bound — and the REST parser
4135
+ * already converts the latter into a 400.
4136
+ *
4137
+ * `statusCode`, `code` and `details` are carried as fields because the server's
4138
+ * Hono error handler duck-types those off any thrown error: a decode path that
4139
+ * forgets to convert still answers 400 with the canonical envelope instead of a
4140
+ * 500 that says "An unexpected error occurred". `query-parser.ts` converts
4141
+ * explicitly all the same — that is the path the contract is stated on, and an
4142
+ * incidental 400 is not a contract.
4143
+ */
4144
+ var UnknownFilterOperatorError = class extends Error {
4145
+ /** The field the condition was written against. */
4146
+ field;
4147
+ /** The operator string as it arrived, verbatim. */
4148
+ operator;
4149
+ /** Every operator this dialect accepts, in canonical spelling. */
4150
+ validOperators = ALL_WHERE_FILTER_OPS;
4151
+ /** See the class docblock: read by the server's error handler. */
4152
+ statusCode = 400;
4153
+ code = "UNKNOWN_FILTER_OPERATOR";
4154
+ details;
4155
+ constructor(field, operator) {
4156
+ super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
4157
+ this.name = "UnknownFilterOperatorError";
4158
+ this.field = field;
4159
+ this.operator = operator;
4160
+ this.details = {
4161
+ field,
4162
+ operator,
4163
+ validOperators: ALL_WHERE_FILTER_OPS
4164
+ };
4165
+ }
4166
+ };
4167
+ /**
4168
+ * Two to three characters of ASCII punctuation and nothing else — the shape
4169
+ * every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
4170
+ * one a column value effectively never has.
4171
+ *
4172
+ * Two characters minimum on purpose. A *single* punctuation character is a
4173
+ * perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
4174
+ * condition — and the only single-character operator anyone actually mistypes
4175
+ * is `=`, which is named separately below. `<` and `>` need no special case:
4176
+ * they are real operators and resolve.
4177
+ */
4178
+ var SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
4179
+ /** Lowercase, strip everything that is not a letter or digit. */
4180
+ function normalizeOperatorName(op) {
4181
+ return op.toLowerCase().replace(/[^a-z0-9]/g, "");
4182
+ }
4183
+ /**
4184
+ * Every real operator name with its case and separators removed, so a
4185
+ * respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
4186
+ * recognised as an attempt at an operator rather than read as a value.
4187
+ *
4188
+ * These are rejected rather than accepted: admitting a second spelling of an
4189
+ * operator would leave two wire spellings of one thing, and the rejection
4190
+ * message names the one that works.
4191
+ */
4192
+ var RESPELLED_OPERATORS = new Set([...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName));
4193
+ /**
4194
+ * Operator names *other* query dialects use, which this one does not have.
4195
+ *
4196
+ * This list is curated, and deliberately so. For a word-shaped string there is
4197
+ * no rule that separates "an operator the caller guessed" from "a value that
4198
+ * happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
4199
+ * `in` list, so the codec cannot simply refuse every unrecognised word in
4200
+ * position 0. The line is therefore drawn by name, and only around names whose
4201
+ * use as an operator is far more likely than their use as one of two sibling
4202
+ * values. `contains` is the motivating case — the first thing a developer
4203
+ * reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
4204
+ *
4205
+ * Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
4206
+ * are left off: as operators they are rare, and as enum values they are common.
4207
+ * Everywhere else the tie goes to *rejecting*, because a 400 naming the
4208
+ * supported set costs the caller one round trip, and the alternative — which is
4209
+ * what every name on this list used to produce — is a query that runs, returns
4210
+ * rows, and is wrong.
4211
+ */
4212
+ var NEAR_MISS_OPERATORS = /* @__PURE__ */ new Set([
4213
+ "contains",
4214
+ "notcontains",
4215
+ "doesnotcontain",
4216
+ "doesnotcontains",
4217
+ "includes",
4218
+ "notincludes",
4219
+ "startswith",
4220
+ "notstartswith",
4221
+ "beginswith",
4222
+ "startingwith",
4223
+ "endswith",
4224
+ "notendswith",
4225
+ "matches",
4226
+ "notmatches",
4227
+ "regex",
4228
+ "regexp",
4229
+ "between",
4230
+ "notbetween",
4231
+ "equals",
4232
+ "notequals",
4233
+ "equalto",
4234
+ "isequalto",
4235
+ "isnotequalto",
4236
+ "greaterthan",
4237
+ "greaterthanorequal",
4238
+ "greaterthanorequalto",
4239
+ "lessthan",
4240
+ "lessthanorequal",
4241
+ "lessthanorequalto",
4242
+ "isempty",
4243
+ "isnotempty",
4244
+ "oneof",
4245
+ "noneof",
4246
+ "anyof",
4247
+ "allof",
4248
+ "null",
4249
+ "isnullorempty"
4250
+ ]);
4251
+ /**
4252
+ * Was this string *meant* as an operator?
4253
+ *
4254
+ * Only consulted after {@link toCanonicalOp} has already failed to resolve it,
4255
+ * so a `true` here is always a rejection.
4256
+ */
4257
+ function isOperatorShaped(op) {
4258
+ if (op === "=") return true;
4259
+ if (SYMBOLIC_OPERATOR.test(op)) return true;
4260
+ const normalized = normalizeOperatorName(op);
4261
+ if (!normalized) return false;
4262
+ return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
4263
+ }
4264
+ /**
4265
+ * Read a `[op, value]` tuple, if that is what this is.
4266
+ *
4267
+ * Three outcomes, and the middle one is the defect this function exists for:
4268
+ *
4269
+ * - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
4270
+ * - the operator does not resolve but was plainly meant as one → throw;
4271
+ * - it does not look like an operator at all → `undefined`, and the caller
4272
+ * falls back to reading the array as a list of values.
4273
+ *
4274
+ * The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
4275
+ * only, with *everything else* — including every REST short-code — dropping
4276
+ * through to `["in", raw]`. So the operator string itself became a value in a
4277
+ * membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
4278
+ * which matches, and the caller got back rows their filter was written to
4279
+ * exclude. `["eq", "active"]` had the same shape of failure.
4280
+ */
4281
+ function readTuple(field, raw) {
4282
+ if (!Array.isArray(raw) || raw.length !== 2) return void 0;
4283
+ const [op, value] = raw;
4284
+ if (typeof op !== "string") return void 0;
4285
+ const canonical = toCanonicalOp(op);
4286
+ if (canonical) return [canonical, value];
4287
+ if (op.includes(".")) return void 0;
4288
+ if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
4289
+ }
3951
4290
  /**
3952
4291
  * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
3953
4292
  *
@@ -3964,10 +4303,13 @@ function deserializeSingle(raw) {
3964
4303
  if (dotIndex === -1) return ["==", raw];
3965
4304
  const prefix = raw.substring(0, dotIndex);
3966
4305
  const rest = raw.substring(dotIndex + 1);
3967
- const canonicalOp = REST_OP_LOOKUP[prefix];
4306
+ const canonicalOp = REST_OP_LOOKUP.get(prefix);
3968
4307
  if (!canonicalOp) return ["==", raw];
3969
4308
  if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
3970
- if (rest.startsWith("(") && rest.endsWith(")")) return [canonicalOp, splitListItems(rest.slice(1, -1))];
4309
+ if (rest.startsWith("(") && rest.endsWith(")")) {
4310
+ const inner = rest.slice(1, -1);
4311
+ return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
4312
+ }
3971
4313
  return [canonicalOp, rest];
3972
4314
  }
3973
4315
  /**
@@ -3982,20 +4324,27 @@ function deserializeSingle(raw) {
3982
4324
  *
3983
4325
  * deserializeFilter({ age: ["gte.18", "lt.65"] })
3984
4326
  * // → { age: [[">=", "18"], ["<", "65"]] }
4327
+ *
4328
+ * @throws {UnknownFilterOperatorError} when a condition names an operator this
4329
+ * dialect does not have. See that class for why a rejection here is a throw.
3985
4330
  */
3986
4331
  function deserializeFilter(query) {
3987
4332
  const result = {};
3988
4333
  for (const [field, raw] of Object.entries(query)) {
3989
4334
  if (raw === void 0) continue;
3990
- if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
3991
- result[field] = raw;
4335
+ const tuple = readTuple(field, raw);
4336
+ if (tuple) {
4337
+ result[field] = tuple;
3992
4338
  continue;
3993
4339
  }
3994
4340
  if (Array.isArray(raw)) {
3995
4341
  if (raw.length === 0) continue;
3996
- if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
3997
- result[field] = raw;
3998
- continue;
4342
+ if (Array.isArray(raw[0])) {
4343
+ const tuples = raw.map((item) => readTuple(field, item));
4344
+ if (tuples.every((t) => t !== void 0)) {
4345
+ result[field] = tuples;
4346
+ continue;
4347
+ }
3999
4348
  }
4000
4349
  if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
4001
4350
  else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
@@ -4039,10 +4388,12 @@ function createPrimaryKeyResolver(options) {
4039
4388
  * than postgres still serve rows with one, and this keeps them working.
4040
4389
  */
4041
4390
  function rowToEntity(row, slug, primaryKeys = []) {
4391
+ const { _matches, ...values } = row;
4042
4392
  return {
4043
4393
  id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
4044
4394
  path: slug,
4045
- values: row
4395
+ values,
4396
+ ..._matches ? { searchMatches: _matches } : {}
4046
4397
  };
4047
4398
  }
4048
4399
  /**
@@ -4200,6 +4551,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4200
4551
  orderBy: params?.orderBy?.[0],
4201
4552
  order: params?.orderBy?.[1],
4202
4553
  searchString: params?.searchString,
4554
+ searchExplain: params?.searchExplain,
4203
4555
  onUpdate: (entities) => {
4204
4556
  onUpdate({
4205
4557
  data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
@@ -4237,8 +4589,11 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4237
4589
  offset(count) {
4238
4590
  return new QueryBuilder(accessor).offset(count);
4239
4591
  },
4240
- search(searchString) {
4241
- return new QueryBuilder(accessor).search(searchString);
4592
+ search(searchString, options) {
4593
+ return new QueryBuilder(accessor).search(searchString, options);
4594
+ },
4595
+ vectorSearch(property, vector, options) {
4596
+ return new QueryBuilder(accessor).vectorSearch(property, vector, options);
4242
4597
  },
4243
4598
  include(...relations) {
4244
4599
  return new QueryBuilder(accessor).include(...relations);
@@ -4326,8 +4681,18 @@ var SdkQueryBuilder = class {
4326
4681
  this.params.offset = count;
4327
4682
  return this;
4328
4683
  }
4329
- search(searchString) {
4684
+ search(searchString, options) {
4330
4685
  this.params.searchString = searchString;
4686
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
4687
+ return this;
4688
+ }
4689
+ vectorSearch(property, vector, options) {
4690
+ this.params.vectorSearch = {
4691
+ property,
4692
+ vector,
4693
+ ...options?.distance !== void 0 && { distance: options.distance },
4694
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
4695
+ };
4331
4696
  return this;
4332
4697
  }
4333
4698
  include(...relations) {
@@ -4414,6 +4779,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
4414
4779
  limit: (count) => new SdkQueryBuilder(client).limit(count),
4415
4780
  offset: (count) => new SdkQueryBuilder(client).offset(count),
4416
4781
  search: (searchString) => new SdkQueryBuilder(client).search(searchString),
4782
+ vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
4417
4783
  include: (...relations) => new SdkQueryBuilder(client).include(...relations)
4418
4784
  };
4419
4785
  return client;
@@ -4459,6 +4825,391 @@ function buildSdkData(driver) {
4459
4825
  return wrapAsSdkData(buildRebaseData(driver));
4460
4826
  }
4461
4827
  //#endregion
4828
+ //#region src/schema/search-column.ts
4829
+ /**
4830
+ * The one place a collection's `search` block becomes SQL.
4831
+ *
4832
+ * Four things describe a Postgres table in this codebase — the DDL generator,
4833
+ * the Drizzle schema generator, the runtime table builder for BaaS mode, and
4834
+ * the boot-time schema ensure — and each of them has, at some point, described
4835
+ * a column differently from the others. The `varchar(255)` note in
4836
+ * `generate-postgres-ddl-logic` is one such scar: the same property produced a
4837
+ * capped column down one path and an uncapped one down the other, and nothing
4838
+ * failed until a user hit the cap.
4839
+ *
4840
+ * So the search column is not implemented four times. It is computed once,
4841
+ * here, and every generator renders the same {@link SearchColumnSpec}. There is
4842
+ * a test asserting exactly that (`search-column-contract.test.ts`); the point of
4843
+ * this module is that the test has something to assert *about*.
4844
+ *
4845
+ * ## Why the expressions look the way they do
4846
+ *
4847
+ * A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
4848
+ * Postgres is stricter here than intuition. Verified against PostgreSQL 18:
4849
+ *
4850
+ * | expression | immutable |
4851
+ * |-----------------------------------------|-----------|
4852
+ * | `to_tsvector('spanish', col)` | yes |
4853
+ * | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
4854
+ * | `array_to_string(col, ' ')` | **no** |
4855
+ * | `col::text` on `text[]` | **no** |
4856
+ * | `to_jsonb(col)` | **no** |
4857
+ * | `unaccent(col)` | **no** — dictionary lookup is STABLE |
4858
+ * | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
4859
+ * | `setweight(...) || setweight(...)` | yes |
4860
+ *
4861
+ * Three of the four things a real search column needs are therefore unavailable
4862
+ * directly, which is why {@link searchHelperFunctions} exists: each wraps a
4863
+ * stable built-in in an SQL function declared IMMUTABLE. That declaration is a
4864
+ * promise, and it is a true one for these three — array joining, JSON string
4865
+ * extraction and accent folding are all deterministic for a given input; the
4866
+ * built-ins are marked stable only because they must account for element types
4867
+ * and dictionaries in general.
4868
+ *
4869
+ * The alternative was to skip `unaccent` and text arrays entirely. That is not
4870
+ * a real option in an accented language: Postgres stems `auditoría` to
4871
+ * `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
4872
+ * typed without accents misses every row that carries them.
4873
+ */
4874
+ /** Schema-qualified so a collection outside `public` still resolves them. */
4875
+ var HELPER_SCHEMA = "public";
4876
+ /**
4877
+ * Names of the helper functions. Frozen: they are recorded in the stored
4878
+ * generation expression of every search column ever created, so renaming one
4879
+ * orphans every table that already has a search column.
4880
+ */
4881
+ var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
4882
+ var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
4883
+ /** Raised when a `search` block names something that cannot be searched. */
4884
+ var SearchConfigError = class extends Error {
4885
+ constructor(message) {
4886
+ super(message);
4887
+ this.name = "SearchConfigError";
4888
+ }
4889
+ };
4890
+ /** The `search` block of a collection, or undefined when it has none. */
4891
+ var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
4892
+ /**
4893
+ * Refuse a `search` block on a collection this engine does not store.
4894
+ *
4895
+ * The type only permits one on a `PostgresCollectionConfig`, so TypeScript
4896
+ * already stops the ordinary case. This catches the rest — a JS config, a cast,
4897
+ * a collection whose `engine` was changed after the block was written — because
4898
+ * the alternative is the exact failure the block exists to prevent: a developer
4899
+ * who declared what to index, saw no error, and got the substring fallback.
4900
+ *
4901
+ * Called with *every* collection, before the Postgres ones are filtered out.
4902
+ */
4903
+ var assertSearchIsPostgresOnly = (collections) => {
4904
+ for (const collection of collections) {
4905
+ if (isPostgresCollectionConfig(collection)) continue;
4906
+ if (!collection.search) continue;
4907
+ const engine = collection.engine ?? "non-postgres";
4908
+ throw new SearchConfigError(`${collection.slug}.search: full-text search is a Postgres feature, and this collection is served by \`${engine}\`. Remove the block — it would otherwise look configured while \`.search()\` kept using the default substring match.`);
4909
+ }
4910
+ };
4911
+ var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
4912
+ /**
4913
+ * Classify a property for search purposes.
4914
+ *
4915
+ * Deliberately narrower than `getSqlColumnType`: search only cares whether a
4916
+ * value reaches text, and the mapping from property to *physical* type is
4917
+ * asserted against `getSqlColumnType` in the contract test rather than
4918
+ * duplicated here.
4919
+ *
4920
+ * Returns null for anything that is not text-bearing, which the caller turns
4921
+ * into a boot error naming the property.
4922
+ */
4923
+ var classify = (prop) => {
4924
+ switch (prop.type) {
4925
+ case "string": {
4926
+ const sp = prop;
4927
+ if (sp.enum) return {
4928
+ kind: "text",
4929
+ reason: "enum"
4930
+ };
4931
+ if (sp.isId === "uuid" || sp.columnType === "uuid") return {
4932
+ kind: "text",
4933
+ reason: "uuid"
4934
+ };
4935
+ return { kind: "text" };
4936
+ }
4937
+ case "map":
4938
+ if (prop.columnType === "json") return {
4939
+ kind: "jsonb",
4940
+ reason: "json"
4941
+ };
4942
+ return { kind: "jsonb" };
4943
+ case "array": {
4944
+ const ap = prop;
4945
+ let colType = ap.columnType;
4946
+ if (!colType && ap.of && !Array.isArray(ap.of)) {
4947
+ const of = ap.of;
4948
+ if (of.type === "string") colType = "text[]";
4949
+ else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
4950
+ else if (of.type === "boolean") colType = "boolean[]";
4951
+ }
4952
+ if (colType === "text[]") return { kind: "text_array" };
4953
+ if (colType === "json") return {
4954
+ kind: "jsonb",
4955
+ reason: "json"
4956
+ };
4957
+ if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
4958
+ kind: "text_array",
4959
+ reason: "non_text_array"
4960
+ };
4961
+ return { kind: "jsonb" };
4962
+ }
4963
+ default: return null;
4964
+ }
4965
+ };
4966
+ var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
4967
+ /** SQL reading one field as plain text, before normalization. */
4968
+ var rawTextSql = (field) => {
4969
+ const col = `"${field.column}"`;
4970
+ if (field.kind === "text") return `coalesce(${col}, '')`;
4971
+ if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
4972
+ return `${SEARCH_TEXT_FN}(coalesce(${field.jsonPath.length === 0 ? col : field.jsonPath.length === 1 ? `${col} -> ${quote(field.jsonPath[0])}` : `${col} #> ${quote(`{${field.jsonPath.join(",")}}`)}`}, '{}'::jsonb))`;
4973
+ };
4974
+ var quote = (v) => `'${v.replace(/'/g, "''")}'`;
4975
+ /**
4976
+ * Resolve and validate one declared field path.
4977
+ *
4978
+ * A path that does not resolve throws. The whole point of an explicit block is
4979
+ * that the author knows what is indexed; a silently dropped field would make it
4980
+ * a guess again, and the failure — a search that returns nothing for content
4981
+ * that is plainly in the row — is invisible from the outside.
4982
+ */
4983
+ var resolveField = (entry, collection, cfg) => {
4984
+ const path = typeof entry === "string" ? entry : entry.path;
4985
+ const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? "B";
4986
+ const where = `${collection.slug}.search`;
4987
+ if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
4988
+ const [head, ...rest] = path.split(".");
4989
+ const prop = collection.properties?.[head];
4990
+ if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
4991
+ const classified = classify(prop);
4992
+ if (!classified) throw new SearchConfigError(`${where}: "${path}" is a \`${prop.type}\` property, which holds no text to search. Searchable kinds are \`string\`, \`string[]\` and \`map\` (or a path inside one).`);
4993
+ if (classified.reason === "enum") throw new SearchConfigError(`${where}: "${path}" is an enum. Enums are a fixed vocabulary — filter on them with \`where\` instead, which is exact and uses an index.`);
4994
+ if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
4995
+ if (classified.reason === "json") throw new SearchConfigError(`${where}: "${path}" is a \`json\` column, and the cast from \`json\` to \`jsonb\` is not immutable, so it cannot feed a generated column. Declare the property as \`jsonb\` (the default) to search it.`);
4996
+ if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
4997
+ if (rest.length > 0 && classified.kind !== "jsonb") throw new SearchConfigError(`${where}: "${path}" addresses a path inside "${head}", but "${head}" is a \`${prop.type}\` property, not a \`map\`. Only map properties have paths inside them.`);
4998
+ const column = columnNameOf(head, prop);
4999
+ const textSql = normalize(rawTextSql({
5000
+ column,
5001
+ jsonPath: rest,
5002
+ kind: classified.kind
5003
+ }), cfg.unaccent === true);
5004
+ const language = cfg.language ?? "simple";
5005
+ return {
5006
+ path,
5007
+ column,
5008
+ jsonPath: rest,
5009
+ kind: classified.kind,
5010
+ weight,
5011
+ sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
5012
+ textSql
5013
+ };
5014
+ };
5015
+ /**
5016
+ * Build the full spec for a collection, or undefined when it has not opted in.
5017
+ *
5018
+ * Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
5019
+ * at boot surface that as a startup failure — a search block that half-works is
5020
+ * worse than one that refuses.
5021
+ */
5022
+ var buildSearchColumnSpec = (collection) => {
5023
+ const cfg = getSearchConfig(collection);
5024
+ if (!cfg) return void 0;
5025
+ if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) throw new SearchConfigError(`${collection.slug}.search: \`fields\` is empty. Name the properties to index, or remove the \`search\` block to keep the default ILIKE behaviour.`);
5026
+ const table = getTableName(collection);
5027
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
5028
+ const column = cfg.column ?? "search_vector";
5029
+ if (collection.properties?.[column]) throw new SearchConfigError(`${collection.slug}.search: the generated column "${column}" collides with a declared property of the same name. Set \`search.column\` to something else.`);
5030
+ const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
5031
+ const seen = /* @__PURE__ */ new Set();
5032
+ for (const f of fields) {
5033
+ if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
5034
+ seen.add(f.path);
5035
+ }
5036
+ const extensions = [];
5037
+ if (cfg.unaccent) extensions.push("unaccent");
5038
+ if (cfg.fuzzy) extensions.push("pg_trgm");
5039
+ const spec = {
5040
+ schema,
5041
+ table,
5042
+ column,
5043
+ language: cfg.language ?? "simple",
5044
+ unaccent: cfg.unaccent === true,
5045
+ fields,
5046
+ expression: fields.map((f) => f.sql).join(" || "),
5047
+ indexName: toPostgresIdentifier(`${table}_${column}_gin`),
5048
+ extensions
5049
+ };
5050
+ if (cfg.fuzzy) {
5051
+ const fuzzyColumn = `${column}_text`;
5052
+ if (collection.properties?.[fuzzyColumn]) throw new SearchConfigError(`${collection.slug}.search: \`fuzzy\` needs the column "${fuzzyColumn}", which collides with a declared property. Set \`search.column\` to something else.`);
5053
+ spec.fuzzy = {
5054
+ column: fuzzyColumn,
5055
+ expression: fields.map((f) => f.textSql).join(" || ' ' || "),
5056
+ indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
5057
+ threshold: cfg.fuzzyThreshold ?? .3
5058
+ };
5059
+ }
5060
+ return spec;
5061
+ };
5062
+ /**
5063
+ * The IMMUTABLE wrappers the generated expressions call.
5064
+ *
5065
+ * `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
5066
+ * than an error, and idempotent for the same reason every other boot-time DDL
5067
+ * statement here is.
5068
+ *
5069
+ * The bodies are stable built-ins wrapped in an immutable promise — see the
5070
+ * module comment for why that promise is sound. `STRICT` matters: it makes NULL
5071
+ * in mean NULL out without executing the body, which is what the `coalesce` at
5072
+ * each call site then absorbs.
5073
+ */
5074
+ var searchHelperFunctions = (spec) => {
5075
+ const statements = [`CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(text[]) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT array_to_string($1, ' ') $$;`, `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(jsonb) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT coalesce(string_agg(v, ' '), '')\n FROM jsonb_array_elements_text(jsonb_path_query_array($1, 'strict $.**?(@.type() == "string")')) AS v $$;`];
5076
+ if (spec.unaccent) statements.push(`CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`);
5077
+ return statements;
5078
+ };
5079
+ /**
5080
+ * `CREATE EXTENSION` statements the spec's expressions depend on.
5081
+ *
5082
+ * `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
5083
+ * `CREATE EXTENSION` installs into the first schema on `search_path`, which
5084
+ * defaults to `"$user", public` — and the scaffold's database role is named
5085
+ * `rebase`, the same as the schema the generator creates one statement earlier.
5086
+ * So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
5087
+ * dictionary in `rebase`, and every reference to `public.unaccent` below fails
5088
+ * with "text search dictionary does not exist". Observed, not theorised.
5089
+ */
5090
+ var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
5091
+ /**
5092
+ * Index statements for the spec.
5093
+ *
5094
+ * `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
5095
+ * SQL file replayed as one unit — a migration, or `search.sql` — where a
5096
+ * concurrent build is not allowed. The boot-time ensure path runs statement by
5097
+ * statement against tables that are live and populated, and uses the
5098
+ * concurrent form instead; see `ensureSearchColumns`.
5099
+ */
5100
+ var searchIndexStatements = (spec) => {
5101
+ const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
5102
+ if (spec.fuzzy) statements.push(`CREATE INDEX IF NOT EXISTS "${spec.fuzzy.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.fuzzy.column}" ${HELPER_SCHEMA}.gin_trgm_ops);`);
5103
+ return statements;
5104
+ };
5105
+ /**
5106
+ * Marker on the comment of every generated search column this module creates.
5107
+ *
5108
+ * Versioned because the fingerprint below is only comparable against itself: a
5109
+ * future change to how it is computed has to read as "not stamped by this
5110
+ * version" rather than as drift on every existing column.
5111
+ */
5112
+ var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
5113
+ /**
5114
+ * A stable fingerprint of one generated column's expression.
5115
+ *
5116
+ * Why a stamp rather than reading the expression back: Postgres stores a
5117
+ * generated column's expression *parsed*, and hands it back deparsed — casts
5118
+ * made explicit, identifiers requoted, schema qualifications added or dropped
5119
+ * according to `search_path`. Comparing that text to the text we generated
5120
+ * would report drift on wording, and this comparison decides whether a boot
5121
+ * refuses, so a false positive is an outage. The stamp is written by the same
5122
+ * code that writes the column, so equality means what it says.
5123
+ */
5124
+ var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
5125
+ /**
5126
+ * The stamps for a spec's generated columns — one per column, never shared.
5127
+ *
5128
+ * Per column on purpose: turning `fuzzy` on adds a second column and changes
5129
+ * nothing about the first, and a spec-wide fingerprint would report the
5130
+ * untouched `tsvector` column as drifted and refuse a boot over a change that
5131
+ * is purely additive.
5132
+ */
5133
+ var searchColumnStamps = (spec) => {
5134
+ const stamp = (column, expression) => {
5135
+ const fingerprint = searchExpressionFingerprint(expression);
5136
+ return {
5137
+ column,
5138
+ expression,
5139
+ fingerprint,
5140
+ sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
5141
+ };
5142
+ };
5143
+ const stamps = [stamp(spec.column, spec.expression)];
5144
+ if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
5145
+ return stamps;
5146
+ };
5147
+ /**
5148
+ * The generated column names a collection's search block adds, if any.
5149
+ *
5150
+ * These are physical columns on the table, so `SELECT *` returns them. They are
5151
+ * an index in column form — a list of lexeme positions, or a concatenation of
5152
+ * every searchable field on the row — and nothing outside the query planner has
5153
+ * any use for them. Left in, every list response carries a second, larger copy
5154
+ * of the row's text.
5155
+ */
5156
+ var searchColumnNames = (collection) => {
5157
+ let spec;
5158
+ try {
5159
+ spec = buildSearchColumnSpec(collection);
5160
+ } catch {
5161
+ return [];
5162
+ }
5163
+ if (!spec) return [];
5164
+ return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
5165
+ };
5166
+ /**
5167
+ * True for a column whose type only ever holds a search index.
5168
+ *
5169
+ * Independent of any collection config on purpose: an introspected database
5170
+ * (BaaS mode) can carry a `tsvector` column this framework never created —
5171
+ * Pagila's `film.fulltext` is the canonical one — and it should not be returned
5172
+ * to callers either. `isDerivedIndexColumn` already keeps such a column out of
5173
+ * the *properties*; this keeps it out of the *rows*.
5174
+ */
5175
+ var isSearchIndexColumn = (column) => {
5176
+ const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
5177
+ return sqlType === "tsvector" || sqlType === "tsquery";
5178
+ };
5179
+ /**
5180
+ * A drizzle select projection over `table` with the search columns dropped.
5181
+ *
5182
+ * Returns undefined when nothing needs dropping, so the common case keeps using
5183
+ * a plain `select()` and this stays invisible in the generated SQL.
5184
+ */
5185
+ var visibleColumnProjection = (tableColumns, collection) => {
5186
+ const excluded = excludedColumnNames(tableColumns, collection);
5187
+ if (!tableColumns || excluded.length === 0) return void 0;
5188
+ const projection = {};
5189
+ for (const [name, column] of Object.entries(tableColumns)) if (!excluded.includes(name)) projection[name] = column;
5190
+ return projection;
5191
+ };
5192
+ /** The same exclusion as a drizzle `db.query` `columns` denylist. */
5193
+ var hiddenColumnsOption = (tableColumns, collection) => {
5194
+ const excluded = excludedColumnNames(tableColumns, collection);
5195
+ if (excluded.length === 0) return void 0;
5196
+ return Object.fromEntries(excluded.map((name) => [name, false]));
5197
+ };
5198
+ /**
5199
+ * The columns to keep out of a response, by name.
5200
+ *
5201
+ * `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
5202
+ * for anything that is not a real drizzle table — a stub in a test, a derived
5203
+ * or nested path with no table behind it. Nothing to exclude is the right
5204
+ * answer there, and it has to be an answer rather than a throw: this runs on
5205
+ * the read path of every collection, opted in or not.
5206
+ */
5207
+ var excludedColumnNames = (tableColumns, collection) => {
5208
+ if (!tableColumns || typeof tableColumns !== "object") return [];
5209
+ const byName = new Set(collection ? searchColumnNames(collection) : []);
5210
+ return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
5211
+ };
5212
+ //#endregion
4462
5213
  //#region src/schema/auth-users-columns.ts
4463
5214
  /**
4464
5215
  * `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
@@ -4570,6 +5321,6 @@ function isAuthCollection(collection) {
4570
5321
  return typeof auth === "object" && auth !== null && auth.enabled === true;
4571
5322
  }
4572
5323
  //#endregion
4573
- export { generateForeignKeyName as A, hasForeignKeyOnTarget as B, getDeclaredPrimaryKeys as C, createRelationRefWithData as D, createRelationRef as E, camelCase as F, Vector as H, toSnakeCase as I, DEFAULT_ONE_OF_TYPE as L, getPolicyNamesForRule as M, isPrototypePollutingKey as N, normalizeToEntityRelation as O, mergeDeep as P, DEFAULT_ONE_OF_VALUE as R, buildCompositeId as S, parseIdValues as T, isManyToMany as V, getColumnName as _, buildSdkData as a, getTableVarName as b, resolveStringColumnLength as c, resolveJunctionSpecs as d, getEffectiveSecurityRules as f, findRelation as g, findAnonymousGrants as h, isAuthCollection as i, legacyForeignKeyName as j, updateDateAutoValues as k, getJunctionCollectionConfig as l, securityRuleToConditions as m, authUsersColumnDefinition as n, CollectionRegistry as o, policyToPostgres as p, authUsersColumnSql as r, relationalCollections as s, AUTH_USERS_COLUMNS as t, getJunctionSecurityRules as u, getEnumVarName as v, isAddressableId as w, resolveCollectionRelations as x, getTableName as y, resolveClientListLimit as z };
5324
+ export { isManyToMany as $, getTableName as A, normalizeToEntityRelation as B, getEffectiveSecurityRules as C, findRelation as D, findAnonymousGrants as E, isAddressableId as F, getPolicyNamesForRule as G, generateForeignKeyName as H, parseIdValues as I, camelCase as J, isPrototypePollutingKey as K, sortCollectionsBySlug as L, resolveCollectionRelations as M, buildCompositeId as N, getColumnName as O, getDeclaredPrimaryKeys as P, hasForeignKeyOnTarget as Q, createRelationRef as R, resolveJunctionSpecs as S, securityRuleToConditions as T, legacyForeignKeyName as U, updateDateAutoValues as V, toPostgresIdentifier as W, DEFAULT_ONE_OF_TYPE as X, toSnakeCase as Y, DEFAULT_ONE_OF_VALUE as Z, CollectionRegistry as _, SEARCH_STAMP_PREFIX as a, getJunctionCollectionConfig as b, assertSearchIsPostgresOnly as c, searchColumnStamps as d, Vector as et, searchExtensionStatements as f, buildSdkData as g, visibleColumnProjection as h, isAuthCollection as i, getTableVarName as j, getEnumVarName as k, buildSearchColumnSpec as l, searchIndexStatements as m, authUsersColumnDefinition as n, SEARCH_TEXT_FN as o, searchHelperFunctions as p, mergeDeep as q, authUsersColumnSql as r, SEARCH_UNACCENT_FN as s, AUTH_USERS_COLUMNS as t, hiddenColumnsOption as u, relationalCollections as v, policyToPostgres as w, getJunctionSecurityRules as x, resolveStringColumnLength as y, createRelationRefWithData as z };
4574
5325
 
4575
- //# sourceMappingURL=auth-users-columns-Do9mw5Y5.js.map
5326
+ //# sourceMappingURL=auth-users-columns-BxT6FRyu.js.map