@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -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 getDataSourceCapabilities, c as toCanonicalOp, n as isPostgresCollectionConfig, o as NULL_OPS, r as isRelationalCollectionConfig, s as REST_TO_CANONICAL, t as getDeclaredSubcollections } from "./src-DoU9yPqq.js";
6
- import { n as ANONYMOUS_USER_IDS, r as policy, t as ANONYMOUS_USER_ID } from "./policy-CeA1JcxP.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,95 @@ 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
+ }
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
+ }
1411
1482
  //#endregion
1412
1483
  //#region ../common/src/util/entities.ts
1413
1484
  /**
@@ -1431,10 +1502,21 @@ function updateDateAutoValues({ inputValues, properties, status, timestampNowVal
1431
1502
  * have `id` and `path` fields — these are relation-shaped objects from
1432
1503
  * edge cases in the data pipeline (REST fallback, stale cache, custom data source).
1433
1504
  *
1505
+ * When `targetPath` is given, also accepts a bare id. A relation column is a
1506
+ * foreign key, and the REST layer returns it as the scalar it is; only some
1507
+ * fetch paths hydrate it into an object. Which form a caller sees therefore
1508
+ * depends on how the row was loaded, and a caller that only accepted objects
1509
+ * reported half of its own data as a type error. The declared target is the
1510
+ * missing half: with it, an id is a relation that has not been fetched yet.
1511
+ *
1434
1512
  * Returns null if the value cannot be coerced.
1435
1513
  */
1436
- function normalizeToEntityRelation(value, propertyType) {
1514
+ function normalizeToEntityRelation(value, propertyType, targetPath) {
1437
1515
  if (value instanceof EntityRelation) return value;
1516
+ if (targetPath && (typeof value === "string" || typeof value === "number")) {
1517
+ if (value === "") return null;
1518
+ return new EntityRelation(value, targetPath);
1519
+ }
1438
1520
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1439
1521
  const obj = value;
1440
1522
  if (!(obj.__type === "relation" || obj.__type === "reference" || typeof obj.isEntityRelation === "function" && obj.isEntityRelation() || typeof obj.isEntityReference === "function" && obj.isEntityReference() || propertyType === "relation" && typeof obj.id !== "undefined" && typeof obj.path === "string")) return null;
@@ -1506,6 +1588,25 @@ function createRelationRefWithData(id, path, data) {
1506
1588
  data
1507
1589
  };
1508
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
+ }
1509
1610
  /** The eight-four-four-four-twelve shape of a UUID, any version. */
1510
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;
1511
1612
  /** Whether one address part can be a value of the column it addresses. */
@@ -1842,6 +1943,48 @@ function getColumnName(fullColumn) {
1842
1943
  return fullColumn.includes(".") ? fullColumn.split(".").pop() : fullColumn;
1843
1944
  }
1844
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
+ /**
1845
1988
  * Look up a relation by key with forgiving normalization.
1846
1989
  *
1847
1990
  * `resolveCollectionRelations` stores each relation under a single canonical
@@ -1972,9 +2115,9 @@ function isKeywordAt(upper, i, keyword) {
1972
2115
  *
1973
2116
  * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
1974
2117
  * `AND` inside
1975
- * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
2118
+ * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
1976
2119
  * split the expression, and re-emitting the halves produced
1977
- * `(EXISTS (...) AND m.user_id = auth.uid())`
2120
+ * `(EXISTS (...) AND m.user_id = rebase.uid())`
1978
2121
  * where `m` is no longer in scope — SQL that Postgres rejects outright with
1979
2122
  * "missing FROM-clause entry for table". Returning null instead keeps such a
1980
2123
  * clause as a `raw` expression, which round-trips verbatim.
@@ -2048,15 +2191,15 @@ function stripOuterParens(sql) {
2048
2191
  }
2049
2192
  }
2050
2193
  function sqlToPolicy(sql) {
2051
- const trimmed = stripOuterParens(sql.trim());
2194
+ const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
2052
2195
  if (trimmed.toLowerCase() === "true") return policy.true();
2053
2196
  if (trimmed.toLowerCase() === "false") return policy.false();
2054
- const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
2197
+ const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
2055
2198
  if (overlapMatch) {
2056
2199
  const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
2057
2200
  return policy.rolesOverlap(roles);
2058
2201
  }
2059
- const containMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
2202
+ const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
2060
2203
  if (containMatch) {
2061
2204
  const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
2062
2205
  return policy.rolesContain(roles);
@@ -2072,32 +2215,60 @@ function sqlToPolicy(sql) {
2072
2215
  const right = parseOperand(rightStr.trim());
2073
2216
  if (left && right) return policy.compare(left, op === "=" ? "eq" : "neq", right);
2074
2217
  }
2075
- return policy.raw(sql);
2218
+ return policy.raw(trimmed);
2076
2219
  }
2077
2220
  /**
2078
- * Literals from other BaaS platforms that people compare `auth.uid()` against
2221
+ * Literals from other BaaS platforms that people compare `rebase.uid()` against
2079
2222
  * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
2080
2223
  * `pgRoles`, one surface over: the same muscle memory inside a `using:` string
2081
2224
  * is the more dangerous spelling, because it inverts a rule instead of
2082
2225
  * emptying a table.
2083
2226
  */
2084
- var FOREIGN_CONVENTION_UIDS = {
2085
- anon: "Supabase",
2086
- authenticated: "Supabase",
2087
- service_role: "Supabase"
2088
- };
2089
- /** `auth.uid() IS NOT NULL` in raw SQL, the clause that is always true. */
2090
- var UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
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");
2253
+ /**
2254
+ * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
2255
+ *
2256
+ * Both schema spellings, because this runs over policy bodies read back from a
2257
+ * database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
2258
+ * A security check that stops recognising a dangerous clause because the
2259
+ * framework renamed a function is a check that silently turns off.
2260
+ */
2261
+ var UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
2091
2262
  /**
2092
2263
  * Find clauses that read as "signed-in users only" but admit anonymous callers.
2093
2264
  *
2094
- * Both spellings come from the same place — Supabase, where `auth.uid()` really
2095
- * is NULL for an anonymous request. Rebase substitutes
2265
+ * Both spellings come from the same place — Supabase, where its own `auth.uid()`
2266
+ * really is NULL for an anonymous request. Rebase substitutes
2096
2267
  * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
2097
2268
  * is how the trusted *server* context is recognised), so:
2098
2269
  *
2099
- * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
2100
- * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
2270
+ * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
2271
+ * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
2101
2272
  * other. This one is not hypothetical and was not only a foreign habit:
2102
2273
  * rebase's own request path reported `'anon'` while everything that compiled
2103
2274
  * or checked a policy used `'anonymous'`, so whichever literal an author
@@ -2125,17 +2296,27 @@ function findAnonymousGrants(expr) {
2125
2296
  case "existsIn":
2126
2297
  visit(e.where);
2127
2298
  return;
2128
- case "raw":
2299
+ case "raw": {
2129
2300
  if (UID_NOT_NULL.test(e.sql)) found.push({
2130
2301
  pattern: "uid-not-null",
2131
2302
  detail: e.sql,
2132
- explanation: `\`auth.uid() IS NOT NULL\` is true for every request that came from a client, including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. Use \`condition: policy.authenticated()\` to mean "signed in".`
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".`
2133
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
+ }
2134
2314
  return;
2315
+ }
2135
2316
  case "compare": {
2136
2317
  const literal = [e.left, e.right].find((o) => o.kind === "literal");
2137
2318
  if (!(e.left.kind === "authUid" || e.right.kind === "authUid") || typeof literal?.value !== "string") return;
2138
- const platform = FOREIGN_CONVENTION_UIDS[literal.value];
2319
+ const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);
2139
2320
  if (!platform) return;
2140
2321
  found.push({
2141
2322
  pattern: "foreign-uid-literal",
@@ -2151,12 +2332,43 @@ function findAnonymousGrants(expr) {
2151
2332
  return found;
2152
2333
  }
2153
2334
  function parseOperand(str) {
2154
- if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
2155
- const stringMatch = str.match(/^'(.+)'$/);
2156
- if (stringMatch) return policy.literal(stringMatch[1]);
2157
- 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);
2158
2344
  return null;
2159
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
+ }
2160
2372
  //#endregion
2161
2373
  //#region ../common/src/util/policy/securityRuleToConditions.ts
2162
2374
  /**
@@ -2233,12 +2445,12 @@ function compile(expr, scope) {
2233
2445
  const rightSql = castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
2234
2446
  return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
2235
2447
  }
2236
- case "rolesOverlap": return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
2237
- case "rolesContain": return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
2238
- case "authenticated": return `auth.uid() IS NOT NULL AND auth.uid() NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
2239
- case "serverContext": return "auth.uid() IS NULL";
2448
+ case "rolesOverlap": return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
2449
+ case "rolesContain": return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
2450
+ case "authenticated": return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
2451
+ case "serverContext": return `${RLS_UID_SQL} IS NULL`;
2240
2452
  case "existsIn": return compileExistsIn(expr, scope);
2241
- case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
2453
+ case "raw": return rewriteLegacyRlsFunctions(expr.sql).replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
2242
2454
  }
2243
2455
  }
2244
2456
  /**
@@ -2275,8 +2487,8 @@ function operandToSql(operand, scope) {
2275
2487
  case "field": return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
2276
2488
  case "outerField": return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
2277
2489
  case "literal": return quoteLiteral(operand.value);
2278
- case "authUid": return "auth.uid()";
2279
- case "authRoles": return "string_to_array(auth.roles(), ',')";
2490
+ case "authUid": return RLS_UID_SQL;
2491
+ case "authRoles": return `string_to_array(${RLS_ROLES_SQL}, ',')`;
2280
2492
  }
2281
2493
  }
2282
2494
  /**
@@ -2314,8 +2526,14 @@ function rolesArraySql(roles) {
2314
2526
  * Rebase's enforcement model is unified: authenticated (user-context) requests
2315
2527
  * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
2316
2528
  * statement — reads and writes. A collection's `securityRules` are the whole
2317
- * authorization model. The server context (auth flows, migrations,
2318
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
2529
+ * authorization model. The server context (auth flows, migrations, raw
2530
+ * `rebase.sql`) runs as the owner and bypasses RLS.
2531
+ *
2532
+ * `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
2533
+ * `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
2534
+ * other caller and clears the baseline below through the *admin* arm, not the
2535
+ * server arm. Which is why `disableDefaultPolicies` plus a lone
2536
+ * `policy.serverContext()` rule locks it out too.
2319
2537
  *
2320
2538
  * Because RLS default-denies, every collection is **locked by default**: with
2321
2539
  * no rules, only the server context and admins can touch it. The generator
@@ -2356,7 +2574,7 @@ var DEFAULT_GUARDED_OPS = [
2356
2574
  "delete"
2357
2575
  ];
2358
2576
  /** Whether a collection is flagged as an authentication collection. */
2359
- function isAuthCollection(collection) {
2577
+ function isAuthCollection$1(collection) {
2360
2578
  const auth = collection.auth;
2361
2579
  return auth === true || typeof auth === "object" && auth?.enabled === true;
2362
2580
  }
@@ -2373,11 +2591,28 @@ function getIdPropertyName$1(collection) {
2373
2591
  *
2374
2592
  * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
2375
2593
  */
2594
+ /**
2595
+ * The restrictive write gate for an auth collection.
2596
+ *
2597
+ * Restrictive, so it is ANDed with everything else: whatever an author's
2598
+ * permissive rules allow, a write to this table still has to satisfy this too.
2599
+ * It is the only thing standing between "users may edit their own row" and
2600
+ * "users may grant themselves any role".
2601
+ */
2602
+ function adminWriteGate(tableName) {
2603
+ return {
2604
+ name: `${tableName}_require_admin_write`,
2605
+ mode: "restrictive",
2606
+ operations: [...DEFAULT_GUARDED_OPS],
2607
+ condition: SERVER_OR_ADMIN_EXPR$1,
2608
+ check: SERVER_OR_ADMIN_EXPR$1
2609
+ };
2610
+ }
2376
2611
  function getEffectiveSecurityRules(collection) {
2377
2612
  const explicit = [...collection.securityRules ?? []];
2378
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return explicit;
2379
2613
  const tableName = getTableName(collection);
2380
2614
  const injected = [];
2615
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection$1(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
2381
2616
  injected.push({
2382
2617
  name: `${tableName}_default_admin_read`,
2383
2618
  operations: ["select"],
@@ -2389,19 +2624,13 @@ function getEffectiveSecurityRules(collection) {
2389
2624
  condition: SERVER_OR_ADMIN_EXPR$1,
2390
2625
  check: SERVER_OR_ADMIN_EXPR$1
2391
2626
  });
2392
- if (isAuthCollection(collection)) {
2627
+ if (isAuthCollection$1(collection)) {
2393
2628
  injected.push({
2394
2629
  name: `${tableName}_default_self_read`,
2395
2630
  operations: ["select"],
2396
2631
  condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
2397
2632
  });
2398
- injected.push({
2399
- name: `${tableName}_require_admin_write`,
2400
- mode: "restrictive",
2401
- operations: [...DEFAULT_GUARDED_OPS],
2402
- condition: SERVER_OR_ADMIN_EXPR$1,
2403
- check: SERVER_OR_ADMIN_EXPR$1
2404
- });
2633
+ injected.push(adminWriteGate(tableName));
2405
2634
  }
2406
2635
  return [...explicit, ...injected];
2407
2636
  }
@@ -3690,8 +3919,24 @@ var QueryBuilder = class {
3690
3919
  /**
3691
3920
  * Set a free-text search string if supported by the backend.
3692
3921
  */
3693
- search(searchString) {
3922
+ search(searchString, options) {
3694
3923
  this.params.searchString = searchString;
3924
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
3925
+ return this;
3926
+ }
3927
+ /**
3928
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
3929
+ *
3930
+ * Postgres only, over a property declared as `type: "vector"`. Rows come
3931
+ * back with a `_distance`; `where` filters before the ordering.
3932
+ */
3933
+ vectorSearch(property, vector, options) {
3934
+ this.params.vectorSearch = {
3935
+ property,
3936
+ vector,
3937
+ ...options?.distance !== void 0 && { distance: options.distance },
3938
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
3939
+ };
3695
3940
  return this;
3696
3941
  }
3697
3942
  /**
@@ -3891,21 +4136,50 @@ async function collectAllPages(find, params, label = "collection") {
3891
4136
  * metadata, so type coercion is the responsibility of the server-side data
3892
4137
  * driver which has access to the collection schema.
3893
4138
  *
3894
- * Commas inside list values are backslash-escaped (`\,`), and literal
3895
- * backslashes are escaped as `\\`.
4139
+ * Structural characters inside a value are backslash-escaped: `,` `\,`,
4140
+ * `(` `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
4141
+ * deliberately conservative — only those four sequences are decoded, so a
4142
+ * backslash that arrives unescaped from an older client survives intact.
3896
4143
  *
3897
4144
  * @module
3898
4145
  */
3899
4146
  /**
3900
- * Unescape a single list item from the wire format.
3901
- * `\\``\`, `\,` → `,`
4147
+ * Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
4148
+ * `)``\)`.
4149
+ */
4150
+ /**
4151
+ * The wire spelling of an empty list.
4152
+ *
4153
+ * A lone backslash: unproducible by {@link escapeWireValue}, which doubles
4154
+ * every backslash it emits, so it cannot collide with any real item.
4155
+ */
4156
+ var EMPTY_LIST_TOKEN = "\\";
4157
+ /**
4158
+ * Unescape a wire-format value.
4159
+ *
4160
+ * **Conservative**, and deliberately so: only the four sequences
4161
+ * {@link escapeWireValue} actually produces are decoded. A backslash followed
4162
+ * by anything else is left exactly as it is.
4163
+ *
4164
+ * This used to consume the backslash before *any* character, which is
4165
+ * indistinguishable for anything this codec emitted — it only ever emits those
4166
+ * four — but not for input arriving from elsewhere. A client on an older
4167
+ * release sends a Windows path or a LIKE pattern with a literal `C:\x`
4168
+ * unescaped, and greedy unescaping silently turned it into `C:x`, changing
4169
+ * which rows matched. Decoding only what the encoder can produce makes the two
4170
+ * directions agree across versions.
3902
4171
  */
3903
- function unescapeListItem(value) {
4172
+ function unescapeWireValue(value) {
3904
4173
  let result = "";
3905
- for (let i = 0; i < value.length; i++) if (value[i] === "\\" && i + 1 < value.length) {
3906
- result += value[i + 1];
3907
- i++;
3908
- } else result += value[i];
4174
+ for (let i = 0; i < value.length; i++) {
4175
+ const next = value[i + 1];
4176
+ if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
4177
+ result += next;
4178
+ i++;
4179
+ continue;
4180
+ }
4181
+ result += value[i];
4182
+ }
3909
4183
  return result;
3910
4184
  }
3911
4185
  /**
@@ -3923,13 +4197,205 @@ function splitListItems(inner) {
3923
4197
  current += inner[i] + inner[i + 1];
3924
4198
  i++;
3925
4199
  } else if (inner[i] === ",") {
3926
- items.push(unescapeListItem(current));
4200
+ items.push(unescapeWireValue(current));
3927
4201
  current = "";
3928
4202
  } else current += inner[i];
3929
- items.push(unescapeListItem(current));
4203
+ items.push(unescapeWireValue(current));
3930
4204
  return items;
3931
4205
  }
3932
- var REST_OP_LOOKUP = REST_TO_CANONICAL;
4206
+ /**
4207
+ * Operator tables as `Map`s, because the key comes off the wire.
4208
+ *
4209
+ * Indexed as plain objects, every `Object.prototype` member answered: a query
4210
+ * string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
4211
+ * and `deserializeTuple` returned it *as the operator*, so a function object
4212
+ * travelled on into the compilers in place of a `WhereFilterOp`. The guard one
4213
+ * line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
4214
+ * and does not: `Object.prototype` is not unknown to a plain object.
4215
+ *
4216
+ * Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
4217
+ * `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
4218
+ */
4219
+ var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
4220
+ new Map(Object.entries(CANONICAL_TO_REST));
4221
+ /** The operator spellings a rejection lists back to the caller. */
4222
+ var VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
4223
+ /**
4224
+ * A filter condition named an operator this dialect does not have.
4225
+ *
4226
+ * ## Why this throws, rather than returning a typed rejection
4227
+ *
4228
+ * `deserializeFilter` is the *shared* codec: the REST ingress
4229
+ * (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
4230
+ * admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
4231
+ * follow.
4232
+ *
4233
+ * - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
4234
+ * depend on `@rebasepro/server` (the dependency runs the other way), and a
4235
+ * browser client has no error handler to render an `ApiError` with. So the
4236
+ * rejection is this plain `Error` subclass, whose `message` reads correctly
4237
+ * wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
4238
+ * - It cannot be a returned rejection *value*. Every caller assigns the result
4239
+ * straight into a query it is about to run; a sentinel that none of them
4240
+ * check would be ignored, which is exactly the silently-wrong-filter failure
4241
+ * this exists to stop. Throwing is also what this file already does for the
4242
+ * sibling cases — `serializeTuple` on an unknown canonical operator,
4243
+ * `deserializeLogicalCondition` past the nesting bound — and the REST parser
4244
+ * already converts the latter into a 400.
4245
+ *
4246
+ * `statusCode`, `code` and `details` are carried as fields because the server's
4247
+ * Hono error handler duck-types those off any thrown error: a decode path that
4248
+ * forgets to convert still answers 400 with the canonical envelope instead of a
4249
+ * 500 that says "An unexpected error occurred". `query-parser.ts` converts
4250
+ * explicitly all the same — that is the path the contract is stated on, and an
4251
+ * incidental 400 is not a contract.
4252
+ */
4253
+ var UnknownFilterOperatorError = class extends Error {
4254
+ /** The field the condition was written against. */
4255
+ field;
4256
+ /** The operator string as it arrived, verbatim. */
4257
+ operator;
4258
+ /** Every operator this dialect accepts, in canonical spelling. */
4259
+ validOperators = ALL_WHERE_FILTER_OPS;
4260
+ /** See the class docblock: read by the server's error handler. */
4261
+ statusCode = 400;
4262
+ code = "UNKNOWN_FILTER_OPERATOR";
4263
+ details;
4264
+ constructor(field, operator) {
4265
+ super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
4266
+ this.name = "UnknownFilterOperatorError";
4267
+ this.field = field;
4268
+ this.operator = operator;
4269
+ this.details = {
4270
+ field,
4271
+ operator,
4272
+ validOperators: ALL_WHERE_FILTER_OPS
4273
+ };
4274
+ }
4275
+ };
4276
+ /**
4277
+ * Two to three characters of ASCII punctuation and nothing else — the shape
4278
+ * every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
4279
+ * one a column value effectively never has.
4280
+ *
4281
+ * Two characters minimum on purpose. A *single* punctuation character is a
4282
+ * perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
4283
+ * condition — and the only single-character operator anyone actually mistypes
4284
+ * is `=`, which is named separately below. `<` and `>` need no special case:
4285
+ * they are real operators and resolve.
4286
+ */
4287
+ var SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
4288
+ /** Lowercase, strip everything that is not a letter or digit. */
4289
+ function normalizeOperatorName(op) {
4290
+ return op.toLowerCase().replace(/[^a-z0-9]/g, "");
4291
+ }
4292
+ /**
4293
+ * Every real operator name with its case and separators removed, so a
4294
+ * respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
4295
+ * recognised as an attempt at an operator rather than read as a value.
4296
+ *
4297
+ * These are rejected rather than accepted: admitting a second spelling of an
4298
+ * operator would leave two wire spellings of one thing, and the rejection
4299
+ * message names the one that works.
4300
+ */
4301
+ var RESPELLED_OPERATORS = new Set([...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName));
4302
+ /**
4303
+ * Operator names *other* query dialects use, which this one does not have.
4304
+ *
4305
+ * This list is curated, and deliberately so. For a word-shaped string there is
4306
+ * no rule that separates "an operator the caller guessed" from "a value that
4307
+ * happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
4308
+ * `in` list, so the codec cannot simply refuse every unrecognised word in
4309
+ * position 0. The line is therefore drawn by name, and only around names whose
4310
+ * use as an operator is far more likely than their use as one of two sibling
4311
+ * values. `contains` is the motivating case — the first thing a developer
4312
+ * reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
4313
+ *
4314
+ * Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
4315
+ * are left off: as operators they are rare, and as enum values they are common.
4316
+ * Everywhere else the tie goes to *rejecting*, because a 400 naming the
4317
+ * supported set costs the caller one round trip, and the alternative — which is
4318
+ * what every name on this list used to produce — is a query that runs, returns
4319
+ * rows, and is wrong.
4320
+ */
4321
+ var NEAR_MISS_OPERATORS = /* @__PURE__ */ new Set([
4322
+ "contains",
4323
+ "notcontains",
4324
+ "doesnotcontain",
4325
+ "doesnotcontains",
4326
+ "includes",
4327
+ "notincludes",
4328
+ "startswith",
4329
+ "notstartswith",
4330
+ "beginswith",
4331
+ "startingwith",
4332
+ "endswith",
4333
+ "notendswith",
4334
+ "matches",
4335
+ "notmatches",
4336
+ "regex",
4337
+ "regexp",
4338
+ "between",
4339
+ "notbetween",
4340
+ "equals",
4341
+ "notequals",
4342
+ "equalto",
4343
+ "isequalto",
4344
+ "isnotequalto",
4345
+ "greaterthan",
4346
+ "greaterthanorequal",
4347
+ "greaterthanorequalto",
4348
+ "lessthan",
4349
+ "lessthanorequal",
4350
+ "lessthanorequalto",
4351
+ "isempty",
4352
+ "isnotempty",
4353
+ "oneof",
4354
+ "noneof",
4355
+ "anyof",
4356
+ "allof",
4357
+ "null",
4358
+ "isnullorempty"
4359
+ ]);
4360
+ /**
4361
+ * Was this string *meant* as an operator?
4362
+ *
4363
+ * Only consulted after {@link toCanonicalOp} has already failed to resolve it,
4364
+ * so a `true` here is always a rejection.
4365
+ */
4366
+ function isOperatorShaped(op) {
4367
+ if (op === "=") return true;
4368
+ if (SYMBOLIC_OPERATOR.test(op)) return true;
4369
+ const normalized = normalizeOperatorName(op);
4370
+ if (!normalized) return false;
4371
+ return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
4372
+ }
4373
+ /**
4374
+ * Read a `[op, value]` tuple, if that is what this is.
4375
+ *
4376
+ * Three outcomes, and the middle one is the defect this function exists for:
4377
+ *
4378
+ * - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
4379
+ * - the operator does not resolve but was plainly meant as one → throw;
4380
+ * - it does not look like an operator at all → `undefined`, and the caller
4381
+ * falls back to reading the array as a list of values.
4382
+ *
4383
+ * The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
4384
+ * only, with *everything else* — including every REST short-code — dropping
4385
+ * through to `["in", raw]`. So the operator string itself became a value in a
4386
+ * membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
4387
+ * which matches, and the caller got back rows their filter was written to
4388
+ * exclude. `["eq", "active"]` had the same shape of failure.
4389
+ */
4390
+ function readTuple(field, raw) {
4391
+ if (!Array.isArray(raw) || raw.length !== 2) return void 0;
4392
+ const [op, value] = raw;
4393
+ if (typeof op !== "string") return void 0;
4394
+ const canonical = toCanonicalOp(op);
4395
+ if (canonical) return [canonical, value];
4396
+ if (op.includes(".")) return void 0;
4397
+ if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
4398
+ }
3933
4399
  /**
3934
4400
  * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
3935
4401
  *
@@ -3946,10 +4412,13 @@ function deserializeSingle(raw) {
3946
4412
  if (dotIndex === -1) return ["==", raw];
3947
4413
  const prefix = raw.substring(0, dotIndex);
3948
4414
  const rest = raw.substring(dotIndex + 1);
3949
- const canonicalOp = REST_OP_LOOKUP[prefix];
4415
+ const canonicalOp = REST_OP_LOOKUP.get(prefix);
3950
4416
  if (!canonicalOp) return ["==", raw];
3951
4417
  if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
3952
- if (rest.startsWith("(") && rest.endsWith(")")) return [canonicalOp, splitListItems(rest.slice(1, -1))];
4418
+ if (rest.startsWith("(") && rest.endsWith(")")) {
4419
+ const inner = rest.slice(1, -1);
4420
+ return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
4421
+ }
3953
4422
  return [canonicalOp, rest];
3954
4423
  }
3955
4424
  /**
@@ -3964,20 +4433,27 @@ function deserializeSingle(raw) {
3964
4433
  *
3965
4434
  * deserializeFilter({ age: ["gte.18", "lt.65"] })
3966
4435
  * // → { age: [[">=", "18"], ["<", "65"]] }
4436
+ *
4437
+ * @throws {UnknownFilterOperatorError} when a condition names an operator this
4438
+ * dialect does not have. See that class for why a rejection here is a throw.
3967
4439
  */
3968
4440
  function deserializeFilter(query) {
3969
4441
  const result = {};
3970
4442
  for (const [field, raw] of Object.entries(query)) {
3971
4443
  if (raw === void 0) continue;
3972
- if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
3973
- result[field] = raw;
4444
+ const tuple = readTuple(field, raw);
4445
+ if (tuple) {
4446
+ result[field] = tuple;
3974
4447
  continue;
3975
4448
  }
3976
4449
  if (Array.isArray(raw)) {
3977
4450
  if (raw.length === 0) continue;
3978
- if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
3979
- result[field] = raw;
3980
- continue;
4451
+ if (Array.isArray(raw[0])) {
4452
+ const tuples = raw.map((item) => readTuple(field, item));
4453
+ if (tuples.every((t) => t !== void 0)) {
4454
+ result[field] = tuples;
4455
+ continue;
4456
+ }
3981
4457
  }
3982
4458
  if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
3983
4459
  else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
@@ -4021,10 +4497,12 @@ function createPrimaryKeyResolver(options) {
4021
4497
  * than postgres still serve rows with one, and this keeps them working.
4022
4498
  */
4023
4499
  function rowToEntity(row, slug, primaryKeys = []) {
4500
+ const { _matches, ...values } = row;
4024
4501
  return {
4025
4502
  id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
4026
4503
  path: slug,
4027
- values: row
4504
+ values,
4505
+ ..._matches ? { searchMatches: _matches } : {}
4028
4506
  };
4029
4507
  }
4030
4508
  /**
@@ -4146,6 +4624,21 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4146
4624
  values: {}
4147
4625
  } });
4148
4626
  },
4627
+ updateMany: driver.updateMany ? async (updates) => {
4628
+ return (await driver.updateMany({
4629
+ path: slug,
4630
+ updates: updates.map((u) => ({
4631
+ id: u.id,
4632
+ values: u.data
4633
+ }))
4634
+ })).map((row) => rowToEntity(row, slug, getPks()));
4635
+ } : void 0,
4636
+ deleteMany: driver.deleteMany ? async (ids) => {
4637
+ await driver.deleteMany({
4638
+ path: slug,
4639
+ ids
4640
+ });
4641
+ } : void 0,
4149
4642
  count: driver.count ? async (params) => {
4150
4643
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
4151
4644
  return driver.count({
@@ -4167,6 +4660,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4167
4660
  orderBy: params?.orderBy?.[0],
4168
4661
  order: params?.orderBy?.[1],
4169
4662
  searchString: params?.searchString,
4663
+ searchExplain: params?.searchExplain,
4170
4664
  onUpdate: (entities) => {
4171
4665
  onUpdate({
4172
4666
  data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
@@ -4204,8 +4698,11 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4204
4698
  offset(count) {
4205
4699
  return new QueryBuilder(accessor).offset(count);
4206
4700
  },
4207
- search(searchString) {
4208
- return new QueryBuilder(accessor).search(searchString);
4701
+ search(searchString, options) {
4702
+ return new QueryBuilder(accessor).search(searchString, options);
4703
+ },
4704
+ vectorSearch(property, vector, options) {
4705
+ return new QueryBuilder(accessor).vectorSearch(property, vector, options);
4209
4706
  },
4210
4707
  include(...relations) {
4211
4708
  return new QueryBuilder(accessor).include(...relations);
@@ -4293,8 +4790,18 @@ var SdkQueryBuilder = class {
4293
4790
  this.params.offset = count;
4294
4791
  return this;
4295
4792
  }
4296
- search(searchString) {
4793
+ search(searchString, options) {
4297
4794
  this.params.searchString = searchString;
4795
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
4796
+ return this;
4797
+ }
4798
+ vectorSearch(property, vector, options) {
4799
+ this.params.vectorSearch = {
4800
+ property,
4801
+ vector,
4802
+ ...options?.distance !== void 0 && { distance: options.distance },
4803
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
4804
+ };
4298
4805
  return this;
4299
4806
  }
4300
4807
  include(...relations) {
@@ -4348,9 +4855,24 @@ function toSdkCollectionClient(snap, slug = "collection") {
4348
4855
  async update(id, data) {
4349
4856
  return entityToRow(await snap.update(id, data));
4350
4857
  },
4858
+ async updateMany(updates) {
4859
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
4860
+ if (updates.length === 0) return [];
4861
+ if (!snap.updateMany) throw new Error("Bulk updates are not supported by this collection's data source. Fall back to update() per record.");
4862
+ return (await snap.updateMany(updates.map((u) => ({
4863
+ id: u.id,
4864
+ data: u.data
4865
+ })))).map(entityToRow);
4866
+ },
4351
4867
  delete(id) {
4352
4868
  return snap.delete(id);
4353
4869
  },
4870
+ async deleteMany(ids) {
4871
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
4872
+ if (ids.length === 0) return;
4873
+ if (!snap.deleteMany) throw new Error("Bulk deletes are not supported by this collection's data source. Fall back to delete() per record.");
4874
+ await snap.deleteMany(ids);
4875
+ },
4354
4876
  count: snap.count ? (params) => snap.count(params) : void 0,
4355
4877
  listen: snap.listen ? (params, onUpdate, onError) => snap.listen(params, (res) => onUpdate({
4356
4878
  data: res.data.map(entityToRow),
@@ -4366,6 +4888,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
4366
4888
  limit: (count) => new SdkQueryBuilder(client).limit(count),
4367
4889
  offset: (count) => new SdkQueryBuilder(client).offset(count),
4368
4890
  search: (searchString) => new SdkQueryBuilder(client).search(searchString),
4891
+ vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
4369
4892
  include: (...relations) => new SdkQueryBuilder(client).include(...relations)
4370
4893
  };
4371
4894
  return client;
@@ -4411,6 +4934,502 @@ function buildSdkData(driver) {
4411
4934
  return wrapAsSdkData(buildRebaseData(driver));
4412
4935
  }
4413
4936
  //#endregion
4414
- export { mergeDeep as A, createRelationRefWithData as C, legacyForeignKeyName as D, generateForeignKeyName as E, resolveClientListLimit as F, hasForeignKeyOnTarget as I, isManyToMany as L, toSnakeCase as M, DEFAULT_ONE_OF_TYPE as N, getPolicyNamesForRule as O, DEFAULT_ONE_OF_VALUE as P, Vector as R, createRelationRef as S, updateDateAutoValues as T, resolveCollectionRelations as _, getJunctionCollectionConfig as a, isAddressableId as b, getEffectiveSecurityRules as c, findAnonymousGrants as d, findRelation as f, getTableVarName as g, getTableName as h, resolveStringColumnLength as i, camelCase as j, isPrototypePollutingKey as k, policyToPostgres as l, getEnumVarName as m, CollectionRegistry as n, getJunctionSecurityRules as o, getColumnName as p, relationalCollections as r, resolveJunctionSpecs as s, buildSdkData as t, securityRuleToConditions as u, buildCompositeId as v, normalizeToEntityRelation as w, parseIdValues as x, getDeclaredPrimaryKeys as y };
4937
+ //#region src/schema/search-column.ts
4938
+ /**
4939
+ * The one place a collection's `search` block becomes SQL.
4940
+ *
4941
+ * Four things describe a Postgres table in this codebase — the DDL generator,
4942
+ * the Drizzle schema generator, the runtime table builder for BaaS mode, and
4943
+ * the boot-time schema ensure — and each of them has, at some point, described
4944
+ * a column differently from the others. The `varchar(255)` note in
4945
+ * `generate-postgres-ddl-logic` is one such scar: the same property produced a
4946
+ * capped column down one path and an uncapped one down the other, and nothing
4947
+ * failed until a user hit the cap.
4948
+ *
4949
+ * So the search column is not implemented four times. It is computed once,
4950
+ * here, and every generator renders the same {@link SearchColumnSpec}. There is
4951
+ * a test asserting exactly that (`search-column-contract.test.ts`); the point of
4952
+ * this module is that the test has something to assert *about*.
4953
+ *
4954
+ * ## Why the expressions look the way they do
4955
+ *
4956
+ * A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
4957
+ * Postgres is stricter here than intuition. Verified against PostgreSQL 18:
4958
+ *
4959
+ * | expression | immutable |
4960
+ * |-----------------------------------------|-----------|
4961
+ * | `to_tsvector('spanish', col)` | yes |
4962
+ * | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
4963
+ * | `array_to_string(col, ' ')` | **no** |
4964
+ * | `col::text` on `text[]` | **no** |
4965
+ * | `to_jsonb(col)` | **no** |
4966
+ * | `unaccent(col)` | **no** — dictionary lookup is STABLE |
4967
+ * | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
4968
+ * | `setweight(...) || setweight(...)` | yes |
4969
+ *
4970
+ * Three of the four things a real search column needs are therefore unavailable
4971
+ * directly, which is why {@link searchHelperFunctions} exists: each wraps a
4972
+ * stable built-in in an SQL function declared IMMUTABLE. That declaration is a
4973
+ * promise, and it is a true one for these three — array joining, JSON string
4974
+ * extraction and accent folding are all deterministic for a given input; the
4975
+ * built-ins are marked stable only because they must account for element types
4976
+ * and dictionaries in general.
4977
+ *
4978
+ * The alternative was to skip `unaccent` and text arrays entirely. That is not
4979
+ * a real option in an accented language: Postgres stems `auditoría` to
4980
+ * `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
4981
+ * typed without accents misses every row that carries them.
4982
+ */
4983
+ /** Schema-qualified so a collection outside `public` still resolves them. */
4984
+ var HELPER_SCHEMA = "public";
4985
+ /**
4986
+ * Names of the helper functions. Frozen: they are recorded in the stored
4987
+ * generation expression of every search column ever created, so renaming one
4988
+ * orphans every table that already has a search column.
4989
+ */
4990
+ var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
4991
+ var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
4992
+ /** Raised when a `search` block names something that cannot be searched. */
4993
+ var SearchConfigError = class extends Error {
4994
+ constructor(message) {
4995
+ super(message);
4996
+ this.name = "SearchConfigError";
4997
+ }
4998
+ };
4999
+ /** The `search` block of a collection, or undefined when it has none. */
5000
+ var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
5001
+ /**
5002
+ * Refuse a `search` block on a collection this engine does not store.
5003
+ *
5004
+ * The type only permits one on a `PostgresCollectionConfig`, so TypeScript
5005
+ * already stops the ordinary case. This catches the rest — a JS config, a cast,
5006
+ * a collection whose `engine` was changed after the block was written — because
5007
+ * the alternative is the exact failure the block exists to prevent: a developer
5008
+ * who declared what to index, saw no error, and got the substring fallback.
5009
+ *
5010
+ * Called with *every* collection, before the Postgres ones are filtered out.
5011
+ */
5012
+ var assertSearchIsPostgresOnly = (collections) => {
5013
+ for (const collection of collections) {
5014
+ if (isPostgresCollectionConfig(collection)) continue;
5015
+ if (!collection.search) continue;
5016
+ const engine = collection.engine ?? "non-postgres";
5017
+ 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.`);
5018
+ }
5019
+ };
5020
+ var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
5021
+ /**
5022
+ * Classify a property for search purposes.
5023
+ *
5024
+ * Deliberately narrower than `getSqlColumnType`: search only cares whether a
5025
+ * value reaches text, and the mapping from property to *physical* type is
5026
+ * asserted against `getSqlColumnType` in the contract test rather than
5027
+ * duplicated here.
5028
+ *
5029
+ * Returns null for anything that is not text-bearing, which the caller turns
5030
+ * into a boot error naming the property.
5031
+ */
5032
+ var classify = (prop) => {
5033
+ switch (prop.type) {
5034
+ case "string": {
5035
+ const sp = prop;
5036
+ if (sp.enum) return {
5037
+ kind: "text",
5038
+ reason: "enum"
5039
+ };
5040
+ if (sp.isId === "uuid" || sp.columnType === "uuid") return {
5041
+ kind: "text",
5042
+ reason: "uuid"
5043
+ };
5044
+ return { kind: "text" };
5045
+ }
5046
+ case "map":
5047
+ if (prop.columnType === "json") return {
5048
+ kind: "jsonb",
5049
+ reason: "json"
5050
+ };
5051
+ return { kind: "jsonb" };
5052
+ case "array": {
5053
+ const ap = prop;
5054
+ let colType = ap.columnType;
5055
+ if (!colType && ap.of && !Array.isArray(ap.of)) {
5056
+ const of = ap.of;
5057
+ if (of.type === "string") colType = "text[]";
5058
+ else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
5059
+ else if (of.type === "boolean") colType = "boolean[]";
5060
+ }
5061
+ if (colType === "text[]") return { kind: "text_array" };
5062
+ if (colType === "json") return {
5063
+ kind: "jsonb",
5064
+ reason: "json"
5065
+ };
5066
+ if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
5067
+ kind: "text_array",
5068
+ reason: "non_text_array"
5069
+ };
5070
+ return { kind: "jsonb" };
5071
+ }
5072
+ default: return null;
5073
+ }
5074
+ };
5075
+ var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
5076
+ /** SQL reading one field as plain text, before normalization. */
5077
+ var rawTextSql = (field) => {
5078
+ const col = `"${field.column}"`;
5079
+ if (field.kind === "text") return `coalesce(${col}, '')`;
5080
+ if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
5081
+ 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))`;
5082
+ };
5083
+ var quote = (v) => `'${v.replace(/'/g, "''")}'`;
5084
+ /**
5085
+ * Resolve and validate one declared field path.
5086
+ *
5087
+ * A path that does not resolve throws. The whole point of an explicit block is
5088
+ * that the author knows what is indexed; a silently dropped field would make it
5089
+ * a guess again, and the failure — a search that returns nothing for content
5090
+ * that is plainly in the row — is invisible from the outside.
5091
+ */
5092
+ var resolveField = (entry, collection, cfg) => {
5093
+ const path = typeof entry === "string" ? entry : entry.path;
5094
+ const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? "B";
5095
+ const where = `${collection.slug}.search`;
5096
+ if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
5097
+ const [head, ...rest] = path.split(".");
5098
+ const prop = collection.properties?.[head];
5099
+ if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
5100
+ const classified = classify(prop);
5101
+ 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).`);
5102
+ 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.`);
5103
+ if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
5104
+ 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.`);
5105
+ if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
5106
+ 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.`);
5107
+ const column = columnNameOf(head, prop);
5108
+ const textSql = normalize(rawTextSql({
5109
+ column,
5110
+ jsonPath: rest,
5111
+ kind: classified.kind
5112
+ }), cfg.unaccent === true);
5113
+ const language = cfg.language ?? "simple";
5114
+ return {
5115
+ path,
5116
+ column,
5117
+ jsonPath: rest,
5118
+ kind: classified.kind,
5119
+ weight,
5120
+ sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
5121
+ textSql
5122
+ };
5123
+ };
5124
+ /**
5125
+ * Build the full spec for a collection, or undefined when it has not opted in.
5126
+ *
5127
+ * Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
5128
+ * at boot surface that as a startup failure — a search block that half-works is
5129
+ * worse than one that refuses.
5130
+ */
5131
+ var buildSearchColumnSpec = (collection) => {
5132
+ const cfg = getSearchConfig(collection);
5133
+ if (!cfg) return void 0;
5134
+ 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.`);
5135
+ const table = getTableName(collection);
5136
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
5137
+ const column = cfg.column ?? "search_vector";
5138
+ 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.`);
5139
+ const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
5140
+ const seen = /* @__PURE__ */ new Set();
5141
+ for (const f of fields) {
5142
+ if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
5143
+ seen.add(f.path);
5144
+ }
5145
+ const extensions = [];
5146
+ if (cfg.unaccent) extensions.push("unaccent");
5147
+ if (cfg.fuzzy) extensions.push("pg_trgm");
5148
+ const spec = {
5149
+ schema,
5150
+ table,
5151
+ column,
5152
+ language: cfg.language ?? "simple",
5153
+ unaccent: cfg.unaccent === true,
5154
+ fields,
5155
+ expression: fields.map((f) => f.sql).join(" || "),
5156
+ indexName: toPostgresIdentifier(`${table}_${column}_gin`),
5157
+ extensions
5158
+ };
5159
+ if (cfg.fuzzy) {
5160
+ const fuzzyColumn = `${column}_text`;
5161
+ 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.`);
5162
+ spec.fuzzy = {
5163
+ column: fuzzyColumn,
5164
+ expression: fields.map((f) => f.textSql).join(" || ' ' || "),
5165
+ indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
5166
+ threshold: cfg.fuzzyThreshold ?? .3
5167
+ };
5168
+ }
5169
+ return spec;
5170
+ };
5171
+ /**
5172
+ * The IMMUTABLE wrappers the generated expressions call.
5173
+ *
5174
+ * `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
5175
+ * than an error, and idempotent for the same reason every other boot-time DDL
5176
+ * statement here is.
5177
+ *
5178
+ * The bodies are stable built-ins wrapped in an immutable promise — see the
5179
+ * module comment for why that promise is sound. `STRICT` matters: it makes NULL
5180
+ * in mean NULL out without executing the body, which is what the `coalesce` at
5181
+ * each call site then absorbs.
5182
+ */
5183
+ var searchHelperFunctions = (spec) => {
5184
+ 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 $$;`];
5185
+ 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) $$;`);
5186
+ return statements;
5187
+ };
5188
+ /**
5189
+ * `CREATE EXTENSION` statements the spec's expressions depend on.
5190
+ *
5191
+ * `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
5192
+ * `CREATE EXTENSION` installs into the first schema on `search_path`, which
5193
+ * defaults to `"$user", public` — and the scaffold's database role is named
5194
+ * `rebase`, the same as the schema the generator creates one statement earlier.
5195
+ * So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
5196
+ * dictionary in `rebase`, and every reference to `public.unaccent` below fails
5197
+ * with "text search dictionary does not exist". Observed, not theorised.
5198
+ */
5199
+ var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
5200
+ /**
5201
+ * Index statements for the spec.
5202
+ *
5203
+ * `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
5204
+ * SQL file replayed as one unit — a migration, or `search.sql` — where a
5205
+ * concurrent build is not allowed. The boot-time ensure path runs statement by
5206
+ * statement against tables that are live and populated, and uses the
5207
+ * concurrent form instead; see `ensureSearchColumns`.
5208
+ */
5209
+ var searchIndexStatements = (spec) => {
5210
+ const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
5211
+ 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);`);
5212
+ return statements;
5213
+ };
5214
+ /**
5215
+ * Marker on the comment of every generated search column this module creates.
5216
+ *
5217
+ * Versioned because the fingerprint below is only comparable against itself: a
5218
+ * future change to how it is computed has to read as "not stamped by this
5219
+ * version" rather than as drift on every existing column.
5220
+ */
5221
+ var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
5222
+ /**
5223
+ * A stable fingerprint of one generated column's expression.
5224
+ *
5225
+ * Why a stamp rather than reading the expression back: Postgres stores a
5226
+ * generated column's expression *parsed*, and hands it back deparsed — casts
5227
+ * made explicit, identifiers requoted, schema qualifications added or dropped
5228
+ * according to `search_path`. Comparing that text to the text we generated
5229
+ * would report drift on wording, and this comparison decides whether a boot
5230
+ * refuses, so a false positive is an outage. The stamp is written by the same
5231
+ * code that writes the column, so equality means what it says.
5232
+ */
5233
+ var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
5234
+ /**
5235
+ * The stamps for a spec's generated columns — one per column, never shared.
5236
+ *
5237
+ * Per column on purpose: turning `fuzzy` on adds a second column and changes
5238
+ * nothing about the first, and a spec-wide fingerprint would report the
5239
+ * untouched `tsvector` column as drifted and refuse a boot over a change that
5240
+ * is purely additive.
5241
+ */
5242
+ var searchColumnStamps = (spec) => {
5243
+ const stamp = (column, expression) => {
5244
+ const fingerprint = searchExpressionFingerprint(expression);
5245
+ return {
5246
+ column,
5247
+ expression,
5248
+ fingerprint,
5249
+ sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
5250
+ };
5251
+ };
5252
+ const stamps = [stamp(spec.column, spec.expression)];
5253
+ if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
5254
+ return stamps;
5255
+ };
5256
+ /**
5257
+ * The generated column names a collection's search block adds, if any.
5258
+ *
5259
+ * These are physical columns on the table, so `SELECT *` returns them. They are
5260
+ * an index in column form — a list of lexeme positions, or a concatenation of
5261
+ * every searchable field on the row — and nothing outside the query planner has
5262
+ * any use for them. Left in, every list response carries a second, larger copy
5263
+ * of the row's text.
5264
+ */
5265
+ var searchColumnNames = (collection) => {
5266
+ let spec;
5267
+ try {
5268
+ spec = buildSearchColumnSpec(collection);
5269
+ } catch {
5270
+ return [];
5271
+ }
5272
+ if (!spec) return [];
5273
+ return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
5274
+ };
5275
+ /**
5276
+ * True for a column whose type only ever holds a search index.
5277
+ *
5278
+ * Independent of any collection config on purpose: an introspected database
5279
+ * (BaaS mode) can carry a `tsvector` column this framework never created —
5280
+ * Pagila's `film.fulltext` is the canonical one — and it should not be returned
5281
+ * to callers either. `isDerivedIndexColumn` already keeps such a column out of
5282
+ * the *properties*; this keeps it out of the *rows*.
5283
+ */
5284
+ var isSearchIndexColumn = (column) => {
5285
+ const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
5286
+ return sqlType === "tsvector" || sqlType === "tsquery";
5287
+ };
5288
+ /**
5289
+ * A drizzle select projection over `table` with the search columns dropped.
5290
+ *
5291
+ * Returns undefined when nothing needs dropping, so the common case keeps using
5292
+ * a plain `select()` and this stays invisible in the generated SQL.
5293
+ */
5294
+ var visibleColumnProjection = (tableColumns, collection) => {
5295
+ const excluded = excludedColumnNames(tableColumns, collection);
5296
+ if (!tableColumns || excluded.length === 0) return void 0;
5297
+ const projection = {};
5298
+ for (const [name, column] of Object.entries(tableColumns)) if (!excluded.includes(name)) projection[name] = column;
5299
+ return projection;
5300
+ };
5301
+ /** The same exclusion as a drizzle `db.query` `columns` denylist. */
5302
+ var hiddenColumnsOption = (tableColumns, collection) => {
5303
+ const excluded = excludedColumnNames(tableColumns, collection);
5304
+ if (excluded.length === 0) return void 0;
5305
+ return Object.fromEntries(excluded.map((name) => [name, false]));
5306
+ };
5307
+ /**
5308
+ * The columns to keep out of a response, by name.
5309
+ *
5310
+ * `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
5311
+ * for anything that is not a real drizzle table — a stub in a test, a derived
5312
+ * or nested path with no table behind it. Nothing to exclude is the right
5313
+ * answer there, and it has to be an answer rather than a throw: this runs on
5314
+ * the read path of every collection, opted in or not.
5315
+ */
5316
+ var excludedColumnNames = (tableColumns, collection) => {
5317
+ if (!tableColumns || typeof tableColumns !== "object") return [];
5318
+ const byName = new Set(collection ? searchColumnNames(collection) : []);
5319
+ return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
5320
+ };
5321
+ //#endregion
5322
+ //#region src/schema/auth-users-columns.ts
5323
+ /**
5324
+ * `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
5325
+ * — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
5326
+ * The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
5327
+ * separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
5328
+ * table that already holds a longer row.
5329
+ */
5330
+ var AUTH_USERS_COLUMNS = [
5331
+ {
5332
+ column: "email",
5333
+ type: "TEXT",
5334
+ notNull: true
5335
+ },
5336
+ {
5337
+ column: "display_name",
5338
+ type: "TEXT"
5339
+ },
5340
+ {
5341
+ column: "photo_url",
5342
+ type: "TEXT"
5343
+ },
5344
+ {
5345
+ column: "roles",
5346
+ type: "TEXT[]",
5347
+ default: "'{}'",
5348
+ notNull: true
5349
+ },
5350
+ {
5351
+ column: "password_hash",
5352
+ type: "TEXT"
5353
+ },
5354
+ {
5355
+ column: "email_verified",
5356
+ type: "BOOLEAN",
5357
+ default: "FALSE",
5358
+ notNull: true
5359
+ },
5360
+ {
5361
+ column: "email_verification_token",
5362
+ type: "TEXT"
5363
+ },
5364
+ {
5365
+ column: "email_verification_sent_at",
5366
+ type: "TIMESTAMP WITH TIME ZONE"
5367
+ },
5368
+ {
5369
+ column: "is_anonymous",
5370
+ type: "BOOLEAN",
5371
+ default: "FALSE",
5372
+ notNull: true
5373
+ },
5374
+ {
5375
+ column: "metadata",
5376
+ type: "JSONB",
5377
+ default: "'{}'",
5378
+ notNull: true
5379
+ },
5380
+ {
5381
+ column: "tokens_valid_after",
5382
+ type: "TIMESTAMP WITH TIME ZONE"
5383
+ },
5384
+ {
5385
+ column: "created_at",
5386
+ type: "TIMESTAMP WITH TIME ZONE",
5387
+ default: "NOW()",
5388
+ notNull: true
5389
+ },
5390
+ {
5391
+ column: "updated_at",
5392
+ type: "TIMESTAMP WITH TIME ZONE",
5393
+ default: "NOW()",
5394
+ notNull: true
5395
+ }
5396
+ ];
5397
+ var BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map((c) => [c.column, c]));
5398
+ /** Type + inline constraints, as they appear after the column name. */
5399
+ function authUsersColumnSql(spec) {
5400
+ return [
5401
+ spec.type,
5402
+ spec.default !== void 0 ? `DEFAULT ${spec.default}` : "",
5403
+ spec.notNull ? "NOT NULL" : ""
5404
+ ].filter(Boolean).join(" ");
5405
+ }
5406
+ /**
5407
+ * The auth-owned definition for a physical column name, or `undefined` when
5408
+ * auth does not own it.
5409
+ *
5410
+ * Callers pass the RESOLVED column name (after `columnName` mapping), because
5411
+ * that is the only name the three creators agree on: the scaffold's users
5412
+ * collection spells the property `displayName` and the column `display_name`.
5413
+ */
5414
+ function authUsersColumnDefinition(column) {
5415
+ const spec = BY_COLUMN.get(column);
5416
+ return spec ? authUsersColumnSql(spec) : void 0;
5417
+ }
5418
+ /**
5419
+ * Whether a collection is an auth collection, i.e. whether the definitions in
5420
+ * this module apply to its table at all.
5421
+ *
5422
+ * Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
5423
+ * that one takes a `CollectionConfig`, this one is called from DDL code paths
5424
+ * that hold looser objects, and both spellings must accept `auth: true` as well
5425
+ * as `auth: { enabled: true }`.
5426
+ */
5427
+ function isAuthCollection(collection) {
5428
+ const auth = collection?.auth;
5429
+ if (auth === true) return true;
5430
+ return typeof auth === "object" && auth !== null && auth.enabled === true;
5431
+ }
5432
+ //#endregion
5433
+ export { DEFAULT_ONE_OF_TYPE as $, getEnumVarName as A, createRelationRefWithData as B, getEffectiveSecurityRules as C, fieldKeyForColumn as D, findAnonymousGrants as E, getDeclaredPrimaryKeys as F, legacyForeignKeyName as G, updateDateAutoValues as H, isAddressableId as I, getPolicyNamesForRule as J, toPostgresIdentifier as K, parseIdValues as L, getTableVarName as M, resolveCollectionRelations as N, findRelation as O, buildCompositeId as P, toSnakeCase as Q, sortCollectionsBySlug as R, resolveJunctionSpecs as S, securityRuleToConditions as T, firstFreeKey as U, normalizeToEntityRelation as V, generateForeignKeyName as W, mergeDeep as X, isPrototypePollutingKey as Y, camelCase as Z, CollectionRegistry as _, SEARCH_STAMP_PREFIX as a, getJunctionCollectionConfig as b, assertSearchIsPostgresOnly as c, searchColumnStamps as d, DEFAULT_ONE_OF_VALUE as et, searchExtensionStatements as f, buildSdkData as g, visibleColumnProjection as h, isAuthCollection as i, getTableName as j, getColumnName as k, buildSearchColumnSpec as l, searchIndexStatements as m, authUsersColumnDefinition as n, isManyToMany as nt, SEARCH_TEXT_FN as o, searchHelperFunctions as p, toWireKey as q, authUsersColumnSql as r, Vector as rt, SEARCH_UNACCENT_FN as s, AUTH_USERS_COLUMNS as t, hasForeignKeyOnTarget as tt, hiddenColumnsOption as u, relationalCollections as v, policyToPostgres as w, getJunctionSecurityRules as x, resolveStringColumnLength as y, createRelationRef as z };
4415
5434
 
4416
- //# sourceMappingURL=src-CU6WZGYV.js.map
5435
+ //# sourceMappingURL=auth-users-columns-BfQHf9JE.js.map