@rebasepro/server-postgres 0.9.1-canary.73476f2 → 0.9.1-canary.74adfbe

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 (62) hide show
  1. package/README.md +21 -0
  2. package/dist/PostgresBackendDriver.d.ts +43 -2
  3. package/dist/PostgresBootstrapper.d.ts +17 -1
  4. package/dist/auth/services.d.ts +68 -52
  5. package/dist/collections/buildRegistry.d.ts +27 -0
  6. package/dist/connection.d.ts +21 -0
  7. package/dist/data-transformer.d.ts +9 -2
  8. package/dist/index.es.js +2185 -775
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
  11. package/dist/schema/auth-schema.d.ts +24 -24
  12. package/dist/schema/doctor.d.ts +1 -1
  13. package/dist/schema/introspect-db-logic.d.ts +0 -5
  14. package/dist/schema/introspect-db-naming.d.ts +10 -0
  15. package/dist/security/policy-drift.d.ts +30 -0
  16. package/dist/security/rls-enforcement.d.ts +2 -2
  17. package/dist/services/FetchService.d.ts +4 -24
  18. package/dist/services/PersistService.d.ts +27 -1
  19. package/dist/services/RelationService.d.ts +34 -1
  20. package/dist/services/channel-history.d.ts +118 -0
  21. package/dist/services/collection-helpers.d.ts +79 -14
  22. package/dist/services/dataService.d.ts +3 -1
  23. package/dist/services/index.d.ts +1 -1
  24. package/dist/services/realtimeService.d.ts +76 -2
  25. package/dist/services/row-pipeline.d.ts +63 -0
  26. package/package.json +13 -34
  27. package/src/PostgresBackendDriver.ts +183 -18
  28. package/src/PostgresBootstrapper.ts +80 -26
  29. package/src/auth/ensure-tables.ts +170 -28
  30. package/src/auth/services.ts +181 -150
  31. package/src/cli.ts +60 -0
  32. package/src/collections/buildRegistry.ts +59 -0
  33. package/src/connection.ts +61 -1
  34. package/src/data-transformer.ts +11 -9
  35. package/src/databasePoolManager.ts +2 -0
  36. package/src/schema/auth-bootstrap-sql.ts +7 -1
  37. package/src/schema/auth-schema.ts +13 -13
  38. package/src/schema/doctor.ts +45 -20
  39. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  40. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  41. package/src/schema/introspect-db-inference.ts +1 -1
  42. package/src/schema/introspect-db-logic.ts +1 -10
  43. package/src/schema/introspect-db-naming.ts +15 -0
  44. package/src/schema/introspect-db.ts +19 -2
  45. package/src/schema/introspect-runtime.ts +1 -1
  46. package/src/security/policy-drift.test.ts +106 -1
  47. package/src/security/policy-drift.ts +56 -0
  48. package/src/security/rls-enforcement.ts +11 -5
  49. package/src/services/BranchService.ts +42 -10
  50. package/src/services/FetchService.ts +65 -270
  51. package/src/services/PersistService.ts +130 -14
  52. package/src/services/RelationService.ts +153 -94
  53. package/src/services/channel-history.ts +343 -0
  54. package/src/services/collection-helpers.ts +164 -47
  55. package/src/services/dataService.ts +3 -2
  56. package/src/services/index.ts +1 -0
  57. package/src/services/realtimeService.ts +238 -29
  58. package/src/services/row-pipeline.ts +239 -0
  59. package/src/utils/drizzle-conditions.ts +13 -0
  60. package/src/websocket.ts +34 -12
  61. package/dist/schema/auth-default-policies.d.ts +0 -10
  62. package/src/schema/auth-default-policies.ts +0 -132
package/dist/index.es.js CHANGED
@@ -3,15 +3,15 @@ import process from "process";
3
3
  const require = __createRequire(import.meta.url);
4
4
  import { Client, Pool } from "pg";
5
5
  import { drizzle } from "drizzle-orm/node-postgres";
6
- import { createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
6
+ import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
7
7
  import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
8
8
  import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
9
- import { createHash, randomUUID } from "crypto";
10
9
  import fs, { promises } from "fs";
11
10
  import path from "path";
12
11
  import chokidar from "chokidar";
13
12
  import { WebSocket, WebSocketServer } from "ws";
14
13
  import { EventEmitter } from "events";
14
+ import { randomUUID } from "crypto";
15
15
  import { inspect } from "util";
16
16
  import os from "os";
17
17
  import { fileURLToPath } from "node:url";
@@ -71,16 +71,51 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
71
71
  var connection_exports = /* @__PURE__ */ __exportAll({
72
72
  createDirectDatabaseConnection: () => createDirectDatabaseConnection,
73
73
  createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
74
- createReadReplicaConnection: () => createReadReplicaConnection
74
+ createReadReplicaConnection: () => createReadReplicaConnection,
75
+ guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
75
76
  });
76
77
  var DEFAULT_POOL = {
77
78
  max: 20,
78
79
  idleTimeoutMillis: 3e4,
79
80
  connectionTimeoutMillis: 1e4,
80
- queryTimeout: 3e4,
81
+ queryTimeout: 6e4,
81
82
  statementTimeout: 3e4,
82
83
  keepAlive: true
83
84
  };
85
+ /** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
86
+ var TX_IDLE = "I";
87
+ /**
88
+ * Destroy pool clients that are released while still inside a transaction.
89
+ *
90
+ * pg-pool returns a client to the idle list whenever `release()` is called
91
+ * without an error — even if the connection is still mid-transaction (status
92
+ * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
93
+ * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
94
+ * (e.g. it was queued behind a statement that hit the client-side
95
+ * query_timeout), the client goes back dirty. The next checkout then runs
96
+ * its statements inside the zombie transaction — with the previous request's
97
+ * `app.*` RLS GUCs still applied, which turns unrelated queries into
98
+ * RLS-scoped ones (observed in production as registration failing with
99
+ * SQLSTATE 42501 under a leaked anonymous context).
100
+ *
101
+ * pg-pool emits `release` before it consults its private `_expired` set, so
102
+ * marking the client expired here makes `_release()` destroy it instead of
103
+ * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
104
+ * private APIs — feature-detect and fall back to loud logging so an upstream
105
+ * change degrades to observability, never to silent corruption.
106
+ */
107
+ function guardPoolAgainstDirtyRelease(pool, label) {
108
+ pool.on("release", (err, client) => {
109
+ if (err) return;
110
+ const txStatus = client?._txStatus;
111
+ if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
112
+ const expired = pool._expired;
113
+ if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
114
+ expired.add(client);
115
+ logger.error(`[${label}] Client released back to the pool while still in a transaction (status '${txStatus}') — destroying it so the open transaction and its session state (RLS GUCs) cannot leak into the next request.`);
116
+ } else logger.error(`[${label}] Client released mid-transaction (status '${txStatus}') but the pool's internal expiry set is unavailable (pg-pool internals changed?). The connection may leak its open transaction into subsequent requests.`);
117
+ });
118
+ }
84
119
  /**
85
120
  * Create a Drizzle-backed Postgres connection with a production-grade
86
121
  * connection pool.
@@ -112,6 +147,7 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
112
147
  logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
113
148
  if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
114
149
  });
150
+ guardPoolAgainstDirtyRelease(pool, "pg-pool");
115
151
  return {
116
152
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
117
153
  pool,
@@ -144,6 +180,7 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
144
180
  pool.on("error", (err) => {
145
181
  logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
146
182
  });
183
+ guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
147
184
  return {
148
185
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
149
186
  pool,
@@ -173,6 +210,7 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
173
210
  pool.on("error", (err) => {
174
211
  logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
175
212
  });
213
+ guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
176
214
  return {
177
215
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
178
216
  pool,
@@ -315,7 +353,7 @@ function getDeclaredSubcollections(collection) {
315
353
  /**
316
354
  * The id a request without a logged-in user reports as `auth.uid()`.
317
355
  *
318
- * A user-context request always sets `app.user_id`: blank would read back as
356
+ * A user-context request always sets `app.uid`: blank would read back as
319
357
  * `NULL`, and `NULL` is how the trusted server context is recognised, so an
320
358
  * anonymous visitor would be promoted to server privileges. The driver
321
359
  * therefore substitutes this sentinel at the single chokepoint where the GUC
@@ -1502,6 +1540,140 @@ function removeFunctions(o) {
1502
1540
  return o;
1503
1541
  }
1504
1542
  //#endregion
1543
+ //#region ../utils/src/sha1.ts
1544
+ /**
1545
+ * Minimal SHA-1 implementation that runs in both Node and the browser.
1546
+ *
1547
+ * This exists because generated Postgres policy names embed a SHA-1 digest of
1548
+ * the security rule. The DDL generator runs on the server (where `node:crypto`
1549
+ * is available) but the Studio has to derive the same names in the browser to
1550
+ * tell a policy it generated apart from one it did not. `node:crypto` cannot be
1551
+ * bundled for the browser, so the shared derivation needs a portable digest.
1552
+ *
1553
+ * SHA-1 is used purely to name things deterministically — never for security.
1554
+ * The output is byte-identical to `createHash("sha1").update(str).digest("hex")`,
1555
+ * which `sha1.test.ts` pins against `node:crypto` directly.
1556
+ */
1557
+ /** Rotate a 32-bit word left by `n` bits. */
1558
+ function rotl(value, n) {
1559
+ return value << n | value >>> 32 - n;
1560
+ }
1561
+ /**
1562
+ * SHA-1 digest of a string, hex-encoded.
1563
+ *
1564
+ * The input is encoded as UTF-8, matching Node's default handling of strings
1565
+ * passed to `hash.update(str)`.
1566
+ */
1567
+ function sha1Hex(input) {
1568
+ const bytes = Array.from(new TextEncoder().encode(input));
1569
+ const bitLength = bytes.length * 8;
1570
+ bytes.push(128);
1571
+ while (bytes.length % 64 !== 56) bytes.push(0);
1572
+ const hi = Math.floor(bitLength / 4294967296);
1573
+ const lo = bitLength >>> 0;
1574
+ bytes.push(hi >>> 24 & 255, hi >>> 16 & 255, hi >>> 8 & 255, hi & 255);
1575
+ bytes.push(lo >>> 24 & 255, lo >>> 16 & 255, lo >>> 8 & 255, lo & 255);
1576
+ let h0 = 1732584193;
1577
+ let h1 = 4023233417;
1578
+ let h2 = 2562383102;
1579
+ let h3 = 271733878;
1580
+ let h4 = 3285377520;
1581
+ const w = new Array(80);
1582
+ for (let offset = 0; offset < bytes.length; offset += 64) {
1583
+ for (let i = 0; i < 16; i++) {
1584
+ const j = offset + i * 4;
1585
+ w[i] = bytes[j] << 24 | bytes[j + 1] << 16 | bytes[j + 2] << 8 | bytes[j + 3] | 0;
1586
+ }
1587
+ for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
1588
+ let a = h0;
1589
+ let b = h1;
1590
+ let c = h2;
1591
+ let d = h3;
1592
+ let e = h4;
1593
+ for (let i = 0; i < 80; i++) {
1594
+ let f;
1595
+ let k;
1596
+ if (i < 20) {
1597
+ f = b & c | ~b & d;
1598
+ k = 1518500249;
1599
+ } else if (i < 40) {
1600
+ f = b ^ c ^ d;
1601
+ k = 1859775393;
1602
+ } else if (i < 60) {
1603
+ f = b & c | b & d | c & d;
1604
+ k = 2400959708;
1605
+ } else {
1606
+ f = b ^ c ^ d;
1607
+ k = 3395469782;
1608
+ }
1609
+ const temp = rotl(a, 5) + f + e + k + w[i] | 0;
1610
+ e = d;
1611
+ d = c;
1612
+ c = rotl(b, 30);
1613
+ b = a;
1614
+ a = temp;
1615
+ }
1616
+ h0 = h0 + a | 0;
1617
+ h1 = h1 + b | 0;
1618
+ h2 = h2 + c | 0;
1619
+ h3 = h3 + d | 0;
1620
+ h4 = h4 + e | 0;
1621
+ }
1622
+ return [
1623
+ h0,
1624
+ h1,
1625
+ h2,
1626
+ h3,
1627
+ h4
1628
+ ].map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
1629
+ }
1630
+ //#endregion
1631
+ //#region ../utils/src/policy-names.ts
1632
+ /**
1633
+ * Naming of the Postgres policies generated from a collection's security rules.
1634
+ *
1635
+ * A rule without an explicit `name` is compiled to `<table>_<op>_<hash>`, where
1636
+ * the hash covers the rule's semantics. The Studio needs the same names to tell
1637
+ * "this policy came from your code" apart from "someone wrote this in SQL" —
1638
+ * without them it treats generated policies as foreign and offers to import
1639
+ * them back into the codebase they came from.
1640
+ *
1641
+ * This is the single definition of that naming. The DDL and Drizzle generators
1642
+ * both derive names from here, so a change cannot silently rename every policy
1643
+ * in every deployed database while the UI keeps matching the old ones.
1644
+ */
1645
+ /** Stable digest of the parts of a rule that determine what the policy does. */
1646
+ function getPolicyNameHash(rule) {
1647
+ return sha1Hex(JSON.stringify({
1648
+ a: rule.access,
1649
+ m: rule.mode,
1650
+ op: rule.operation,
1651
+ ops: rule.operations?.slice().sort(),
1652
+ own: rule.ownerField,
1653
+ rol: rule.roles?.slice().sort(),
1654
+ pg: rule.pgRoles?.slice().sort(),
1655
+ u: rule.using,
1656
+ w: rule.withCheck,
1657
+ c: rule.condition,
1658
+ ch: rule.check
1659
+ })).substring(0, 7);
1660
+ }
1661
+ /** The operations a rule expands to — `operations` wins over `operation`. */
1662
+ function getPolicyOperations(rule) {
1663
+ return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
1664
+ }
1665
+ /**
1666
+ * Every Postgres policy name a single rule compiles to — one per operation.
1667
+ *
1668
+ * @param rule The security rule as written in the collection config.
1669
+ * @param tableName The rule's table (see `getTableName` in `@rebasepro/common`).
1670
+ */
1671
+ function getPolicyNamesForRule(rule, tableName) {
1672
+ const ops = getPolicyOperations(rule);
1673
+ const ruleHash = getPolicyNameHash(rule);
1674
+ return ops.map((op, opIdx) => rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`);
1675
+ }
1676
+ //#endregion
1505
1677
  //#region ../utils/src/names.ts
1506
1678
  /**
1507
1679
  * Generates a foreign key column name from a given string, typically a collection slug or name.
@@ -1626,6 +1798,109 @@ function createRelationRefWithData(id, path, data) {
1626
1798
  data
1627
1799
  };
1628
1800
  }
1801
+ /**
1802
+ * Derive a row's address from its key columns.
1803
+ *
1804
+ * Single key → the value as a string. Composite → each part joined by
1805
+ * {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what
1806
+ * {@link parseIdValues} expects to invert.
1807
+ */
1808
+ function buildCompositeId(values, primaryKeys) {
1809
+ if (primaryKeys.length === 0) return "";
1810
+ if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
1811
+ return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
1812
+ }
1813
+ /**
1814
+ * Invert {@link buildCompositeId}: turn an address back into key columns, each
1815
+ * coerced to the type its column actually round-trips as.
1816
+ *
1817
+ * This is the boundary where a URL segment becomes a query parameter, so a
1818
+ * malformed address must throw rather than silently produce a query that
1819
+ * matches the wrong row (or none).
1820
+ */
1821
+ function parseIdValues(idValue, primaryKeys) {
1822
+ const result = {};
1823
+ if (primaryKeys.length === 0) return result;
1824
+ if (primaryKeys.length === 1) {
1825
+ const pk = primaryKeys[0];
1826
+ if (pk.type === "number" && !pk.isUUID) {
1827
+ const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
1828
+ if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
1829
+ result[pk.fieldName] = parsed;
1830
+ } else result[pk.fieldName] = String(idValue);
1831
+ return result;
1832
+ }
1833
+ const parts = String(idValue).split(":::");
1834
+ if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
1835
+ for (let i = 0; i < primaryKeys.length; i++) {
1836
+ const pk = primaryKeys[i];
1837
+ const val = parts[i];
1838
+ if (pk.type === "number" && !pk.isUUID) {
1839
+ const parsed = parseInt(val, 10);
1840
+ if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
1841
+ result[pk.fieldName] = parsed;
1842
+ } else result[pk.fieldName] = val;
1843
+ }
1844
+ return result;
1845
+ }
1846
+ /**
1847
+ * The primary keys of a collection, as declared by its properties.
1848
+ *
1849
+ * This is the only tier both sides can read, because it is the only one written
1850
+ * in the config: the postgres driver can also infer keys from the Drizzle
1851
+ * schema, which the browser never sees and is never sent — the admin compiles
1852
+ * the collection files into its own bundle rather than being served them. A key
1853
+ * that lives only in the Drizzle schema is therefore invisible here, and the
1854
+ * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
1855
+ * to add.
1856
+ *
1857
+ * Returns an empty array when a collection declares none, which callers must
1858
+ * treat as "not addressable" rather than defaulting to `id`: guessing a key
1859
+ * that is not the real one produces confidently wrong addresses.
1860
+ */
1861
+ function getDeclaredPrimaryKeys(collection) {
1862
+ const properties = collection.properties;
1863
+ if (!properties) return [];
1864
+ const keys = [];
1865
+ for (const [fieldName, propRaw] of Object.entries(properties)) {
1866
+ const prop = propRaw;
1867
+ if (!prop || typeof prop !== "object") continue;
1868
+ if (!("isId" in prop) || !prop.isId) continue;
1869
+ keys.push({
1870
+ fieldName,
1871
+ type: prop.type === "number" ? "number" : "string",
1872
+ isUUID: prop.isId === "uuid"
1873
+ });
1874
+ }
1875
+ return keys;
1876
+ }
1877
+ /**
1878
+ * The keys to address a collection's rows with, resolved the way the driver
1879
+ * resolves them — minus the tier the browser cannot reach.
1880
+ *
1881
+ * The postgres driver tries, in order: properties marked `isId`; the primary
1882
+ * keys of the Drizzle schema; and finally a column literally named `id`. Only
1883
+ * the first and last are visible in a `CollectionConfig`, which is what both
1884
+ * sides share.
1885
+ *
1886
+ * So the two agree except on a collection that declares no `isId` and whose key
1887
+ * is known only to Drizzle. There, the driver reads the real key, and this
1888
+ * either resolves nothing (reported to the console by the caller) or — if the
1889
+ * table happens to have an unrelated `id` property — resolves `id`, which is
1890
+ * the wrong key and cannot be detected from here: the addresses look right and
1891
+ * route wrong. Only the config can settle it, so the server names both cases
1892
+ * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
1893
+ */
1894
+ function resolvePrimaryKeys(collection) {
1895
+ const declared = getDeclaredPrimaryKeys(collection);
1896
+ if (declared.length > 0) return declared;
1897
+ const idProp = collection.properties?.id;
1898
+ if (idProp && typeof idProp === "object") return [{
1899
+ fieldName: "id",
1900
+ type: idProp.type === "number" ? "number" : "string"
1901
+ }];
1902
+ return [];
1903
+ }
1629
1904
  //#endregion
1630
1905
  //#region ../common/src/util/enums.ts
1631
1906
  function enumToObjectEntries(enumValues) {
@@ -1859,16 +2134,110 @@ function getSubcollections(collection) {
1859
2134
  * It handles:
1860
2135
  * - `field = 'literal'`
1861
2136
  * - `field != 'literal'`
1862
- * - `field = current_setting('app.user_id')`
1863
- * - `A AND B`
2137
+ * - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
2138
+ * - `A AND B`, `A OR B` — only where the keyword is at the top level
1864
2139
  * - `true`
1865
2140
  * - `IN (...)` (as optimistic true)
1866
2141
  *
1867
2142
  * For anything it doesn't understand, it returns a `raw` expression, which
1868
2143
  * the evaluator treats as "unknown" (and usually optimistic true).
2144
+ *
2145
+ * **This output also round-trips back into DDL** via `policyToPostgres` (the
2146
+ * schema/policy generators), so decomposing a clause the parser only partly
2147
+ * understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
2148
+ * prefer `raw`: it is reproduced verbatim.
1869
2149
  */
2150
+ /** True when `keyword` starts at `i` as a standalone word. */
2151
+ function isKeywordAt(upper, i, keyword) {
2152
+ if (!upper.startsWith(keyword, i)) return false;
2153
+ const before = i === 0 ? " " : upper[i - 1];
2154
+ const after = upper[i + keyword.length] ?? " ";
2155
+ return /[\s()]/.test(before) && /[\s()]/.test(after);
2156
+ }
2157
+ /**
2158
+ * Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
2159
+ * outside a string literal. Returns null when it never does, so the caller
2160
+ * leaves the clause alone.
2161
+ *
2162
+ * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
2163
+ * `AND` inside
2164
+ * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
2165
+ * split the expression, and re-emitting the halves produced
2166
+ * `(EXISTS (...) AND m.user_id = auth.uid())`
2167
+ * where `m` is no longer in scope — SQL that Postgres rejects outright with
2168
+ * "missing FROM-clause entry for table". Returning null instead keeps such a
2169
+ * clause as a `raw` expression, which round-trips verbatim.
2170
+ */
2171
+ function splitTopLevel(sql, keyword) {
2172
+ const upper = sql.toUpperCase();
2173
+ const parts = [];
2174
+ let depth = 0;
2175
+ let inString = false;
2176
+ let start = 0;
2177
+ for (let i = 0; i < sql.length; i++) {
2178
+ const ch = sql[i];
2179
+ if (inString) {
2180
+ if (ch === "'") if (sql[i + 1] === "'") i++;
2181
+ else inString = false;
2182
+ continue;
2183
+ }
2184
+ if (ch === "'") {
2185
+ inString = true;
2186
+ continue;
2187
+ }
2188
+ if (ch === "(") {
2189
+ depth++;
2190
+ continue;
2191
+ }
2192
+ if (ch === ")") {
2193
+ depth--;
2194
+ continue;
2195
+ }
2196
+ if (depth === 0 && isKeywordAt(upper, i, keyword)) {
2197
+ parts.push(sql.slice(start, i));
2198
+ i += keyword.length - 1;
2199
+ start = i + 1;
2200
+ }
2201
+ }
2202
+ if (parts.length === 0) return null;
2203
+ parts.push(sql.slice(start));
2204
+ const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
2205
+ return trimmedParts.length > 1 ? trimmedParts : null;
2206
+ }
2207
+ /** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
2208
+ function stripOuterParens(sql) {
2209
+ let s = sql.trim();
2210
+ for (;;) {
2211
+ if (!s.startsWith("(") || !s.endsWith(")")) return s;
2212
+ let depth = 0;
2213
+ let inString = false;
2214
+ let wraps = true;
2215
+ for (let i = 0; i < s.length; i++) {
2216
+ const ch = s[i];
2217
+ if (inString) {
2218
+ if (ch === "'") if (s[i + 1] === "'") i++;
2219
+ else inString = false;
2220
+ continue;
2221
+ }
2222
+ if (ch === "'") {
2223
+ inString = true;
2224
+ continue;
2225
+ }
2226
+ if (ch === "(") depth++;
2227
+ else if (ch === ")") {
2228
+ depth--;
2229
+ if (depth === 0 && i < s.length - 1) {
2230
+ wraps = false;
2231
+ break;
2232
+ }
2233
+ }
2234
+ }
2235
+ if (!wraps) return s;
2236
+ s = s.slice(1, -1).trim();
2237
+ }
2238
+ }
1870
2239
  function sqlToPolicy(sql) {
1871
- const trimmed = sql.trim();
2240
+ const trimmed = stripOuterParens(sql.trim());
1872
2241
  if (trimmed.toLowerCase() === "true") return policy.true();
1873
2242
  if (trimmed.toLowerCase() === "false") return policy.false();
1874
2243
  const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
@@ -1881,14 +2250,10 @@ function sqlToPolicy(sql) {
1881
2250
  const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
1882
2251
  return policy.rolesContain(roles);
1883
2252
  }
1884
- if (trimmed.toUpperCase().includes(" OR ")) {
1885
- const parts = trimmed.split(/ OR /i);
1886
- return policy.or(...parts.map(sqlToPolicy));
1887
- }
1888
- if (trimmed.toUpperCase().includes(" AND ")) {
1889
- const parts = trimmed.split(/ AND /i);
1890
- return policy.and(...parts.map(sqlToPolicy));
1891
- }
2253
+ const orParts = splitTopLevel(trimmed, "OR");
2254
+ if (orParts) return policy.or(...orParts.map(sqlToPolicy));
2255
+ const andParts = splitTopLevel(trimmed, "AND");
2256
+ if (andParts) return policy.and(...andParts.map(sqlToPolicy));
1892
2257
  const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
1893
2258
  if (match) {
1894
2259
  const [, leftStr, op, rightStr] = match;
@@ -1970,7 +2335,7 @@ function findAnonymousGrants(expr) {
1970
2335
  return found;
1971
2336
  }
1972
2337
  function parseOperand(str) {
1973
- if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
2338
+ if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
1974
2339
  const stringMatch = str.match(/^'(.+)'$/);
1975
2340
  if (stringMatch) return policy.literal(stringMatch[1]);
1976
2341
  if (/^\w+$/.test(str)) return policy.field(str);
@@ -2194,6 +2559,307 @@ var buildPropertyCallbacks = (properties) => {
2194
2559
  };
2195
2560
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
2196
2561
  };
2562
+ //#endregion
2563
+ //#region ../common/src/util/auth-default-policies.ts
2564
+ /**
2565
+ * Default RLS policies injected by the schema generator.
2566
+ *
2567
+ * Rebase's enforcement model is unified: authenticated (user-context) requests
2568
+ * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
2569
+ * statement — reads and writes. A collection's `securityRules` are the whole
2570
+ * authorization model. The server context (auth flows, migrations,
2571
+ * `dataAsAdmin`) runs as the owner and bypasses RLS.
2572
+ *
2573
+ * Because RLS default-denies, every collection is **locked by default**: with
2574
+ * no rules, only the server context and admins can touch it. The generator
2575
+ * injects that safe baseline:
2576
+ *
2577
+ * **For every collection**
2578
+ * 1. A permissive **server-or-admin SELECT** grant.
2579
+ * 2. A permissive **server-or-admin write** grant (insert/update/delete).
2580
+ *
2581
+ * Author `securityRules` are permissive and OR together, so explicit rules only
2582
+ * *broaden* access from this locked baseline (e.g. "users read/write their own
2583
+ * rows").
2584
+ *
2585
+ * **For auth collections additionally**
2586
+ * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
2587
+ * their own row (profile, session bootstrap) without every app re-declaring
2588
+ * it.
2589
+ * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
2590
+ * every other policy, so a write is rejected unless the caller is an admin
2591
+ * (or the server context) — even if the author also wrote a permissive rule
2592
+ * such as "a user may edit their own row". Without this, a permissive owner
2593
+ * rule would let a user change their own `roles`.
2594
+ *
2595
+ * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
2596
+ * — the built-in flows that run without a user (signup, migrations) set no user
2597
+ * GUC — which also lets the owner connection satisfy these policies even under
2598
+ * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
2599
+ * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
2600
+ *
2601
+ * Opt out with `disableDefaultPolicies: true` to take full responsibility for
2602
+ * the collection's RLS.
2603
+ */
2604
+ var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2605
+ /** Write operations that must be admin-gated by default on auth collections. */
2606
+ var DEFAULT_GUARDED_OPS = [
2607
+ "insert",
2608
+ "update",
2609
+ "delete"
2610
+ ];
2611
+ /** Whether a collection is flagged as an authentication collection. */
2612
+ function isAuthCollection(collection) {
2613
+ const auth = collection.auth;
2614
+ return auth === true || typeof auth === "object" && auth?.enabled === true;
2615
+ }
2616
+ /** The property marked as the row id (falls back to `id`). */
2617
+ function getIdPropertyName$1(collection) {
2618
+ for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
2619
+ return "id";
2620
+ }
2621
+ /**
2622
+ * Returns the security rules that should be applied to a collection: the
2623
+ * author's explicit `securityRules` plus the framework defaults described in
2624
+ * the module doc (baseline server/admin read for all collections; self-read
2625
+ * and the admin write gate for auth collections).
2626
+ *
2627
+ * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
2628
+ */
2629
+ function getEffectiveSecurityRules(collection) {
2630
+ const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
2631
+ if (collection.disableDefaultPolicies) return explicit;
2632
+ const tableName = getTableName$1(collection);
2633
+ const injected = [];
2634
+ injected.push({
2635
+ name: `${tableName}_default_admin_read`,
2636
+ operations: ["select"],
2637
+ condition: SERVER_OR_ADMIN_EXPR$1
2638
+ });
2639
+ injected.push({
2640
+ name: `${tableName}_default_admin_write`,
2641
+ operations: [...DEFAULT_GUARDED_OPS],
2642
+ condition: SERVER_OR_ADMIN_EXPR$1,
2643
+ check: SERVER_OR_ADMIN_EXPR$1
2644
+ });
2645
+ if (isAuthCollection(collection)) {
2646
+ injected.push({
2647
+ name: `${tableName}_default_self_read`,
2648
+ operations: ["select"],
2649
+ condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
2650
+ });
2651
+ injected.push({
2652
+ name: `${tableName}_require_admin_write`,
2653
+ mode: "restrictive",
2654
+ operations: [...DEFAULT_GUARDED_OPS],
2655
+ condition: SERVER_OR_ADMIN_EXPR$1,
2656
+ check: SERVER_OR_ADMIN_EXPR$1
2657
+ });
2658
+ }
2659
+ return [...explicit, ...injected];
2660
+ }
2661
+ //#endregion
2662
+ //#region ../common/src/util/junction-policies.ts
2663
+ var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2664
+ /**
2665
+ * Walk every collection's resolved relations and aggregate the junction tables
2666
+ * they declare. Two collections may declare the same junction from opposite
2667
+ * sides (posts→tags and tags→posts through `posts_tags`); both become
2668
+ * `declaringSides` of one spec, so derived write grants consider both.
2669
+ */
2670
+ function resolveJunctionSpecs(collections) {
2671
+ const specs = /* @__PURE__ */ new Map();
2672
+ for (const collection of collections) {
2673
+ const resolved = resolveCollectionRelations(collection);
2674
+ for (const relation of Object.values(resolved)) {
2675
+ if (!relation.through) continue;
2676
+ const targetCollection = typeof relation.target === "function" ? relation.target() : void 0;
2677
+ if (!targetCollection) continue;
2678
+ const rawName = relation.through.table;
2679
+ const table = rawName.includes(".") ? rawName.split(".").pop() : rawName;
2680
+ const schema = "public";
2681
+ const source = {
2682
+ collection,
2683
+ junctionColumn: relation.through.sourceColumn,
2684
+ relation
2685
+ };
2686
+ const target = {
2687
+ collection: targetCollection,
2688
+ junctionColumn: relation.through.targetColumn
2689
+ };
2690
+ const existing = specs.get(table);
2691
+ if (!existing) specs.set(table, {
2692
+ table,
2693
+ schema,
2694
+ endpoints: [source, target],
2695
+ declaringSides: [source]
2696
+ });
2697
+ else if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
2698
+ }
2699
+ }
2700
+ return specs;
2701
+ }
2702
+ /**
2703
+ * A synthetic CollectionConfig standing in for the junction during policy
2704
+ * compilation and naming. Its two FK columns carry explicit `columnName`s so
2705
+ * `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
2706
+ * whatever their casing.
2707
+ */
2708
+ function getJunctionCollectionConfig(spec) {
2709
+ const properties = {};
2710
+ for (const endpoint of spec.endpoints) properties[endpoint.junctionColumn] = {
2711
+ type: "string",
2712
+ columnName: endpoint.junctionColumn
2713
+ };
2714
+ return {
2715
+ slug: spec.table,
2716
+ name: spec.table,
2717
+ table: spec.table,
2718
+ schema: spec.schema,
2719
+ properties
2720
+ };
2721
+ }
2722
+ /** The property marked as the row id (falls back to `id`). */
2723
+ function getIdPropertyName(collection) {
2724
+ for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
2725
+ return "id";
2726
+ }
2727
+ /** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */
2728
+ function existsEndpoint(endpoint, extra) {
2729
+ const correlation = policy.compare(policy.field(getIdPropertyName(endpoint.collection)), "eq", policy.outerField(endpoint.junctionColumn));
2730
+ return policy.existsIn({
2731
+ collection: endpoint.collection.slug,
2732
+ where: extra ? policy.and(correlation, extra) : correlation
2733
+ });
2734
+ }
2735
+ /**
2736
+ * Whether a parent-rule expression keeps its meaning when moved inside the
2737
+ * junction's `EXISTS` subquery — and the re-scoped copy if it does.
2738
+ *
2739
+ * Returns `null` when the rule cannot be embedded faithfully: `raw` SQL
2740
+ * anywhere (its `{column}` placeholders would bind to the junction), or an
2741
+ * `outerField` inside a nested `existsIn` (it would bind to the junction while
2742
+ * the author meant the parent, and no operand can express "the middle scope").
2743
+ * Top-level `outerField`s are rewritten to `field`, which is what they meant.
2744
+ */
2745
+ function embedParentExpression(expr, depth = 0) {
2746
+ switch (expr.kind) {
2747
+ case "raw": return null;
2748
+ case "and":
2749
+ case "or": {
2750
+ const parts = [];
2751
+ for (const child of expr.operands) {
2752
+ const embedded = embedParentExpression(child, depth);
2753
+ if (!embedded) return null;
2754
+ parts.push(embedded);
2755
+ }
2756
+ return expr.kind === "and" ? policy.and(...parts) : policy.or(...parts);
2757
+ }
2758
+ case "not": {
2759
+ const embedded = embedParentExpression(expr.operand, depth);
2760
+ return embedded ? policy.not(embedded) : null;
2761
+ }
2762
+ case "existsIn": {
2763
+ const where = embedParentExpression(expr.where, depth + 1);
2764
+ return where ? policy.existsIn({
2765
+ collection: expr.collection,
2766
+ where
2767
+ }) : null;
2768
+ }
2769
+ case "compare": {
2770
+ const left = embedOperand(expr.left, depth);
2771
+ const right = embedOperand(expr.right, depth);
2772
+ if (!left || !right) return null;
2773
+ return {
2774
+ ...expr,
2775
+ left,
2776
+ right
2777
+ };
2778
+ }
2779
+ default: return expr;
2780
+ }
2781
+ }
2782
+ /** Re-scope an operand, or return `null` if its binding cannot be preserved. */
2783
+ function embedOperand(operand, depth) {
2784
+ if (operand.kind === "outerField") {
2785
+ if (depth === 0) return policy.field(operand.name);
2786
+ return null;
2787
+ }
2788
+ return operand;
2789
+ }
2790
+ /** Does the rule cover the `update` operation? */
2791
+ function coversUpdate(rule) {
2792
+ return getPolicyOperations(rule).some((op) => op === "update" || op === "all");
2793
+ }
2794
+ /**
2795
+ * The full derived policy set for a junction table: the locked server/admin
2796
+ * baseline, the endpoint-visibility read grant, inherited write grants, and
2797
+ * inherited restrictive gates. Returns `[]` when every declaring collection set
2798
+ * `disableDefaultPolicies` — the junction is then the author's to police, and
2799
+ * stays locked (RLS is still enabled) until they write policies for it.
2800
+ */
2801
+ function getJunctionSecurityRules(spec) {
2802
+ if (spec.declaringSides.every((side) => side.collection.disableDefaultPolicies)) return [];
2803
+ const rules = [];
2804
+ rules.push({
2805
+ name: `${spec.table}_default_admin_read`,
2806
+ operations: ["select"],
2807
+ condition: SERVER_OR_ADMIN_EXPR
2808
+ });
2809
+ rules.push({
2810
+ name: `${spec.table}_default_admin_write`,
2811
+ operations: [
2812
+ "insert",
2813
+ "update",
2814
+ "delete"
2815
+ ],
2816
+ condition: SERVER_OR_ADMIN_EXPR,
2817
+ check: SERVER_OR_ADMIN_EXPR
2818
+ });
2819
+ rules.push({
2820
+ name: `${spec.table}_default_edge_read`,
2821
+ operations: ["select"],
2822
+ condition: policy.and(existsEndpoint(spec.endpoints[0]), existsEndpoint(spec.endpoints[1]))
2823
+ });
2824
+ const writeGrants = [];
2825
+ for (const side of spec.declaringSides) {
2826
+ const updateRules = ((isPostgresCollectionConfig(side.collection) ? side.collection.securityRules : void 0) ?? []).filter(coversUpdate);
2827
+ const permissive = updateRules.filter((r) => r.mode !== "restrictive");
2828
+ const restrictive = updateRules.filter((r) => r.mode === "restrictive");
2829
+ const embeddedGates = [];
2830
+ let gatesEmbeddable = true;
2831
+ for (const gate of restrictive) {
2832
+ const using = securityRuleToConditions(gate).usingExpr;
2833
+ const embedded = using ? embedParentExpression(using) : null;
2834
+ if (!embedded) {
2835
+ gatesEmbeddable = false;
2836
+ break;
2837
+ }
2838
+ embeddedGates.push(embedded);
2839
+ }
2840
+ if (!gatesEmbeddable) continue;
2841
+ const grants = [];
2842
+ for (const rule of permissive) {
2843
+ const using = securityRuleToConditions(rule).usingExpr;
2844
+ const embedded = using ? embedParentExpression(using) : null;
2845
+ if (embedded) grants.push(embedded);
2846
+ }
2847
+ if (grants.length === 0) continue;
2848
+ const condition = embeddedGates.length > 0 ? policy.and(policy.or(...grants), ...embeddedGates) : policy.or(...grants);
2849
+ writeGrants.push(existsEndpoint(side, condition));
2850
+ }
2851
+ if (writeGrants.length > 0) rules.push({
2852
+ name: `${spec.table}_default_edge_write`,
2853
+ operations: [
2854
+ "insert",
2855
+ "update",
2856
+ "delete"
2857
+ ],
2858
+ condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),
2859
+ check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)
2860
+ });
2861
+ return rules;
2862
+ }
2197
2863
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
2198
2864
  (function(root, factory) {
2199
2865
  if (typeof define === "function" && define.amd) define(factory);
@@ -3463,18 +4129,45 @@ function deserializeFilter(query) {
3463
4129
  }
3464
4130
  //#endregion
3465
4131
  //#region ../common/src/data/buildRebaseData.ts
4132
+ function createPrimaryKeyResolver(options) {
4133
+ const cache = /* @__PURE__ */ new Map();
4134
+ const warned = /* @__PURE__ */ new Set();
4135
+ return function primaryKeysFor(slug) {
4136
+ const cached = cache.get(slug);
4137
+ if (cached) return cached;
4138
+ const collection = options?.resolveCollection?.(slug);
4139
+ if (!collection) return [];
4140
+ const keys = resolvePrimaryKeys(collection);
4141
+ if (keys.length > 0) {
4142
+ cache.set(slug, keys);
4143
+ return keys;
4144
+ }
4145
+ if (!warned.has(slug)) {
4146
+ warned.add(slug);
4147
+ console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
4148
+ }
4149
+ return keys;
4150
+ };
4151
+ }
3466
4152
  /**
3467
- * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
3468
- * Mirrors the client SDK's rowToEntity conversion.
4153
+ * Give a flat row the Entity view-model the admin renders.
4154
+ *
4155
+ * The address is *derived here* — it is not a column, and the row it came from
4156
+ * does not contain one. Rows carry exactly what the table has, with the types
4157
+ * Postgres returned; the id is this layer's invention, and this is the only
4158
+ * place it is minted.
4159
+ *
4160
+ * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
4161
+ * than postgres still serve rows with one, and this keeps them working.
3469
4162
  */
3470
- function rowToEntity(row, slug) {
4163
+ function rowToEntity(row, slug, primaryKeys = []) {
3471
4164
  return {
3472
- id: row.id,
4165
+ id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
3473
4166
  path: slug,
3474
4167
  values: row
3475
4168
  };
3476
4169
  }
3477
- function createDriverAccessor(driver, slug) {
4170
+ function createDriverAccessor(driver, slug, getPks = () => []) {
3478
4171
  const accessor = {
3479
4172
  async find(params) {
3480
4173
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
@@ -3507,7 +4200,7 @@ function createDriverAccessor(driver, slug) {
3507
4200
  hasMore = offset + rows.length < total;
3508
4201
  }
3509
4202
  return {
3510
- data: rows.map((row) => rowToEntity(row, slug)),
4203
+ data: rows.map((row) => rowToEntity(row, slug, getPks())),
3511
4204
  meta: {
3512
4205
  total,
3513
4206
  limit,
@@ -3521,7 +4214,7 @@ function createDriverAccessor(driver, slug) {
3521
4214
  path: slug,
3522
4215
  id
3523
4216
  });
3524
- return row ? rowToEntity(row, slug) : void 0;
4217
+ return row ? rowToEntity(row, slug, getPks()) : void 0;
3525
4218
  },
3526
4219
  async create(data, id) {
3527
4220
  return rowToEntity(await driver.save({
@@ -3529,15 +4222,22 @@ function createDriverAccessor(driver, slug) {
3529
4222
  values: data,
3530
4223
  id,
3531
4224
  status: "new"
3532
- }), slug);
4225
+ }), slug, getPks());
3533
4226
  },
4227
+ createMany: driver.saveMany ? async (data, options) => {
4228
+ return (await driver.saveMany({
4229
+ path: slug,
4230
+ rows: data,
4231
+ upsert: options?.upsert
4232
+ })).map((row) => rowToEntity(row, slug, getPks()));
4233
+ } : void 0,
3534
4234
  async update(id, data) {
3535
4235
  return rowToEntity(await driver.save({
3536
4236
  path: slug,
3537
4237
  values: data,
3538
4238
  id,
3539
4239
  status: "existing"
3540
- }), slug);
4240
+ }), slug, getPks());
3541
4241
  },
3542
4242
  async delete(id) {
3543
4243
  return driver.delete({ row: {
@@ -3566,7 +4266,7 @@ function createDriverAccessor(driver, slug) {
3566
4266
  searchString: params?.searchString,
3567
4267
  onUpdate: (entities) => {
3568
4268
  onUpdate({
3569
- data: entities.map((row) => rowToEntity(row, slug)),
4269
+ data: entities.map((row) => rowToEntity(row, slug, getPks())),
3570
4270
  meta: {
3571
4271
  total: entities.length,
3572
4272
  limit,
@@ -3582,7 +4282,7 @@ function createDriverAccessor(driver, slug) {
3582
4282
  return driver.listenOne({
3583
4283
  path: slug,
3584
4284
  id,
3585
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
4285
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
3586
4286
  onError
3587
4287
  });
3588
4288
  } : void 0,
@@ -3621,12 +4321,13 @@ function createDriverAccessor(driver, slug) {
3621
4321
  * await data.products.create({ name: "Camera", price: 299 });
3622
4322
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
3623
4323
  */
3624
- function buildRebaseData(driver) {
4324
+ function buildRebaseData(driver, options) {
3625
4325
  const cache = /* @__PURE__ */ new Map();
4326
+ const primaryKeysFor = createPrimaryKeyResolver(options);
3626
4327
  function getAccessor(slug) {
3627
4328
  let accessor = cache.get(slug);
3628
4329
  if (!accessor) {
3629
- accessor = createDriverAccessor(driver, slug);
4330
+ accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
3630
4331
  cache.set(slug, accessor);
3631
4332
  }
3632
4333
  return accessor;
@@ -3639,8 +4340,9 @@ function buildRebaseData(driver) {
3639
4340
  } });
3640
4341
  }
3641
4342
  /**
3642
- * Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
3643
- * (id included) under `.values`, so this is just that payload.
4343
+ * Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
4344
+ * the row untouched under `.values` and derives `.id` alongside it, so dropping
4345
+ * the wrapper is the whole operation — the address was never part of the row.
3644
4346
  */
3645
4347
  function entityToRow(entity) {
3646
4348
  return entity.values;
@@ -3727,6 +4429,12 @@ function toSdkCollectionClient(snap) {
3727
4429
  async create(data, id) {
3728
4430
  return entityToRow(await snap.create(data, id));
3729
4431
  },
4432
+ async createMany(data, options) {
4433
+ if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
4434
+ if (data.length === 0) return [];
4435
+ if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
4436
+ return (await snap.createMany(data, options)).map(entityToRow);
4437
+ },
3730
4438
  async update(id, data) {
3731
4439
  return entityToRow(await snap.update(id, data));
3732
4440
  },
@@ -3879,8 +4587,26 @@ function getTableForCollection(collection, registry) {
3879
4587
  if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
3880
4588
  return table;
3881
4589
  }
4590
+ /**
4591
+ * The key columns a collection's rows are addressed by.
4592
+ *
4593
+ * Three tiers, in order: properties marked `isId`, the primary keys of the
4594
+ * drizzle schema, and finally a column literally named `id`. Only the first is
4595
+ * visible to the browser, which is why a key known only to drizzle is reported
4596
+ * at boot — see {@link warnOnKeysTheAdminCannotResolve}.
4597
+ *
4598
+ * Returns `[]` when nothing resolves, rather than throwing. It used to open by
4599
+ * resolving the table, which throws when there is none — so the `isId` tier,
4600
+ * which needs no table at all, was unreachable for exactly the collections
4601
+ * most likely to have no table registered. Every caller that wanted "no keys"
4602
+ * to mean "no keys" had to spell that out in a try/catch.
4603
+ *
4604
+ * Callers that cannot proceed without a key must say so themselves, naming the
4605
+ * collection: an empty array here means "this collection has no address", which
4606
+ * is a different answer in a notification (broadcast a wildcard) than in a save
4607
+ * (fail).
4608
+ */
3882
4609
  function getPrimaryKeys(collection, registry) {
3883
- const table = getTableForCollection(collection, registry);
3884
4610
  if (collection.properties) {
3885
4611
  const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
3886
4612
  fieldName: key,
@@ -3889,6 +4615,8 @@ function getPrimaryKeys(collection, registry) {
3889
4615
  }));
3890
4616
  if (idProps.length > 0) return idProps;
3891
4617
  }
4618
+ const table = registry.getTable(getTableName$1(collection));
4619
+ if (!table) return [];
3892
4620
  const keys = [];
3893
4621
  for (const [key, colRaw] of Object.entries(table)) {
3894
4622
  const col = colRaw;
@@ -3916,35 +4644,89 @@ function getPrimaryKeys(collection, registry) {
3916
4644
  }
3917
4645
  return keys;
3918
4646
  }
3919
- function parseIdValues(idValue, primaryKeys) {
3920
- const result = {};
3921
- if (primaryKeys.length === 0) return result;
3922
- if (primaryKeys.length === 1) {
3923
- const pk = primaryKeys[0];
3924
- if (pk.type === "number" && !pk.isUUID) {
3925
- const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
3926
- if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
3927
- result[pk.fieldName] = parsed;
3928
- } else result[pk.fieldName] = String(idValue);
3929
- return result;
3930
- }
3931
- const parts = String(idValue).split(":::");
3932
- if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
3933
- for (let i = 0; i < primaryKeys.length; i++) {
3934
- const pk = primaryKeys[i];
3935
- const val = parts[i];
3936
- if (pk.type === "number" && !pk.isUUID) {
3937
- const parsed = parseInt(val, 10);
3938
- if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
3939
- result[pk.fieldName] = parsed;
3940
- } else result[pk.fieldName] = val;
4647
+ /**
4648
+ * The key columns, for callers that cannot do their job without one.
4649
+ *
4650
+ * {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
4651
+ * collection with no address. Most of this driver, though, is building a WHERE
4652
+ * clause and has no meaning without a key — for those, an empty array is not an
4653
+ * answer, and indexing `[0]` into it produces `Cannot read properties of
4654
+ * undefined` three frames from where the real problem is. This says what is
4655
+ * wrong and which collection it is wrong about.
4656
+ */
4657
+ function requirePrimaryKeys(collection, registry) {
4658
+ const keys = getPrimaryKeys(collection, registry);
4659
+ if (keys.length === 0) throw new Error(`Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`);
4660
+ return keys;
4661
+ }
4662
+ /**
4663
+ * Collections whose key the *browser* cannot resolve, and what it will do
4664
+ * instead.
4665
+ *
4666
+ * The two sides resolve keys from different evidence. This driver reads, in
4667
+ * order: properties marked `isId`, the primary keys of the Drizzle schema, then
4668
+ * a column literally named `id`. The admin shares the `CollectionConfig` — it
4669
+ * compiles the same collection files into its bundle — but never the Drizzle
4670
+ * schema, so the middle tier is invisible to it.
4671
+ *
4672
+ * Nothing can normalize this at runtime: the server does not serve the admin
4673
+ * its collections, so a key resolved here cannot be handed over there. The
4674
+ * config files are the only thing both sides read, so the fix is an edit to
4675
+ * them, and the most this can do is say exactly which edit.
4676
+ *
4677
+ * Two shapes, and the second is the dangerous one:
4678
+ *
4679
+ * - No `isId`, no `id` property → the admin resolves no address, warns in the
4680
+ * console, and rows cannot be opened or linked.
4681
+ * - No `isId`, but an `id` property that is *not* the key → the admin addresses
4682
+ * rows by `id` while this driver reads the address as the real key. Nothing
4683
+ * errors: the addresses look right and route wrong.
4684
+ */
4685
+ function findUnresolvableKeyCollections(collections, registry) {
4686
+ const findings = [];
4687
+ for (const collection of collections) {
4688
+ if (getDeclaredPrimaryKeys(collection).length > 0) continue;
4689
+ const keys = getPrimaryKeys(collection, registry);
4690
+ if (keys.length === 0) continue;
4691
+ if (keys.length === 1 && keys[0].fieldName === "id") continue;
4692
+ findings.push({
4693
+ collection,
4694
+ keys,
4695
+ shadowedByIdProperty: Boolean(collection.properties?.id)
4696
+ });
3941
4697
  }
3942
- return result;
4698
+ return findings;
3943
4699
  }
3944
- function buildCompositeId(values, primaryKeys) {
3945
- if (primaryKeys.length === 0) return "";
3946
- if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
3947
- return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
4700
+ /**
4701
+ * Report the collections from {@link findUnresolvableKeyCollections} at boot,
4702
+ * with the edit that fixes each one.
4703
+ *
4704
+ * Grouped by failure, not by collection: the shadowed case is a routing bug and
4705
+ * the silent case is a missing feature, and they deserve different urgency.
4706
+ */
4707
+ function warnOnKeysTheAdminCannotResolve(collections, registry) {
4708
+ const findings = findUnresolvableKeyCollections(collections, registry);
4709
+ if (findings.length === 0) return;
4710
+ const edit = (f) => `${f.collection.slug}: mark ${f.keys.map((k) => `\`${k.fieldName}\``).join(" and ")} with \`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
4711
+ const shadowed = findings.filter((f) => f.shadowedByIdProperty);
4712
+ const silent = findings.filter((f) => !f.shadowedByIdProperty);
4713
+ if (shadowed.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema — but they do have a property called `id`. The admin has no way to know `id` is not the key, so it will address rows by it while this server reads the address as the real key: the links look right and route wrong. Nothing will error.\n\n" + shadowed.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
4714
+ if (silent.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema, which the admin never sees. It will resolve no address for their rows, so detail links, caching and relations will not work for them:\n\n" + silent.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
4715
+ }
4716
+ /**
4717
+ * The address of a row: derived from the collection's primary keys, because a
4718
+ * row does not carry one — it is exactly its columns.
4719
+ *
4720
+ * Falls back to a literal `id` column, for a row that reached us from somewhere
4721
+ * other than this driver. Returns `""` when there is no key and no `id` —
4722
+ * callers decide what that means, since "unaddressable" is a different answer
4723
+ * in a notification (broadcast a wildcard) than in a save (fail).
4724
+ */
4725
+ function deriveRowAddress(row, collection, registry) {
4726
+ const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
4727
+ if (composite && composite.split(":::").some((part) => part !== "")) return composite;
4728
+ if (row.id !== void 0 && row.id !== null) return String(row.id);
4729
+ return "";
3948
4730
  }
3949
4731
  //#endregion
3950
4732
  //#region src/utils/drizzle-conditions.ts
@@ -4484,6 +5266,7 @@ var DrizzleConditionBuilder = class {
4484
5266
  static buildVectorSearchConditions(table, vectorSearch) {
4485
5267
  const column = table[vectorSearch.property];
4486
5268
  if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
5269
+ if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
4487
5270
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
4488
5271
  const distanceFn = vectorSearch.distance || "cosine";
4489
5272
  let operator;
@@ -4567,12 +5350,10 @@ function serializeDataToServer(row, properties, collection, registry) {
4567
5350
  continue;
4568
5351
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
4569
5352
  const serializedValue = serializePropertyToServer(effectiveValue, property);
4570
- const pks = getPrimaryKeys(collection, registry);
4571
5353
  inverseRelationUpdates.push({
4572
5354
  relationKey: key,
4573
5355
  relation,
4574
- newValue: serializedValue,
4575
- currentId: row.id || buildCompositeId(row, pks)
5356
+ newValue: serializedValue
4576
5357
  });
4577
5358
  continue;
4578
5359
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
@@ -4582,15 +5363,11 @@ function serializeDataToServer(row, properties, collection, registry) {
4582
5363
  relation,
4583
5364
  newTargetId: serializedValue
4584
5365
  });
4585
- else {
4586
- const pks = getPrimaryKeys(collection, registry);
4587
- inverseRelationUpdates.push({
4588
- relationKey: key,
4589
- relation,
4590
- newValue: serializedValue,
4591
- currentId: row.id || buildCompositeId(row, pks)
4592
- });
4593
- }
5366
+ else inverseRelationUpdates.push({
5367
+ relationKey: key,
5368
+ relation,
5369
+ newValue: serializedValue
5370
+ });
4594
5371
  continue;
4595
5372
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
4596
5373
  const serializedValue = serializePropertyToServer(effectiveValue, property);
@@ -4990,6 +5767,63 @@ var RelationService = class {
4990
5767
  this.registry = registry;
4991
5768
  }
4992
5769
  /**
5770
+ * One target row, as the {@link RelatedRow} everything here returns.
5771
+ *
5772
+ * Eight sites built this by hand, which is how the address came to be the
5773
+ * target's first key column in all eight — one edit, eight places to miss.
5774
+ *
5775
+ * `resolveNested` is the one thing they did not agree on, and the
5776
+ * disagreement was invisible: the single-parent fetches pass `db` and
5777
+ * `registry` to `parseDataFromServer`, so the target's *own* relations get
5778
+ * resolved too, while the batch paths deliberately do not — a query per
5779
+ * target row is the N+1 the batching exists to avoid. Naming the parameter
5780
+ * makes that a decision rather than a difference between two call sites
5781
+ * nobody was comparing.
5782
+ */
5783
+ async toRelatedRow(targetRow, targetCollection, targetPks, options) {
5784
+ const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
5785
+ return {
5786
+ id: buildCompositeId(targetRow, targetPks),
5787
+ path: targetCollection.slug,
5788
+ values
5789
+ };
5790
+ }
5791
+ /**
5792
+ * A WHERE matching any of `parentIds`, by the whole key.
5793
+ *
5794
+ * A single key is an `IN (…)`. A composite one cannot be: matching
5795
+ * `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
5796
+ * share their first column each receive the other's relations. It becomes
5797
+ * an OR of ANDs — one exact address per parent — which Postgres indexes the
5798
+ * same way it would a multi-column key lookup.
5799
+ */
5800
+ parentKeyCondition(parentTable, parentPks, parentIds) {
5801
+ const columnFor = (fieldName) => {
5802
+ const col = parentTable[fieldName];
5803
+ if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
5804
+ return col;
5805
+ };
5806
+ if (parentPks.length === 1) {
5807
+ const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
5808
+ return inArray(columnFor(parentPks[0].fieldName), values);
5809
+ }
5810
+ return or(...parentIds.map((id) => {
5811
+ const values = parseIdValues(id, parentPks);
5812
+ return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
5813
+ }));
5814
+ }
5815
+ /**
5816
+ * Reject a relation that cannot express a composite-keyed parent.
5817
+ *
5818
+ * `localKey` and `foreignKeyOnTarget` are single column names: one column
5819
+ * cannot reference a two-column key, so such a relation has no correct
5820
+ * reading. Left alone it would silently match on the first key column and
5821
+ * hand a tenant's rows to its neighbour — say so instead.
5822
+ */
5823
+ assertSingleKeyAddressable(parentCollection, parentPks, via) {
5824
+ if (parentPks.length > 1) throw new Error(`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but '${parentCollection.slug}' is keyed on ${parentPks.map((k) => `'${k.fieldName}'`).join(" + ")}. One column cannot reference a composite key — express this relation with \`joinPath\`, whose \`on.from\`/\`on.to\` take every key column.`);
5825
+ }
5826
+ /**
4993
5827
  * Fetch rows related to a parent row through a specific relation
4994
5828
  */
4995
5829
  async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
@@ -5008,9 +5842,9 @@ var RelationService = class {
5008
5842
  async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
5009
5843
  const targetCollection = relation.target();
5010
5844
  const targetTable = getTableForCollection(targetCollection, this.registry);
5011
- const idInfo = getPrimaryKeys(targetCollection, this.registry);
5845
+ const idInfo = requirePrimaryKeys(targetCollection, this.registry);
5012
5846
  const idField = targetTable[idInfo[0].fieldName];
5013
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5847
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5014
5848
  const parentIdInfo = parentPks[0];
5015
5849
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5016
5850
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5034,7 +5868,7 @@ var RelationService = class {
5034
5868
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5035
5869
  currentTable = joinTable;
5036
5870
  }
5037
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5871
+ const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
5038
5872
  query = query.where(eq(parentIdField, parsedParentId));
5039
5873
  if (options.limit) query = query.limit(options.limit);
5040
5874
  const results = await query;
@@ -5042,13 +5876,7 @@ var RelationService = class {
5042
5876
  const rows = [];
5043
5877
  for (const row of results) {
5044
5878
  const targetRow = row[targetTableName] || row;
5045
- const id = targetRow[idInfo[0].fieldName];
5046
- const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5047
- rows.push({
5048
- id: id?.toString() || "",
5049
- path: targetCollection.slug,
5050
- values: parsedValues
5051
- });
5879
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5052
5880
  }
5053
5881
  return rows;
5054
5882
  }
@@ -5067,13 +5895,7 @@ var RelationService = class {
5067
5895
  const rows = [];
5068
5896
  for (const row of results) {
5069
5897
  const targetRow = row[getTableName$1(targetCollection)] || row;
5070
- const id = targetRow[idInfo[0].fieldName];
5071
- const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5072
- rows.push({
5073
- id: id?.toString() || "",
5074
- path: targetCollection.slug,
5075
- values: parsedValues
5076
- });
5898
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5077
5899
  }
5078
5900
  return rows;
5079
5901
  }
@@ -5090,8 +5912,8 @@ var RelationService = class {
5090
5912
  }
5091
5913
  const targetCollection = relation.target();
5092
5914
  const targetTable = getTableForCollection(targetCollection, this.registry);
5093
- const targetIdField = targetTable[getPrimaryKeys(targetCollection, this.registry)[0].fieldName];
5094
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5915
+ const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
5916
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5095
5917
  const parentIdInfo = parentPks[0];
5096
5918
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5097
5919
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5110,9 +5932,10 @@ var RelationService = class {
5110
5932
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5111
5933
  const targetCollection = relation.target();
5112
5934
  const targetTable = getTableForCollection(targetCollection, this.registry);
5113
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5935
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5936
+ const targetIdInfo = targetPks[0];
5114
5937
  const targetIdField = targetTable[targetIdInfo.fieldName];
5115
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5938
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5116
5939
  const parentIdInfo = parentPks[0];
5117
5940
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5118
5941
  if (!parentTable) throw new Error("Parent table not found");
@@ -5136,25 +5959,19 @@ var RelationService = class {
5136
5959
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5137
5960
  currentTable = joinTable;
5138
5961
  }
5139
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5140
- query = query.where(inArray(parentIdField, parsedParentIds));
5962
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5141
5963
  const results = await query;
5142
5964
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5143
5965
  const resultMap = /* @__PURE__ */ new Map();
5144
5966
  for (const row of results) {
5145
5967
  const parentRow = row[getTableName$1(parentCollection)] || row;
5146
5968
  const targetRow = row[targetTableName] || row;
5147
- const parentId = parentRow[parentIdInfo.fieldName];
5148
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5149
- resultMap.set(String(parentId), {
5150
- id: String(targetRow[targetIdInfo.fieldName]),
5151
- path: targetCollection.slug,
5152
- values: parsedValues
5153
- });
5969
+ resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5154
5970
  }
5155
5971
  return resultMap;
5156
5972
  }
5157
5973
  if (relation.direction === "owning" && relation.localKey) {
5974
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
5158
5975
  const localKeyCol = parentTable[relation.localKey];
5159
5976
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
5160
5977
  const fkRows = await this.db.select({
@@ -5183,17 +6000,11 @@ var RelationService = class {
5183
6000
  const resultMap = /* @__PURE__ */ new Map();
5184
6001
  for (const [parentIdStr, fkValue] of parentToFk) {
5185
6002
  const targetRow = targetById.get(String(fkValue));
5186
- if (targetRow) {
5187
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5188
- resultMap.set(parentIdStr, {
5189
- id: String(targetRow[targetIdInfo.fieldName]),
5190
- path: targetCollection.slug,
5191
- values: parsedValues
5192
- });
5193
- }
6003
+ if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
5194
6004
  }
5195
6005
  return resultMap;
5196
6006
  }
6007
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5197
6008
  let query = this.db.select().from(targetTable).$dynamic();
5198
6009
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5199
6010
  const results = await query;
@@ -5204,14 +6015,7 @@ var RelationService = class {
5204
6015
  let parentId;
5205
6016
  if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5206
6017
  else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
5207
- if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5208
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5209
- resultMap.set(String(parentId), {
5210
- id: String(targetRow[targetIdInfo.fieldName]),
5211
- path: targetCollection.slug,
5212
- values: parsedValues
5213
- });
5214
- }
6018
+ if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5215
6019
  }
5216
6020
  return resultMap;
5217
6021
  }
@@ -5225,9 +6029,9 @@ var RelationService = class {
5225
6029
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5226
6030
  const targetCollection = relation.target();
5227
6031
  const targetTable = getTableForCollection(targetCollection, this.registry);
5228
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5229
- const targetIdField = targetTable[targetIdInfo.fieldName];
5230
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
6032
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6033
+ const targetIdField = targetTable[targetPks[0].fieldName];
6034
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5231
6035
  const parentIdInfo = parentPks[0];
5232
6036
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5233
6037
  if (!parentTable) throw new Error("Parent table not found");
@@ -5249,27 +6053,22 @@ var RelationService = class {
5249
6053
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5250
6054
  currentTable = joinTable;
5251
6055
  }
5252
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5253
- query = query.where(inArray(parentIdField, parsedParentIds));
6056
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5254
6057
  const results = await query;
5255
6058
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5256
6059
  const resultMap = /* @__PURE__ */ new Map();
5257
6060
  for (const row of results) {
5258
6061
  const parentRow = row[getTableName$1(parentCollection)] || row;
5259
6062
  const targetRow = row[targetTableName] || row;
5260
- const parentId = String(parentRow[parentIdInfo.fieldName]);
5261
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6063
+ const parentId = buildCompositeId(parentRow, parentPks);
5262
6064
  const arr = resultMap.get(parentId) || [];
5263
- arr.push({
5264
- id: String(targetRow[targetIdInfo.fieldName]),
5265
- path: targetCollection.slug,
5266
- values: parsedValues
5267
- });
6065
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5268
6066
  resultMap.set(parentId, arr);
5269
6067
  }
5270
6068
  return resultMap;
5271
6069
  }
5272
6070
  if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
6071
+ this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
5273
6072
  const junctionTable = this.registry.getTable(relation.through.table);
5274
6073
  if (!junctionTable) {
5275
6074
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -5288,17 +6087,13 @@ var RelationService = class {
5288
6087
  const junctionData = row[relation.through.table] || row;
5289
6088
  const targetData = row[targetTableName] || row;
5290
6089
  const parentId = String(junctionData[relation.through.sourceColumn]);
5291
- const parsedValues = await parseDataFromServer(targetData, targetCollection);
5292
6090
  const arr = resultMap.get(parentId) || [];
5293
- arr.push({
5294
- id: String(targetData[targetIdInfo.fieldName]),
5295
- path: targetCollection.slug,
5296
- values: parsedValues
5297
- });
6091
+ arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
5298
6092
  resultMap.set(parentId, arr);
5299
6093
  }
5300
6094
  return resultMap;
5301
6095
  }
6096
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5302
6097
  let query = this.db.select().from(targetTable).$dynamic();
5303
6098
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5304
6099
  const results = await query;
@@ -5311,14 +6106,9 @@ var RelationService = class {
5311
6106
  else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5312
6107
  else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
5313
6108
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5314
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5315
6109
  const key = String(parentId);
5316
6110
  const arr = resultMap.get(key) || [];
5317
- arr.push({
5318
- id: String(targetRow[targetIdInfo.fieldName]),
5319
- path: targetCollection.slug,
5320
- values: parsedValues
5321
- });
6111
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5322
6112
  resultMap.set(key, arr);
5323
6113
  }
5324
6114
  }
@@ -5366,12 +6156,12 @@ var RelationService = class {
5366
6156
  logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
5367
6157
  continue;
5368
6158
  }
5369
- const parentPks = getPrimaryKeys(collection, this.registry);
6159
+ const parentPks = requirePrimaryKeys(collection, this.registry);
5370
6160
  const parentIdInfo = parentPks[0];
5371
6161
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
5372
6162
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
5373
6163
  if (targetEntityIds.length > 0) {
5374
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6164
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5375
6165
  const targetIdInfo = targetPks[0];
5376
6166
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
5377
6167
  [sourceJunctionColumn.name]: parsedParentId,
@@ -5391,12 +6181,12 @@ var RelationService = class {
5391
6181
  logger.warn(`Junction columns not found for relation '${key}'`);
5392
6182
  continue;
5393
6183
  }
5394
- const parentPks = getPrimaryKeys(collection, this.registry);
6184
+ const parentPks = requirePrimaryKeys(collection, this.registry);
5395
6185
  const parentIdInfo = parentPks[0];
5396
6186
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
5397
6187
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
5398
6188
  if (targetEntityIds.length > 0) {
5399
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6189
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5400
6190
  const targetIdInfo = targetPks[0];
5401
6191
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
5402
6192
  [sourceJunctionColumn.name]: parsedParentId,
@@ -5407,7 +6197,7 @@ var RelationService = class {
5407
6197
  } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
5408
6198
  else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
5409
6199
  const targetTable = getTableForCollection(targetCollection, this.registry);
5410
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6200
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5411
6201
  const targetIdInfo = targetPks[0];
5412
6202
  const targetIdCol = targetTable[targetIdInfo.fieldName];
5413
6203
  const fkCol = targetTable[relation.foreignKeyOnTarget];
@@ -5415,7 +6205,7 @@ var RelationService = class {
5415
6205
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
5416
6206
  continue;
5417
6207
  }
5418
- const parentPks = getPrimaryKeys(collection, this.registry);
6208
+ const parentPks = requirePrimaryKeys(collection, this.registry);
5419
6209
  const parentIdInfo = parentPks[0];
5420
6210
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
5421
6211
  if (targetEntityIds.length > 0) {
@@ -5435,9 +6225,9 @@ var RelationService = class {
5435
6225
  try {
5436
6226
  const targetCollection = relation.target();
5437
6227
  const targetTable = getTableForCollection(targetCollection, this.registry);
5438
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6228
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5439
6229
  const targetIdInfo = targetPks[0];
5440
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6230
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5441
6231
  const sourceIdInfo = sourcePks[0];
5442
6232
  if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
5443
6233
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
@@ -5518,12 +6308,12 @@ var RelationService = class {
5518
6308
  logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
5519
6309
  return;
5520
6310
  }
5521
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6311
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5522
6312
  const sourceIdInfo = sourcePks[0];
5523
6313
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
5524
6314
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
5525
6315
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
5526
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6316
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5527
6317
  const targetIdInfo = targetPks[0];
5528
6318
  const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
5529
6319
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -5531,7 +6321,7 @@ var RelationService = class {
5531
6321
  }));
5532
6322
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
5533
6323
  } else if (newValue && !Array.isArray(newValue)) {
5534
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6324
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5535
6325
  const targetIdInfo = targetPks[0];
5536
6326
  const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
5537
6327
  const newLink = {
@@ -5562,12 +6352,12 @@ var RelationService = class {
5562
6352
  logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
5563
6353
  return;
5564
6354
  }
5565
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6355
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5566
6356
  const sourceIdInfo = sourcePks[0];
5567
6357
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
5568
6358
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
5569
6359
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
5570
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6360
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5571
6361
  const targetIdInfo = targetPks[0];
5572
6362
  const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
5573
6363
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -5588,12 +6378,12 @@ var RelationService = class {
5588
6378
  const { relation, newTargetId } = upd;
5589
6379
  const targetCollection = relation.target();
5590
6380
  const targetTable = getTableForCollection(targetCollection, this.registry);
5591
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6381
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5592
6382
  const targetIdInfo = targetPks[0];
5593
6383
  const targetIdCol = targetTable[targetIdInfo.fieldName];
5594
6384
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
5595
6385
  const parentTable = getTableForCollection(parentCollection, this.registry);
5596
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
6386
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5597
6387
  const parentIdInfo = parentPks[0];
5598
6388
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5599
6389
  const parentIdCol = parentTable[parentIdInfo.fieldName];
@@ -5664,7 +6454,7 @@ var RelationService = class {
5664
6454
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
5665
6455
  return;
5666
6456
  }
5667
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6457
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5668
6458
  const targetIdInfo = targetPks[0];
5669
6459
  const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
5670
6460
  const junctionData = {
@@ -5678,7 +6468,151 @@ var RelationService = class {
5678
6468
  throw error;
5679
6469
  }
5680
6470
  }
5681
- };
6471
+ };
6472
+ //#endregion
6473
+ //#region src/services/row-pipeline.ts
6474
+ /**
6475
+ * Whether a many-relation reaches its target through a junction table.
6476
+ *
6477
+ * Also used to build the drizzle `with` config, which is why it is exported:
6478
+ * the query has to nest one level deeper for a junction, and the row walk has
6479
+ * to unwrap that same level back out.
6480
+ */
6481
+ function isJunctionRelation(relation) {
6482
+ if (relation.through) return true;
6483
+ if (relation.joinPath && relation.joinPath.length > 1) return true;
6484
+ return false;
6485
+ }
6486
+ /**
6487
+ * Reach the target row inside a junction row.
6488
+ *
6489
+ * A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
6490
+ * the foreign keys, and the target nested under one of them. The target is the
6491
+ * only object among them, so that is how it is found. A junction row that has
6492
+ * not been nested (no `with` on the join) has no object and is returned as-is.
6493
+ */
6494
+ function unwrapJunctionRow(item) {
6495
+ const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
6496
+ return nestedKey ? item[nestedKey] : item;
6497
+ }
6498
+ /**
6499
+ * Give back the number a `number` property was declared to be.
6500
+ *
6501
+ * Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
6502
+ * numeric can hold more precision than a double. But the collection declared
6503
+ * this property `number` and the OpenAPI spec this server publishes says
6504
+ * `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
6505
+ * asymmetrically, because a create answers with the number and the read that
6506
+ * follows answers with the string. Any client that multiplies a price works
6507
+ * until the first refresh.
6508
+ *
6509
+ * Declaring a property `number` already accepts double precision — that is what
6510
+ * the admin has always parsed it to. Columns REST serves that no property
6511
+ * declares are left exactly as the database returned them.
6512
+ */
6513
+ function coerceDeclaredNumber(value, property) {
6514
+ if (property?.type !== "number" || typeof value !== "string") return value;
6515
+ const parsed = parseFloat(value);
6516
+ return isNaN(parsed) ? null : parsed;
6517
+ }
6518
+ /** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
6519
+ function coerceDeclaredNumbers(row, collection) {
6520
+ const properties = collection.properties;
6521
+ if (!properties) return row;
6522
+ const out = {};
6523
+ for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
6524
+ return out;
6525
+ }
6526
+ /** Render one target row in the requested style. */
6527
+ /**
6528
+ * Drop every column the collection marked `excludeFromApi`.
6529
+ *
6530
+ * Password hashes and verification tokens have to be readable server-side but
6531
+ * must never reach a client — and "never" has to mean every exit from this
6532
+ * pipeline, including relation targets, or a secret leaks through whichever
6533
+ * path was overlooked. Keyed by both the property name and its column name,
6534
+ * since a row can arrive keyed either way depending on the caller.
6535
+ */
6536
+ function stripExcluded(row, collection) {
6537
+ const properties = collection.properties;
6538
+ if (!properties) return row;
6539
+ for (const [key, property] of Object.entries(properties)) {
6540
+ if (!property?.excludeFromApi) continue;
6541
+ delete row[key];
6542
+ if (property.columnName) delete row[property.columnName];
6543
+ }
6544
+ return row;
6545
+ }
6546
+ function renderTarget(targetRow, targetCollection, style, registry) {
6547
+ if (style === "inline") return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
6548
+ const address = relationTargetAddress(targetRow, targetCollection, registry);
6549
+ const path = targetCollection.slug;
6550
+ return createRelationRefWithData(address, path, {
6551
+ id: address,
6552
+ path,
6553
+ values: normalizeDbValues(targetRow, targetCollection)
6554
+ });
6555
+ }
6556
+ /**
6557
+ * The address a relation ref points at.
6558
+ *
6559
+ * The whole key, not its first column: a composite-keyed target addressed by
6560
+ * `tenant_id` alone points at every row that shares it. A target whose key
6561
+ * cannot be resolved at all used to throw here — reading `[0]` of an empty
6562
+ * array — taking down the parent's fetch over a relation it may not even have
6563
+ * asked for. The first column is a guess, but a ref that resolves to nothing
6564
+ * beats no rows at all.
6565
+ */
6566
+ function relationTargetAddress(targetRow, targetCollection, registry) {
6567
+ const address = deriveRowAddress(targetRow, targetCollection, registry);
6568
+ if (address) return address;
6569
+ return String(targetRow[Object.keys(targetRow)[0]] ?? "");
6570
+ }
6571
+ /**
6572
+ * The row the admin renders: every column, with relations as references.
6573
+ *
6574
+ * Values are normalized (dates, numbers, NaN) because the admin's view-model
6575
+ * expects real types. The row's own address is *not* among the columns — it is
6576
+ * derived by the consumer from the collection's primary keys.
6577
+ */
6578
+ function toCmsRow(row, collection, registry) {
6579
+ const resolvedRelations = resolveCollectionRelations(collection);
6580
+ const normalized = normalizeDbValues(row, collection);
6581
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
6582
+ const relData = row[relation.relationName || key];
6583
+ if (relData === void 0 || relData === null) continue;
6584
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
6585
+ const targetCollection = relation.target();
6586
+ normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
6587
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
6588
+ }
6589
+ return stripExcluded(normalized, collection);
6590
+ }
6591
+ /**
6592
+ * The row REST serves: every column under its own name, with the value Postgres
6593
+ * returned, and relations inlined as the target's columns.
6594
+ *
6595
+ * Values are the ones the database returned, except where that contradicts the
6596
+ * declared type: a `number` property is served as a number (see
6597
+ * {@link coerceDeclaredNumber}). Dates stay as the database returned them —
6598
+ * JSON has its own opinions about dates that the admin's view-model does not
6599
+ * share.
6600
+ *
6601
+ * Keyed by the row rather than by the relation list — a REST fetch only loads
6602
+ * the relations `include` asked for, so the row is the authority on which are
6603
+ * actually there.
6604
+ */
6605
+ function toRestRow(row, collection, registry) {
6606
+ const resolvedRelations = resolveCollectionRelations(collection);
6607
+ const flat = {};
6608
+ for (const [key, value] of Object.entries(row)) {
6609
+ const relation = findRelation(resolvedRelations, key);
6610
+ if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
6611
+ else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
6612
+ else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
6613
+ }
6614
+ return stripExcluded(flat, collection);
6615
+ }
5682
6616
  //#endregion
5683
6617
  //#region src/services/FetchService.ts
5684
6618
  /**
@@ -5738,7 +6672,7 @@ var FetchService = class {
5738
6672
  if (!shouldInclude(key)) continue;
5739
6673
  const drizzleRelName = relation.relationName || key;
5740
6674
  if (relation.joinPath && relation.joinPath.length > 0) continue;
5741
- if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
6675
+ if (relation.cardinality === "many" && isJunctionRelation(relation)) {
5742
6676
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
5743
6677
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
5744
6678
  else withConfig[drizzleRelName] = true;
@@ -5747,14 +6681,6 @@ var FetchService = class {
5747
6681
  return withConfig;
5748
6682
  }
5749
6683
  /**
5750
- * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
5751
- */
5752
- isJunctionRelation(relation, _collection) {
5753
- if (relation.through) return true;
5754
- if (relation.joinPath && relation.joinPath.length > 1) return true;
5755
- return false;
5756
- }
5757
- /**
5758
6684
  * Get the Drizzle relation name on the junction table that points to the actual target row.
5759
6685
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
5760
6686
  */
@@ -5763,55 +6689,6 @@ var FetchService = class {
5763
6689
  return null;
5764
6690
  }
5765
6691
  /**
5766
- * Convert a db.query result row (with nested relation objects) to a flat row.
5767
- * Handles:
5768
- * - Placing `id` at the top level as a string
5769
- * - Type normalization (dates, numbers, NaN) via normalizeDbValues
5770
- * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
5771
- * - Flattening junction-table many-to-many results
5772
- */
5773
- drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
5774
- const resolvedRelations = resolveCollectionRelations(collection);
5775
- const normalizedValues = normalizeDbValues(row, collection);
5776
- for (const [key, relation] of Object.entries(resolvedRelations)) {
5777
- const relData = row[relation.relationName || key];
5778
- if (relData === void 0 || relData === null) continue;
5779
- if (relation.cardinality === "many" && Array.isArray(relData)) {
5780
- const targetCollection = relation.target();
5781
- const targetPath = targetCollection.slug;
5782
- const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
5783
- normalizedValues[key] = relData.map((item) => {
5784
- let targetRow = item;
5785
- if (this.isJunctionRelation(relation, collection)) {
5786
- const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
5787
- if (nestedKey) targetRow = item[nestedKey];
5788
- }
5789
- const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
5790
- return createRelationRefWithData(relId, targetPath, {
5791
- id: relId,
5792
- path: targetPath,
5793
- values: normalizeDbValues(targetRow, targetCollection)
5794
- });
5795
- });
5796
- } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
5797
- const targetCollection = relation.target();
5798
- const targetPath = targetCollection.slug;
5799
- const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
5800
- const relObj = relData;
5801
- const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
5802
- normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
5803
- id: relId,
5804
- path: targetPath,
5805
- values: normalizeDbValues(relObj, targetCollection)
5806
- });
5807
- }
5808
- }
5809
- return {
5810
- ...normalizedValues,
5811
- id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
5812
- };
5813
- }
5814
- /**
5815
6692
  * Post-fetch joinPath relations for a single flat row.
5816
6693
  * joinPath relations cannot be expressed via Drizzle's `with` config,
5817
6694
  * so they must be loaded separately after the primary query.
@@ -5832,31 +6709,6 @@ var FetchService = class {
5832
6709
  await Promise.all(promises);
5833
6710
  }
5834
6711
  /**
5835
- * Post-fetch joinPath relations for a batch of flat rows.
5836
- * Uses batch fetching to avoid N+1 queries for list views.
5837
- */
5838
- async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
5839
- if (rows.length === 0) return;
5840
- const resolvedRelations = resolveCollectionRelations(collection);
5841
- const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
5842
- if (joinPathRelations.length === 0) return;
5843
- for (const [key, relation] of joinPathRelations) try {
5844
- const rowIds = rows.map((r) => {
5845
- return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
5846
- });
5847
- const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
5848
- for (const row of rows) {
5849
- const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
5850
- const relatedRow = resultMap.get(String(id));
5851
- if (relatedRow) {
5852
- if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
5853
- }
5854
- }
5855
- } catch (e) {
5856
- logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
5857
- }
5858
- }
5859
- /**
5860
6712
  * Resolves joinPath relations for raw REST rows and directly injects them.
5861
6713
  * Uses RelationService to query the database and maps results back to the flattened objects.
5862
6714
  */
@@ -5867,72 +6719,29 @@ var FetchService = class {
5867
6719
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
5868
6720
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
5869
6721
  if (joinPathRelations.length === 0) return;
5870
- const idInfo = idInfoArray[0];
6722
+ const parentIdOf = (row) => {
6723
+ const address = buildCompositeId(row, idInfoArray);
6724
+ return address && address.split(":::").some((part) => part !== "") ? address : void 0;
6725
+ };
5871
6726
  for (const [key, relation] of joinPathRelations) try {
5872
- const rowIds = rows.map((r) => {
5873
- return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
5874
- });
6727
+ const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
6728
+ if (addressable.length === 0) continue;
6729
+ const rowIds = addressable.map((r) => parentIdOf(r));
5875
6730
  if (relation.cardinality === "one") {
5876
6731
  const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
5877
- for (const row of rows) {
5878
- const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
5879
- const relatedRow = resultMap.get(String(id));
5880
- if (relatedRow) row[key] = {
5881
- ...relatedRow.values,
5882
- id: relatedRow.id
5883
- };
5884
- else row[key] = null;
6732
+ for (const row of addressable) {
6733
+ const relatedRow = resultMap.get(String(parentIdOf(row)));
6734
+ row[key] = relatedRow ? { ...relatedRow.values } : null;
5885
6735
  }
5886
6736
  } else if (relation.cardinality === "many") {
5887
6737
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
5888
- for (const row of rows) {
5889
- const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
5890
- row[key] = (resultMap.get(String(id)) || []).map((e) => ({
5891
- ...e.values,
5892
- id: e.id
5893
- }));
5894
- }
6738
+ for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
5895
6739
  }
5896
6740
  } catch (e) {
5897
6741
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
5898
6742
  }
5899
6743
  }
5900
6744
  /**
5901
- * Convert a db.query result row to a flat REST-style object with populated relations.
5902
- */
5903
- drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
5904
- const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
5905
- const resolvedRelations = resolveCollectionRelations(collection);
5906
- for (const [k, v] of Object.entries(row)) {
5907
- if (k === idInfo.fieldName) continue;
5908
- const relation = findRelation(resolvedRelations, k);
5909
- if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
5910
- if (this.isJunctionRelation(relation, collection)) {
5911
- const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
5912
- if (nestedKey) {
5913
- const nested = item[nestedKey];
5914
- return {
5915
- ...nested,
5916
- id: String(nested.id ?? nested[Object.keys(nested)[0]])
5917
- };
5918
- }
5919
- }
5920
- return {
5921
- ...item,
5922
- id: String(item.id ?? item[Object.keys(item)[0]])
5923
- };
5924
- });
5925
- else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
5926
- const relObj = v;
5927
- flat[k] = {
5928
- ...relObj,
5929
- id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
5930
- };
5931
- } else flat[k] = v;
5932
- }
5933
- return flat;
5934
- }
5935
- /**
5936
6745
  * Build db.query-compatible options from standard fetch options.
5937
6746
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
5938
6747
  */
@@ -6002,7 +6811,7 @@ var FetchService = class {
6002
6811
  async fetchOne(collectionPath, id, databaseId) {
6003
6812
  const collection = getCollectionByPath(collectionPath, this.registry);
6004
6813
  const table = getTableForCollection(collection, this.registry);
6005
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6814
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6006
6815
  const idInfo = idInfoArray[0];
6007
6816
  const idField = table[idInfo.fieldName];
6008
6817
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6016,7 +6825,7 @@ var FetchService = class {
6016
6825
  with: withConfig
6017
6826
  });
6018
6827
  if (!row) return void 0;
6019
- const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
6828
+ const flatRow = toCmsRow(row, collection, this.registry);
6020
6829
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6021
6830
  return flatRow;
6022
6831
  } catch (e) {
@@ -6058,7 +6867,7 @@ var FetchService = class {
6058
6867
  async fetchRowsWithConditions(collectionPath, options = {}) {
6059
6868
  const collection = getCollectionByPath(collectionPath, this.registry);
6060
6869
  const table = getTableForCollection(collection, this.registry);
6061
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6870
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6062
6871
  const idInfo = idInfoArray[0];
6063
6872
  const idField = table[idInfo.fieldName];
6064
6873
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6068,7 +6877,7 @@ var FetchService = class {
6068
6877
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6069
6878
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6070
6879
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6071
- return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray));
6880
+ return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
6072
6881
  } catch (e) {
6073
6882
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6074
6883
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6129,8 +6938,10 @@ var FetchService = class {
6129
6938
  }
6130
6939
  /**
6131
6940
  * Fallback path used when db.query is unavailable.
6132
- * The primary path uses drizzleResultToRow which handles relation
6133
- * mapping without N+1 queries.
6941
+ *
6942
+ * The primary path runs the results through `toCmsRow`, which maps
6943
+ * relations from what drizzle already nested — no query per row. This one
6944
+ * has no nesting to read, so it resolves relations itself, in batches.
6134
6945
  *
6135
6946
  * Process raw database results into flat rows with relations.
6136
6947
  */
@@ -6139,8 +6950,7 @@ var FetchService = class {
6139
6950
  const parsedRows = await Promise.all(results.map(async (rawRow) => {
6140
6951
  return {
6141
6952
  rawRow,
6142
- values: await parseDataFromServer(rawRow, collection),
6143
- id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
6953
+ values: await parseDataFromServer(rawRow, collection)
6144
6954
  };
6145
6955
  }));
6146
6956
  if (!skipRelations) {
@@ -6180,10 +6990,7 @@ var FetchService = class {
6180
6990
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
6181
6991
  }
6182
6992
  }
6183
- return parsedRows.map((item) => ({
6184
- ...item.values,
6185
- id: item.id
6186
- }));
6993
+ return parsedRows.map((item) => item.values);
6187
6994
  }
6188
6995
  /**
6189
6996
  * Fetch a collection of rows
@@ -6214,10 +7021,7 @@ var FetchService = class {
6214
7021
  const relationKey = pathSegments[i];
6215
7022
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
6216
7023
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
6217
- if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
6218
- ...row.values,
6219
- id: row.id
6220
- }));
7024
+ if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
6221
7025
  if (i + 1 < pathSegments.length) {
6222
7026
  const nextEntityId = pathSegments[i + 1];
6223
7027
  currentCollection = relation.target();
@@ -6279,7 +7083,7 @@ var FetchService = class {
6279
7083
  if (value === void 0 || value === null) return true;
6280
7084
  const collection = getCollectionByPath(collectionPath, this.registry);
6281
7085
  const table = getTableForCollection(collection, this.registry);
6282
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7086
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6283
7087
  const idInfo = idInfoArray[0];
6284
7088
  const idField = table[idInfo.fieldName];
6285
7089
  const field = table[fieldName];
@@ -6306,7 +7110,7 @@ var FetchService = class {
6306
7110
  async fetchCollectionForRest(collectionPath, options = {}, include) {
6307
7111
  const collection = getCollectionByPath(collectionPath, this.registry);
6308
7112
  const table = getTableForCollection(collection, this.registry);
6309
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7113
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6310
7114
  const idInfo = idInfoArray[0];
6311
7115
  const idField = table[idInfo.fieldName];
6312
7116
  const tableName = getTableName(table);
@@ -6314,7 +7118,7 @@ var FetchService = class {
6314
7118
  if (qb && !options.searchString && !options.vectorSearch) try {
6315
7119
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
6316
7120
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
6317
- const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray));
7121
+ const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
6318
7122
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
6319
7123
  return restRows;
6320
7124
  } catch (e) {
@@ -6325,10 +7129,7 @@ var FetchService = class {
6325
7129
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
6326
7130
  }
6327
7131
  const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
6328
- if (!include || include.length === 0) return rows.map((row) => ({
6329
- ...row,
6330
- id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6331
- }));
7132
+ if (!include || include.length === 0) return rows;
6332
7133
  const resolvedRelations = resolveCollectionRelations(collection);
6333
7134
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
6334
7135
  const shouldInclude = (key) => include[0] === "*" || include.includes(key);
@@ -6340,10 +7141,7 @@ var FetchService = class {
6340
7141
  for (const row of rows) {
6341
7142
  const eid = row[idInfo.fieldName];
6342
7143
  const related = batchResults.get(String(eid));
6343
- if (related) row[key] = {
6344
- ...related.values,
6345
- id: related.id
6346
- };
7144
+ if (related) row[key] = { ...related.values };
6347
7145
  }
6348
7146
  } catch (e) {
6349
7147
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
@@ -6361,10 +7159,7 @@ var FetchService = class {
6361
7159
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
6362
7160
  }
6363
7161
  }
6364
- return rows.map((row) => ({
6365
- ...row,
6366
- id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6367
- }));
7162
+ return rows;
6368
7163
  }
6369
7164
  /**
6370
7165
  * Fetch a single row with optional relation includes for REST API.
@@ -6372,7 +7167,7 @@ var FetchService = class {
6372
7167
  async fetchOneForRest(collectionPath, id, include, databaseId) {
6373
7168
  const collection = getCollectionByPath(collectionPath, this.registry);
6374
7169
  const table = getTableForCollection(collection, this.registry);
6375
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7170
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6376
7171
  const idInfo = idInfoArray[0];
6377
7172
  const idField = table[idInfo.fieldName];
6378
7173
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
@@ -6385,7 +7180,7 @@ var FetchService = class {
6385
7180
  ...withConfig ? { with: withConfig } : {}
6386
7181
  });
6387
7182
  if (!row) return null;
6388
- const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
7183
+ const restRow = toRestRow(row, collection, this.registry);
6389
7184
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
6390
7185
  return restRow;
6391
7186
  } catch (e) {
@@ -6397,11 +7192,7 @@ var FetchService = class {
6397
7192
  }
6398
7193
  const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
6399
7194
  if (result.length === 0) return null;
6400
- const raw = result[0];
6401
- const flatEntity = {
6402
- ...raw,
6403
- id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
6404
- };
7195
+ const flatEntity = { ...result[0] };
6405
7196
  if (!include || include.length === 0) return flatEntity;
6406
7197
  const resolvedRelations = resolveCollectionRelations(collection);
6407
7198
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
@@ -6434,7 +7225,7 @@ var FetchService = class {
6434
7225
  async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
6435
7226
  const collection = getCollectionByPath(collectionPath, this.registry);
6436
7227
  const table = getTableForCollection(collection, this.registry);
6437
- const idField = table[getPrimaryKeys(collection, this.registry)[0].fieldName];
7228
+ const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
6438
7229
  let vectorMeta;
6439
7230
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
6440
7231
  let query = vectorMeta ? this.db.select({
@@ -6513,32 +7304,15 @@ var FetchService = class {
6513
7304
  if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
6514
7305
  }
6515
7306
  return (await queryTarget.findMany(queryOpts)).map((row) => {
6516
- const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
6517
- for (const [k, v] of Object.entries(row)) {
6518
- if (k === idInfo.fieldName) continue;
6519
- if (Array.isArray(v)) flat[k] = v.map((item) => {
6520
- const keys = Object.keys(item);
6521
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6522
- if (nestedObj && keys.length <= 3) {
6523
- const nested = item[nestedObj];
6524
- return {
6525
- ...nested,
6526
- id: String(nested.id ?? nested[Object.keys(nested)[0]])
6527
- };
6528
- }
6529
- return {
6530
- ...item,
6531
- id: String(item.id ?? item[Object.keys(item)[0]])
6532
- };
6533
- });
6534
- else if (typeof v === "object" && v !== null) {
6535
- const relObj = v;
6536
- flat[k] = {
6537
- ...relObj,
6538
- id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
6539
- };
6540
- } else flat[k] = v;
6541
- }
7307
+ const flat = {};
7308
+ for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
7309
+ const keys = Object.keys(item);
7310
+ const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
7311
+ if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
7312
+ return { ...item };
7313
+ });
7314
+ else if (typeof v === "object" && v !== null) flat[k] = { ...v };
7315
+ else flat[k] = v;
6542
7316
  return flat;
6543
7317
  });
6544
7318
  } catch (e) {
@@ -6754,6 +7528,27 @@ var PersistService = class {
6754
7528
  this.fetchService = new FetchService(db, registry);
6755
7529
  }
6756
7530
  /**
7531
+ * Explain a write that matched no rows.
7532
+ *
7533
+ * Row-level security filters UPDATE and DELETE through the policy's USING
7534
+ * clause instead of raising: a denied write is reported by Postgres exactly
7535
+ * like a successful one that happened to match nothing. Left unchecked, a
7536
+ * caller cannot tell "denied" from "done" — the write returns 200/204 and
7537
+ * the row is untouched.
7538
+ *
7539
+ * Re-reading the target over the *same* RLS-scoped handle separates the two
7540
+ * cases. A visible row means the policy rejected the write (403); an
7541
+ * invisible one means there is nothing there to write for this caller (404,
7542
+ * matching what a GET would say). The re-read is bound by the caller's own
7543
+ * policies, so it discloses nothing a plain read wouldn't.
7544
+ *
7545
+ * Only reached when zero rows matched, so the happy path pays nothing.
7546
+ */
7547
+ async explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
7548
+ if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(`Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`, "WRITE_DENIED");
7549
+ return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
7550
+ }
7551
+ /**
6757
7552
  * Delete an row by ID
6758
7553
  */
6759
7554
  async delete(collectionPath, id, _databaseId) {
@@ -6767,7 +7562,7 @@ var PersistService = class {
6767
7562
  if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
6768
7563
  conditions.push(eq(field, parsedIdObj[info.fieldName]));
6769
7564
  }
6770
- await this.db.delete(table).where(and(...conditions));
7565
+ if (((await this.db.delete(table).where(and(...conditions))).rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
6771
7566
  }
6772
7567
  /**
6773
7568
  * Delete all rows from a collection
@@ -6778,8 +7573,14 @@ var PersistService = class {
6778
7573
  }
6779
7574
  /**
6780
7575
  * Save an row (create or update)
7576
+ *
7577
+ * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
7578
+ * UPDATE against the primary key rather than a plain UPDATE. That is one
7579
+ * statement, so it cannot lose a race the way a read-then-write can, and it
7580
+ * does not care whether the row already exists — which is what a re-runnable
7581
+ * import needs.
6781
7582
  */
6782
- async save(collectionPath, values, id, databaseId) {
7583
+ async save(collectionPath, values, id, databaseId, options) {
6783
7584
  let effectiveCollectionPath = collectionPath;
6784
7585
  const effectiveValues = { ...values };
6785
7586
  let junctionTableInfo;
@@ -6870,7 +7671,7 @@ var PersistService = class {
6870
7671
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
6871
7672
  savedId = await this.db.transaction(async (tx) => {
6872
7673
  let currentId;
6873
- if (id) {
7674
+ if (id && !options?.upsert) {
6874
7675
  currentId = id;
6875
7676
  const idValues = parseIdValues(id, idInfoArray);
6876
7677
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
@@ -6881,13 +7682,28 @@ var PersistService = class {
6881
7682
  const field = table[info.fieldName];
6882
7683
  conditions.push(eq(field, idValues[info.fieldName]));
6883
7684
  }
6884
- await updateQuery.where(and(...conditions));
7685
+ if (((await updateQuery.where(and(...conditions))).rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(tx, table, conditions, effectiveCollectionPath, currentId, "update");
6885
7686
  }
6886
7687
  } else {
6887
7688
  const dataForInsert = { ...entityData };
7689
+ if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
6888
7690
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
6889
- const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
6890
- currentId = buildCompositeId(resultRow, idInfoArray);
7691
+ const insertQuery = tx.insert(table).values(dataForInsert);
7692
+ const hasFullKey = idInfoArray.length > 0 && idInfoArray.every((info) => dataForInsert[info.fieldName] !== void 0);
7693
+ let result;
7694
+ if (options?.upsert && hasFullKey) {
7695
+ const target = idInfoArray.map((info) => table[info.fieldName]);
7696
+ const set = { ...dataForInsert };
7697
+ for (const info of idInfoArray) delete set[info.fieldName];
7698
+ result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
7699
+ target,
7700
+ set
7701
+ }).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
7702
+ } else result = await insertQuery.returning(returningKeys);
7703
+ const resultRow = result[0];
7704
+ if (!resultRow) if (id) currentId = id;
7705
+ else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
7706
+ else currentId = buildCompositeId(resultRow, idInfoArray);
6891
7707
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
6892
7708
  }
6893
7709
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
@@ -6898,7 +7714,7 @@ var PersistService = class {
6898
7714
  } catch (error) {
6899
7715
  throw this.toUserFriendlyError(error, collection.slug);
6900
7716
  }
6901
- const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
7717
+ const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
6902
7718
  if (!finalEntity) throw new Error("Could not fetch row after save.");
6903
7719
  return finalEntity;
6904
7720
  }
@@ -6918,6 +7734,7 @@ var PersistService = class {
6918
7734
  * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
6919
7735
  */
6920
7736
  toUserFriendlyError(error, collectionSlug) {
7737
+ if (error instanceof ApiError || error?.name === "ApiError") return error;
6921
7738
  const pgError = extractPgError(error);
6922
7739
  if (pgError) {
6923
7740
  const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
@@ -6995,8 +7812,8 @@ var DataService = class {
6995
7812
  /**
6996
7813
  * Save an row (create or update)
6997
7814
  */
6998
- async save(collectionPath, values, id, databaseId) {
6999
- return this.persistService.save(collectionPath, values, id, databaseId);
7815
+ async save(collectionPath, values, id, databaseId, options) {
7816
+ return this.persistService.save(collectionPath, values, id, databaseId, options);
7000
7817
  }
7001
7818
  /**
7002
7819
  * Delete an row by ID
@@ -7064,6 +7881,26 @@ var DataService = class {
7064
7881
  */
7065
7882
  /** Internal prefix applied to branch database names to avoid collisions. */
7066
7883
  var BRANCH_DB_PREFIX = "rb_";
7884
+ /** `duplicate_database` — the target database name is already taken. */
7885
+ var PG_DUPLICATE_DATABASE = "42P04";
7886
+ /** `object_in_use` — the database still has connections attached. */
7887
+ var PG_OBJECT_IN_USE = "55006";
7888
+ /**
7889
+ * Describe a failed branch DDL statement in terms a user can act on.
7890
+ *
7891
+ * Drizzle reports failures as `Failed query: <sql> params:` and hides the real
7892
+ * PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
7893
+ * the actual problem. Match on the PG error code instead — it survives wrapping
7894
+ * and, unlike the message text, is not locale-dependent.
7895
+ */
7896
+ function describeBranchDdlError(err, fallbackContext) {
7897
+ const pgError = extractPgError(err);
7898
+ if (pgError?.code === PG_DUPLICATE_DATABASE) return /* @__PURE__ */ new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
7899
+ if (pgError?.code === PG_OBJECT_IN_USE) return /* @__PURE__ */ new Error(`Cannot complete the operation: the database "${fallbackContext}" has active connections. Close other clients or connections and try again.`);
7900
+ const detail = pgError?.message ?? extractCauseMessage(err);
7901
+ if (detail) return new Error(detail);
7902
+ return err instanceof Error ? err : new Error(String(err));
7903
+ }
7067
7904
  /** Fully-qualified metadata table in the rebase schema. */
7068
7905
  var BRANCHES_TABLE = "rebase.branches";
7069
7906
  /**
@@ -7132,10 +7969,8 @@ var BranchService = class {
7132
7969
  try {
7133
7970
  await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
7134
7971
  } catch (err) {
7135
- const msg = err instanceof Error ? err.message : String(err);
7136
- if (msg.includes("already exists")) throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
7137
- if (msg.includes("being accessed by other users")) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
7138
- throw err;
7972
+ if (extractPgError(err)?.code === PG_OBJECT_IN_USE) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
7973
+ throw describeBranchDdlError(err, dbName);
7139
7974
  }
7140
7975
  const now = /* @__PURE__ */ new Date();
7141
7976
  await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
@@ -7160,8 +7995,8 @@ var BranchService = class {
7160
7995
  try {
7161
7996
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
7162
7997
  } catch (err) {
7163
- if ((err instanceof Error ? err.message : String(err)).includes("being accessed by other users")) throw new Error(`Cannot delete branch "${sanitizedName}": the database has active connections. Close other clients and try again.`);
7164
- throw err;
7998
+ if (extractPgError(err)?.code === PG_OBJECT_IN_USE) throw new Error(`Cannot delete branch "${sanitizedName}": the database has active connections. Close other clients and try again.`);
7999
+ throw describeBranchDdlError(err, dbName);
7165
8000
  }
7166
8001
  await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
7167
8002
  }
@@ -7347,7 +8182,7 @@ async function ensureAppRole(run, schemas) {
7347
8182
  * SECURITY: this function is only ever called on the **user** path (the server
7348
8183
  * context uses the base/owner driver and never calls it). The default policies
7349
8184
  * treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
7350
- * is `NULLIF(current_setting('app.user_id'), '')` — so an EMPTY user id would
8185
+ * is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
7351
8186
  * be read as NULL and silently escalate a user request to server privileges.
7352
8187
  * Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
7353
8188
  * rather than trusting every caller (e.g. realtime subscription auth) to do it.
@@ -7355,14 +8190,15 @@ async function ensureAppRole(run, schemas) {
7355
8190
  * semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
7356
8191
  */
7357
8192
  async function applyAuthContext(tx, auth, userRole) {
7358
- const userId = typeof auth.userId === "string" && auth.userId.trim() !== "" ? auth.userId : ANONYMOUS_USER_ID;
8193
+ const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
7359
8194
  const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
7360
8195
  await tx.execute(sql`
7361
8196
  SELECT
7362
- set_config('app.user_id', ${userId}, true),
8197
+ set_config('app.uid', ${uid}, true),
8198
+ set_config('app.user_id', ${uid}, true),
7363
8199
  set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
7364
8200
  set_config('app.jwt', ${JSON.stringify({
7365
- sub: userId,
8201
+ sub: uid,
7366
8202
  roles: auth.roles
7367
8203
  })}, true)
7368
8204
  `);
@@ -7508,6 +8344,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7508
8344
  executeSql: (...args) => this.executeSql(...args),
7509
8345
  fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
7510
8346
  fetchAvailableRoles: () => this.fetchAvailableRoles(),
8347
+ fetchApplicationRoles: () => this.fetchApplicationRoles(),
7511
8348
  fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
7512
8349
  fetchUnmappedTables: (...args) => this.fetchUnmappedTables(...args),
7513
8350
  fetchTableMetadata: (...args) => this.fetchTableMetadata(...args),
@@ -7793,17 +8630,19 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7793
8630
  this.realtimeService.subscriptions.delete(subscriptionId);
7794
8631
  };
7795
8632
  }
7796
- async save({ path, id, values, collection, status }) {
8633
+ async save({ path, id, values, collection, status, upsert }) {
7797
8634
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
7798
8635
  let updatedValues = values;
7799
8636
  const contextForCallback = this.buildCallContext();
7800
8637
  let previousValuesForHistory;
7801
- if (status === "existing" && id) {
7802
- const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8638
+ if (status === "existing" && id) try {
8639
+ const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
7803
8640
  if (existing) {
7804
8641
  const { id: _existingId, ...existingValues } = existing;
7805
8642
  previousValuesForHistory = existingValues;
7806
8643
  }
8644
+ } catch (err) {
8645
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
7807
8646
  }
7808
8647
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
7809
8648
  if (globalCallbacks?.beforeSave) {
@@ -7850,7 +8689,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7850
8689
  timestampNowValue: /* @__PURE__ */ new Date()
7851
8690
  });
7852
8691
  try {
7853
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
8692
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
7854
8693
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
7855
8694
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
7856
8695
  collection: resolvedCollection,
@@ -7871,8 +8710,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7871
8710
  context: contextForCallback
7872
8711
  });
7873
8712
  }
7874
- const savedId = savedRow.id;
7875
- const { id: _savedId, ...savedValues } = savedRow;
8713
+ const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
8714
+ const savedValues = savedRow;
7876
8715
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
7877
8716
  if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
7878
8717
  collection: resolvedCollection,
@@ -7904,7 +8743,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7904
8743
  }
7905
8744
  if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
7906
8745
  tableName: path,
7907
- id: savedId.toString(),
8746
+ id: savedId,
7908
8747
  action: status === "new" ? "create" : "update",
7909
8748
  values: savedValues,
7910
8749
  previousValues: previousValuesForHistory,
@@ -7912,11 +8751,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7912
8751
  });
7913
8752
  if (this._deferNotifications) this._pendingNotifications.push({
7914
8753
  path,
7915
- id: savedId.toString(),
8754
+ id: savedId,
7916
8755
  row: savedRow,
7917
8756
  databaseId: resolvedCollection?.databaseId
7918
8757
  });
7919
- else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
8758
+ else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
7920
8759
  return savedRow;
7921
8760
  } catch (error) {
7922
8761
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
@@ -7951,12 +8790,52 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7951
8790
  throw error;
7952
8791
  }
7953
8792
  }
8793
+ /**
8794
+ * Write many rows through the same pipeline as {@link save}.
8795
+ *
8796
+ * The batch runs in one transaction of its own, so a failure part-way leaves
8797
+ * nothing behind — the point of a batch is that a re-run starts from a known
8798
+ * state. When this driver is already inside a transaction (the authenticated
8799
+ * path, via `withTransaction`) the nested call becomes a savepoint, which is
8800
+ * still atomic and still commits once.
8801
+ *
8802
+ * Rows are applied in order, so a batch that touches the same key twice ends
8803
+ * with the last write winning, exactly as separate calls would.
8804
+ */
8805
+ async saveMany({ path, rows, collection, upsert }) {
8806
+ return this.db.transaction(async (tx) => {
8807
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
8808
+ txDriver.dataService = new DataService(tx, this.registry);
8809
+ txDriver.client = this.client;
8810
+ txDriver._deferNotifications = this._deferNotifications;
8811
+ txDriver._pendingNotifications = this._pendingNotifications;
8812
+ const saved = [];
8813
+ for (let i = 0; i < rows.length; i++) {
8814
+ const values = rows[i];
8815
+ const id = values?.id;
8816
+ try {
8817
+ saved.push(await txDriver.save({
8818
+ path,
8819
+ values,
8820
+ collection,
8821
+ status: "new",
8822
+ upsert
8823
+ }));
8824
+ } catch (error) {
8825
+ const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
8826
+ throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
8827
+ statusCode: error?.statusCode,
8828
+ code: error?.code,
8829
+ name: error?.name
8830
+ });
8831
+ }
8832
+ }
8833
+ return saved;
8834
+ });
8835
+ }
7954
8836
  async delete({ row, collection }) {
7955
8837
  const targetPath = row.path;
7956
- const targetRow = {
7957
- id: row.id,
7958
- ...row.values ?? {}
7959
- };
8838
+ const targetRow = { ...row.values ?? {} };
7960
8839
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
7961
8840
  const contextForCallback = this.buildCallContext();
7962
8841
  if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -8119,6 +8998,45 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8119
8998
  async fetchAvailableRoles() {
8120
8999
  return (await this.executeSql("SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, rolname, 'member') ORDER BY rolname;")).map((r) => r.rolname);
8121
9000
  }
9001
+ /**
9002
+ * Application-level roles actually in use in this project.
9003
+ *
9004
+ * Distinct from {@link fetchAvailableRoles}, which returns native
9005
+ * PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
9006
+ * are the roles the SQL editor can `SET ROLE` to. *These* are the strings
9007
+ * held in the users table's `roles` column, injected per-transaction as
9008
+ * `auth.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
9009
+ * into a `SecurityRule.roles` field produces a condition no user can ever
9010
+ * satisfy, so the two must not be conflated.
9011
+ *
9012
+ * Roles have no registry table — they were migrated out of
9013
+ * `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
9014
+ * set is derived from what is assigned. A role that is declared in a policy
9015
+ * but held by nobody yet cannot be discovered here; callers that need it
9016
+ * should union in the roles they already know about.
9017
+ */
9018
+ async fetchApplicationRoles() {
9019
+ const located = await this.executeSql(`
9020
+ SELECT table_schema, table_name
9021
+ FROM information_schema.columns
9022
+ WHERE column_name = 'roles'
9023
+ AND data_type = 'ARRAY'
9024
+ AND table_name = 'users'
9025
+ AND table_schema NOT IN ('information_schema', 'pg_catalog')
9026
+ ORDER BY (table_schema = 'rebase') DESC, table_schema
9027
+ LIMIT 1;
9028
+ `);
9029
+ if (located.length === 0) return [];
9030
+ const schema = located[0].table_schema;
9031
+ const table = located[0].table_name;
9032
+ const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
9033
+ return (await this.executeSql(`
9034
+ SELECT DISTINCT unnest(roles) AS role
9035
+ FROM ${qualified}
9036
+ WHERE roles IS NOT NULL
9037
+ ORDER BY role;
9038
+ `)).map((r) => r.role).filter((r) => typeof r === "string" && r.length > 0);
9039
+ }
8122
9040
  async fetchCurrentDatabase() {
8123
9041
  return this.poolManager?.defaultDatabaseName;
8124
9042
  }
@@ -8260,15 +9178,15 @@ var AuthenticatedPostgresBackendDriver = class {
8260
9178
  async withTransaction(operation, options) {
8261
9179
  const pendingNotifications = [];
8262
9180
  const result = await this.delegate.db.transaction(async (tx) => {
8263
- let userId = this.user?.uid;
8264
- if (!userId) {
9181
+ let uid = this.user?.uid;
9182
+ if (!uid) {
8265
9183
  logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
8266
- userId = "anonymous";
9184
+ uid = "anonymous";
8267
9185
  }
8268
9186
  const userRoles = this.user?.roles ?? [];
8269
9187
  if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
8270
9188
  await applyAuthContext(tx, {
8271
- userId,
9189
+ uid,
8272
9190
  roles: userRoles
8273
9191
  }, this.delegate.rlsUserRole);
8274
9192
  const txEntityService = new DataService(tx, this.delegate.registry);
@@ -8295,7 +9213,7 @@ var AuthenticatedPostgresBackendDriver = class {
8295
9213
  */
8296
9214
  injectAuthContext(unsubscribe) {
8297
9215
  const authContext = {
8298
- userId: this.user?.uid || "anonymous",
9216
+ uid: this.user?.uid || "anonymous",
8299
9217
  roles: this.user?.roles ?? []
8300
9218
  };
8301
9219
  const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
@@ -8315,6 +9233,18 @@ var AuthenticatedPostgresBackendDriver = class {
8315
9233
  async save(props) {
8316
9234
  return this.withTransaction((delegate) => delegate.save(props));
8317
9235
  }
9236
+ /**
9237
+ * One transaction for the whole batch, rather than one per row.
9238
+ *
9239
+ * This is the point of the method: `save` opens a transaction per call, so
9240
+ * importing 10k rows through it means 10k transactions (and, over HTTP, 10k
9241
+ * round trips). Here the RLS context is established once and every row lands
9242
+ * or none does. Realtime notifications are already deferred to commit by
9243
+ * `withTransaction`, so a batch does not flood subscribers mid-flight.
9244
+ */
9245
+ async saveMany(props) {
9246
+ return this.withTransaction((delegate) => delegate.saveMany(props));
9247
+ }
8318
9248
  async delete(props) {
8319
9249
  return this.withTransaction((delegate) => delegate.delete(props));
8320
9250
  }
@@ -8364,6 +9294,7 @@ var DatabasePoolManager = class {
8364
9294
  pool.on("error", (err) => {
8365
9295
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
8366
9296
  });
9297
+ guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
8367
9298
  this.pools.set(databaseName, pool);
8368
9299
  return pool;
8369
9300
  }
@@ -8426,19 +9357,19 @@ function createAuthSchema(usersSchemaName = "rebase") {
8426
9357
  */
8427
9358
  const refreshTokens = tableCreator("refresh_tokens", {
8428
9359
  id: uuid("id").defaultRandom().primaryKey(),
8429
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9360
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
8430
9361
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
8431
9362
  expiresAt: timestamp("expires_at").notNull(),
8432
9363
  userAgent: varchar("user_agent", { length: 500 }),
8433
9364
  ipAddress: varchar("ip_address", { length: 45 }),
8434
9365
  createdAt: timestamp("created_at").defaultNow().notNull()
8435
- }, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.userId, table.userAgent, table.ipAddress) }));
9366
+ }, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.uid, table.userAgent, table.ipAddress) }));
8436
9367
  /**
8437
9368
  * Password reset tokens for forgot password flow
8438
9369
  */
8439
9370
  const passwordResetTokens = tableCreator("password_reset_tokens", {
8440
9371
  id: uuid("id").defaultRandom().primaryKey(),
8441
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9372
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
8442
9373
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
8443
9374
  expiresAt: timestamp("expires_at").notNull(),
8444
9375
  usedAt: timestamp("used_at"),
@@ -8457,7 +9388,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
8457
9388
  */
8458
9389
  const userIdentities = tableCreator("user_identities", {
8459
9390
  id: uuid("id").defaultRandom().primaryKey(),
8460
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9391
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
8461
9392
  provider: varchar("provider", { length: 50 }).notNull(),
8462
9393
  providerId: varchar("provider_id", { length: 255 }).notNull(),
8463
9394
  profileData: jsonb("profile_data"),
@@ -8469,7 +9400,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
8469
9400
  */
8470
9401
  const mfaFactors = tableCreator("mfa_factors", {
8471
9402
  id: uuid("id").defaultRandom().primaryKey(),
8472
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9403
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
8473
9404
  factorType: varchar("factor_type", { length: 20 }).notNull(),
8474
9405
  secretEncrypted: varchar("secret_encrypted", { length: 500 }).notNull(),
8475
9406
  friendlyName: varchar("friendly_name", { length: 255 }),
@@ -8495,14 +9426,14 @@ function createAuthSchema(usersSchemaName = "rebase") {
8495
9426
  }),
8496
9427
  recoveryCodes: tableCreator("recovery_codes", {
8497
9428
  id: uuid("id").defaultRandom().primaryKey(),
8498
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9429
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
8499
9430
  codeHash: varchar("code_hash", { length: 255 }).notNull(),
8500
9431
  usedAt: timestamp("used_at"),
8501
9432
  createdAt: timestamp("created_at").defaultNow().notNull()
8502
9433
  }),
8503
9434
  magicLinkTokens: tableCreator("magic_link_tokens", {
8504
9435
  id: uuid("id").defaultRandom().primaryKey(),
8505
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9436
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
8506
9437
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
8507
9438
  expiresAt: timestamp("expires_at").notNull(),
8508
9439
  usedAt: timestamp("used_at"),
@@ -8530,135 +9461,36 @@ var usersRelations = relations(users, ({ many }) => ({
8530
9461
  magicLinkTokens: many(magicLinkTokens)
8531
9462
  }));
8532
9463
  var refreshTokensRelations = relations(refreshTokens, ({ one }) => ({ user: one(users, {
8533
- fields: [refreshTokens.userId],
8534
- references: [users.id]
8535
- }) }));
8536
- var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
8537
- fields: [passwordResetTokens.userId],
8538
- references: [users.id]
8539
- }) }));
8540
- var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
8541
- fields: [userIdentities.userId],
8542
- references: [users.id]
8543
- }) }));
8544
- var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
8545
- user: one(users, {
8546
- fields: [mfaFactors.userId],
8547
- references: [users.id]
8548
- }),
8549
- challenges: many(mfaChallenges)
8550
- }));
8551
- var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: one(mfaFactors, {
8552
- fields: [mfaChallenges.factorId],
8553
- references: [mfaFactors.id]
8554
- }) }));
8555
- var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
8556
- fields: [recoveryCodes.userId],
8557
- references: [users.id]
8558
- }) }));
8559
- var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
8560
- fields: [magicLinkTokens.userId],
8561
- references: [users.id]
8562
- }) }));
8563
- //#endregion
8564
- //#region src/schema/auth-default-policies.ts
8565
- /**
8566
- * Default RLS policies injected by the schema generator.
8567
- *
8568
- * Rebase's enforcement model is unified: authenticated (user-context) requests
8569
- * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
8570
- * statement — reads and writes. A collection's `securityRules` are the whole
8571
- * authorization model. The server context (auth flows, migrations,
8572
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
8573
- *
8574
- * Because RLS default-denies, every collection is **locked by default**: with
8575
- * no rules, only the server context and admins can touch it. The generator
8576
- * injects that safe baseline:
8577
- *
8578
- * **For every collection**
8579
- * 1. A permissive **server-or-admin SELECT** grant.
8580
- * 2. A permissive **server-or-admin write** grant (insert/update/delete).
8581
- *
8582
- * Author `securityRules` are permissive and OR together, so explicit rules only
8583
- * *broaden* access from this locked baseline (e.g. "users read/write their own
8584
- * rows").
8585
- *
8586
- * **For auth collections additionally**
8587
- * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
8588
- * their own row (profile, session bootstrap) without every app re-declaring
8589
- * it.
8590
- * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
8591
- * every other policy, so a write is rejected unless the caller is an admin
8592
- * (or the server context) — even if the author also wrote a permissive rule
8593
- * such as "a user may edit their own row". Without this, a permissive owner
8594
- * rule would let a user change their own `roles`.
8595
- *
8596
- * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
8597
- * — the built-in flows that run without a user (signup, migrations) set no user
8598
- * GUC — which also lets the owner connection satisfy these policies even under
8599
- * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
8600
- * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
8601
- *
8602
- * Opt out with `disableDefaultPolicies: true` to take full responsibility for
8603
- * the collection's RLS.
8604
- */
8605
- var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
8606
- /** Write operations that must be admin-gated by default on auth collections. */
8607
- var DEFAULT_GUARDED_OPS = [
8608
- "insert",
8609
- "update",
8610
- "delete"
8611
- ];
8612
- /** Whether a collection is flagged as an authentication collection. */
8613
- function isAuthCollection(collection) {
8614
- const auth = collection.auth;
8615
- return auth === true || typeof auth === "object" && auth?.enabled === true;
8616
- }
8617
- /** The property marked as the row id (falls back to `id`). */
8618
- function getIdPropertyName(collection) {
8619
- for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
8620
- return "id";
8621
- }
8622
- /**
8623
- * Returns the security rules that should be applied to a collection: the
8624
- * author's explicit `securityRules` plus the framework defaults described in
8625
- * the module doc (baseline server/admin read for all collections; self-read
8626
- * and the admin write gate for auth collections).
8627
- *
8628
- * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
8629
- */
8630
- function getEffectiveSecurityRules(collection) {
8631
- const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
8632
- if (collection.disableDefaultPolicies) return explicit;
8633
- const tableName = getTableName$1(collection);
8634
- const injected = [];
8635
- injected.push({
8636
- name: `${tableName}_default_admin_read`,
8637
- operations: ["select"],
8638
- condition: SERVER_OR_ADMIN_EXPR
8639
- });
8640
- injected.push({
8641
- name: `${tableName}_default_admin_write`,
8642
- operations: [...DEFAULT_GUARDED_OPS],
8643
- condition: SERVER_OR_ADMIN_EXPR,
8644
- check: SERVER_OR_ADMIN_EXPR
8645
- });
8646
- if (isAuthCollection(collection)) {
8647
- injected.push({
8648
- name: `${tableName}_default_self_read`,
8649
- operations: ["select"],
8650
- condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
8651
- });
8652
- injected.push({
8653
- name: `${tableName}_require_admin_write`,
8654
- mode: "restrictive",
8655
- operations: [...DEFAULT_GUARDED_OPS],
8656
- condition: SERVER_OR_ADMIN_EXPR,
8657
- check: SERVER_OR_ADMIN_EXPR
8658
- });
8659
- }
8660
- return [...explicit, ...injected];
8661
- }
9464
+ fields: [refreshTokens.uid],
9465
+ references: [users.id]
9466
+ }) }));
9467
+ var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
9468
+ fields: [passwordResetTokens.uid],
9469
+ references: [users.id]
9470
+ }) }));
9471
+ var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
9472
+ fields: [userIdentities.uid],
9473
+ references: [users.id]
9474
+ }) }));
9475
+ var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
9476
+ user: one(users, {
9477
+ fields: [mfaFactors.uid],
9478
+ references: [users.id]
9479
+ }),
9480
+ challenges: many(mfaChallenges)
9481
+ }));
9482
+ var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: one(mfaFactors, {
9483
+ fields: [mfaChallenges.factorId],
9484
+ references: [mfaFactors.id]
9485
+ }) }));
9486
+ var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
9487
+ fields: [recoveryCodes.uid],
9488
+ references: [users.id]
9489
+ }) }));
9490
+ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
9491
+ fields: [magicLinkTokens.uid],
9492
+ references: [users.id]
9493
+ }) }));
8662
9494
  //#endregion
8663
9495
  //#region src/schema/generate-drizzle-schema-logic.ts
8664
9496
  /**
@@ -8857,31 +9689,12 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
8857
9689
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
8858
9690
  */
8859
9691
  var wrapSql = (clause) => `sql\`${clause}\``;
8860
- /**
8861
- * Generates a deterministic hash based on the rule configuration.
8862
- */
8863
- var getPolicyNameHash = (rule) => {
8864
- const data = JSON.stringify({
8865
- a: rule.access,
8866
- m: rule.mode,
8867
- op: rule.operation,
8868
- ops: rule.operations?.slice().sort(),
8869
- own: rule.ownerField,
8870
- rol: rule.roles?.slice().sort(),
8871
- pg: rule.pgRoles?.slice().sort(),
8872
- u: rule.using,
8873
- w: rule.withCheck,
8874
- c: rule.condition,
8875
- ch: rule.check
8876
- });
8877
- return createHash("sha1").update(data).digest("hex").substring(0, 7);
8878
- };
8879
9692
  var generatePolicyCode = (collection, rule, index, resolveCollection) => {
8880
9693
  const tableName = getTableName$1(collection);
8881
9694
  const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
8882
- const ruleHash = getPolicyNameHash(rule);
9695
+ const policyNames = getPolicyNamesForRule(rule, tableName);
8883
9696
  return ops.map((op, opIdx) => {
8884
- return generateSinglePolicyCode(collection, rule, op, rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`, resolveCollection);
9697
+ return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
8885
9698
  }).join("");
8886
9699
  };
8887
9700
  /**
@@ -9010,6 +9823,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
9010
9823
  });
9011
9824
  });
9012
9825
  schemaContent += "\n";
9826
+ const junctionSpecs = resolveJunctionSpecs(collections);
9013
9827
  for (const collection of collections) {
9014
9828
  const tableName = getTableName$1(collection);
9015
9829
  if (tableName) allTablesToGenerate.set(tableName, { collection });
@@ -9043,9 +9857,17 @@ var generateSchema = async (collections, stripPolicies = false) => {
9043
9857
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
9044
9858
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
9045
9859
  schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
9046
- schemaContent += "}, (table) => ({\n";
9047
- schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
9048
- schemaContent += "}));\n\n";
9860
+ schemaContent += "}, (table) => ([\n";
9861
+ schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
9862
+ const junctionSpec = junctionSpecs.get(baseTableName);
9863
+ if (!stripPolicies && junctionSpec) {
9864
+ const junctionCollection = getJunctionCollectionConfig(junctionSpec);
9865
+ const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
9866
+ getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
9867
+ schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
9868
+ });
9869
+ }
9870
+ schemaContent += "])).enableRLS();\n\n";
9049
9871
  } else if (!isJunction) {
9050
9872
  const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
9051
9873
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -9499,6 +10321,267 @@ var CdcListener = class CdcListener {
9499
10321
  }
9500
10322
  };
9501
10323
  //#endregion
10324
+ //#region src/services/channel-history.ts
10325
+ /**
10326
+ * Ordered, replayable per-channel message history.
10327
+ *
10328
+ * Broadcast on its own is fire-and-forget to whoever is connected at the
10329
+ * instant it is sent: fine for presence and for "someone saved" notifications,
10330
+ * not enough for op-based collaborative editing, where a client that blinks
10331
+ * out for two seconds has to resync a whole document rather than catch up on
10332
+ * the four operations it missed. This adds the missing half — every retained
10333
+ * broadcast gets a per-channel sequence number, and a client can ask for
10334
+ * everything after the last one it saw.
10335
+ *
10336
+ * Three decisions worth stating, because each rules out a simpler-looking one:
10337
+ *
10338
+ * - **Retention is server-side and opt-in.** A channel is created by whoever
10339
+ * names it, so a client-supplied history depth would let any visitor commit
10340
+ * the backend to unbounded storage. And presence channels — the common case
10341
+ * — must not pay for this: with no rules configured nothing is written, no
10342
+ * table is created, and `broadcast` runs exactly the code it ran before.
10343
+ *
10344
+ * - **Sequence numbers come from the database, not from a counter in this
10345
+ * process.** They have to survive a restart and be shared across instances;
10346
+ * an in-memory counter would restart at 1 after a deploy and hand a
10347
+ * reconnecting client a replay from the wrong era, silently.
10348
+ *
10349
+ * - **The cursor row outlives the messages it numbered.** Pruning is what
10350
+ * makes retention affordable, but pruning the cursor along with the messages
10351
+ * would restart the sequence and make `sinceSeq` mean something different
10352
+ * before and after — the worst kind of bug, because replay would still
10353
+ * return rows and they would look plausible. Cursors are tiny and are kept
10354
+ * forever; see {@link prune}, which touches only `channel_messages`.
10355
+ */
10356
+ /** How many messages a replay returns when the caller does not say. */
10357
+ var DEFAULT_REPLAY_LIMIT = 200;
10358
+ /**
10359
+ * Hard ceiling on one replay, whatever the caller asks for.
10360
+ *
10361
+ * A reconnecting client names its own `limit`, so this is the only thing
10362
+ * standing between a stale `sinceSeq` and a single frame carrying a channel's
10363
+ * entire retained history. A client that is further behind than this is told so
10364
+ * via `latestSeq` and can decide to resync wholesale instead of paging.
10365
+ */
10366
+ var MAX_REPLAY_LIMIT = 1e3;
10367
+ /** Minimum gap between two prunes of the same channel. */
10368
+ var PRUNE_THROTTLE_MS = 3e4;
10369
+ /**
10370
+ * Parse a retention TTL into milliseconds.
10371
+ *
10372
+ * Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`,
10373
+ * `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the
10374
+ * caller treats as "no TTL" — a misspelt duration must not silently become an
10375
+ * aggressive one.
10376
+ */
10377
+ function parseTtlMs(ttl) {
10378
+ if (ttl === void 0 || ttl === null) return void 0;
10379
+ if (typeof ttl === "number") return Number.isFinite(ttl) && ttl > 0 ? ttl : void 0;
10380
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/i.exec(ttl);
10381
+ if (!match) {
10382
+ logger.warn(`⚠️ [ChannelHistory] Ignoring unparseable retention ttl "${ttl}" — expected e.g. "30s", "15m", "24h", "7d".`);
10383
+ return;
10384
+ }
10385
+ const value = parseFloat(match[1]);
10386
+ const unit = match[2].toLowerCase();
10387
+ const ms = value * (unit === "ms" ? 1 : unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5);
10388
+ return ms > 0 ? ms : void 0;
10389
+ }
10390
+ /**
10391
+ * Whether `channel` is covered by `rule`.
10392
+ *
10393
+ * Exact match, or a trailing `*` acting as a prefix. Not a general glob: this
10394
+ * decides what reaches disk, and a pattern language whose reach is not obvious
10395
+ * at a glance is the wrong tool for that job.
10396
+ */
10397
+ function channelMatchesRule(channel, rule) {
10398
+ const pattern = rule.match;
10399
+ if (pattern === "*") return true;
10400
+ if (pattern.endsWith("*")) return channel.startsWith(pattern.slice(0, -1));
10401
+ return channel === pattern;
10402
+ }
10403
+ /**
10404
+ * Persistence and replay for retained channels.
10405
+ *
10406
+ * Inert unless constructed with at least one rule: {@link enabled} is false,
10407
+ * {@link ensureTables} does nothing, and {@link retentionFor} answers undefined
10408
+ * for every channel, so the realtime service never reaches the SQL below.
10409
+ */
10410
+ var ChannelHistoryStore = class {
10411
+ db;
10412
+ rules;
10413
+ /** Resolved rule per channel name, so the match runs once per channel. */
10414
+ resolved = /* @__PURE__ */ new Map();
10415
+ /** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */
10416
+ lastPruned = /* @__PURE__ */ new Map();
10417
+ tablesReady = false;
10418
+ constructor(db, rules = []) {
10419
+ this.db = db;
10420
+ this.rules = rules.filter((rule) => {
10421
+ if (!rule?.match) {
10422
+ logger.warn("⚠️ [ChannelHistory] Ignoring a retention rule with no `match`.");
10423
+ return false;
10424
+ }
10425
+ if (!(rule.limit !== void 0 || rule.ttl !== void 0)) {
10426
+ logger.warn(`⚠️ [ChannelHistory] Retention rule "${rule.match}" sets neither \`limit\` nor \`ttl\` — ignoring it, as it would retain forever.`);
10427
+ return false;
10428
+ }
10429
+ return true;
10430
+ });
10431
+ }
10432
+ /** Whether any channel retains anything at all. */
10433
+ get enabled() {
10434
+ return this.rules.length > 0;
10435
+ }
10436
+ /**
10437
+ * The retention that applies to `channel`, or undefined when none does.
10438
+ *
10439
+ * First matching rule wins, so callers order them most-specific first.
10440
+ */
10441
+ retentionFor(channel) {
10442
+ if (!this.enabled || !channel) return void 0;
10443
+ const cached = this.resolved.get(channel);
10444
+ if (cached !== void 0) return cached ?? void 0;
10445
+ const rule = this.rules.find((r) => channelMatchesRule(channel, r));
10446
+ const resolved = rule ? {
10447
+ limit: rule.limit,
10448
+ ttlMs: parseTtlMs(rule.ttl)
10449
+ } : null;
10450
+ this.resolved.set(channel, resolved);
10451
+ return resolved ?? void 0;
10452
+ }
10453
+ /**
10454
+ * Create the history tables. Idempotent, and a no-op when no rule is set —
10455
+ * a deployment that never retains anything gets no schema for it.
10456
+ */
10457
+ async ensureTables() {
10458
+ if (!this.enabled || this.tablesReady) return;
10459
+ await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
10460
+ await this.db.execute(sql`
10461
+ CREATE TABLE IF NOT EXISTS rebase.channel_messages (
10462
+ channel TEXT NOT NULL,
10463
+ seq BIGINT NOT NULL,
10464
+ event TEXT NOT NULL,
10465
+ payload JSONB,
10466
+ sender_id TEXT,
10467
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10468
+ PRIMARY KEY (channel, seq)
10469
+ )
10470
+ `);
10471
+ await this.db.execute(sql`
10472
+ CREATE INDEX IF NOT EXISTS idx_channel_messages_created
10473
+ ON rebase.channel_messages (created_at)
10474
+ `);
10475
+ await this.db.execute(sql`
10476
+ CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
10477
+ channel TEXT PRIMARY KEY,
10478
+ last_seq BIGINT NOT NULL
10479
+ )
10480
+ `);
10481
+ this.tablesReady = true;
10482
+ logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
10483
+ }
10484
+ /**
10485
+ * Append a broadcast and return the sequence number it was given.
10486
+ *
10487
+ * The sequence is allocated by the same statement that stores the message,
10488
+ * so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE`
10489
+ * takes a row lock on the channel's cursor, which is what makes concurrent
10490
+ * broadcasts to one channel line up in a single order — and what keeps
10491
+ * different channels from contending with each other at all.
10492
+ */
10493
+ async append(channel, event, payload, senderId) {
10494
+ const row = (await this.db.execute(sql`
10495
+ WITH next AS (
10496
+ INSERT INTO rebase.channel_cursors (channel, last_seq)
10497
+ VALUES (${channel}, 1)
10498
+ ON CONFLICT (channel)
10499
+ DO UPDATE SET last_seq = rebase.channel_cursors.last_seq + 1
10500
+ RETURNING last_seq
10501
+ )
10502
+ INSERT INTO rebase.channel_messages (channel, seq, event, payload, sender_id)
10503
+ SELECT ${channel}, next.last_seq, ${event}, ${JSON.stringify(payload ?? null)}::jsonb, ${senderId ?? null}
10504
+ FROM next
10505
+ RETURNING seq, created_at
10506
+ `)).rows[0];
10507
+ if (!row) throw new Error(`Failed to append to channel history for "${channel}"`);
10508
+ return {
10509
+ seq: Number(row.seq),
10510
+ at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
10511
+ };
10512
+ }
10513
+ /**
10514
+ * Everything retained for `channel` after `sinceSeq`, oldest first.
10515
+ *
10516
+ * `latestSeq` is reported whether or not the messages were capped, so a
10517
+ * client that is further behind than one page can tell.
10518
+ */
10519
+ async replay(channel, sinceSeq = 0, limit = DEFAULT_REPLAY_LIMIT) {
10520
+ const capped = Math.max(1, Math.min(Math.floor(limit) || DEFAULT_REPLAY_LIMIT, MAX_REPLAY_LIMIT));
10521
+ const after = Number.isFinite(sinceSeq) && sinceSeq > 0 ? Math.floor(sinceSeq) : 0;
10522
+ const messages = (await this.db.execute(sql`
10523
+ SELECT seq, event, payload, sender_id, created_at
10524
+ FROM rebase.channel_messages
10525
+ WHERE channel = ${channel} AND seq > ${after}
10526
+ ORDER BY seq ASC
10527
+ LIMIT ${capped}
10528
+ `)).rows.map((row) => ({
10529
+ seq: Number(row.seq),
10530
+ event: row.event,
10531
+ payload: row.payload,
10532
+ senderId: row.sender_id ?? void 0,
10533
+ at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
10534
+ }));
10535
+ const cursorRow = (await this.db.execute(sql`
10536
+ SELECT last_seq FROM rebase.channel_cursors WHERE channel = ${channel}
10537
+ `)).rows[0];
10538
+ return {
10539
+ messages,
10540
+ latestSeq: cursorRow ? Number(cursorRow.last_seq) : 0
10541
+ };
10542
+ }
10543
+ /**
10544
+ * Enforce a channel's retention bounds.
10545
+ *
10546
+ * Throttled per channel, so a burst of operations prunes once rather than
10547
+ * once per message — the cost then tracks elapsed time instead of write
10548
+ * volume, which is what makes retention affordable on a hot channel.
10549
+ */
10550
+ async prune(channel, retention) {
10551
+ const now = Date.now();
10552
+ if (now - (this.lastPruned.get(channel) ?? 0) < PRUNE_THROTTLE_MS) return 0;
10553
+ this.lastPruned.set(channel, now);
10554
+ let deleted = 0;
10555
+ if (retention.ttlMs !== void 0) {
10556
+ const result = await this.db.execute(sql`
10557
+ DELETE FROM rebase.channel_messages
10558
+ WHERE channel = ${channel}
10559
+ AND created_at < NOW() - MAKE_INTERVAL(secs => ${retention.ttlMs / 1e3})
10560
+ `);
10561
+ deleted += result.rowCount ?? 0;
10562
+ }
10563
+ if (retention.limit !== void 0 && retention.limit > 0) {
10564
+ const result = await this.db.execute(sql`
10565
+ DELETE FROM rebase.channel_messages
10566
+ WHERE channel = ${channel}
10567
+ AND seq <= (
10568
+ SELECT seq FROM rebase.channel_messages
10569
+ WHERE channel = ${channel}
10570
+ ORDER BY seq DESC
10571
+ OFFSET ${Math.floor(retention.limit)} LIMIT 1
10572
+ )
10573
+ `);
10574
+ deleted += result.rowCount ?? 0;
10575
+ }
10576
+ return deleted;
10577
+ }
10578
+ /** Forget throttle and match caches. Called on shutdown. */
10579
+ clear() {
10580
+ this.resolved.clear();
10581
+ this.lastPruned.clear();
10582
+ }
10583
+ };
10584
+ //#endregion
9502
10585
  //#region src/services/realtimeService.ts
9503
10586
  /** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
9504
10587
  var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
@@ -9514,6 +10597,26 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9514
10597
  clients = /* @__PURE__ */ new Map();
9515
10598
  channels = /* @__PURE__ */ new Map();
9516
10599
  presence = /* @__PURE__ */ new Map();
10600
+ /**
10601
+ * Ordered, replayable history for channels that opt into it.
10602
+ *
10603
+ * Undefined until {@link configureChannelHistory} is called, and inert even
10604
+ * then unless retention rules were supplied — so presence and ephemeral
10605
+ * notification channels never touch it. See `channel-history.ts`.
10606
+ */
10607
+ channelHistory;
10608
+ /**
10609
+ * One promise chain per retained channel, so that assigning a sequence
10610
+ * number and fanning the message out happen in the same order for every
10611
+ * message on that channel.
10612
+ *
10613
+ * Without it, two concurrent broadcasts can be numbered 4 and 5 by the
10614
+ * database and still reach subscribers as 5 then 4 — live order and replay
10615
+ * order would disagree, which is exactly the divergence sequence numbers
10616
+ * are supposed to rule out. Keyed by channel, so unrelated channels never
10617
+ * wait on each other.
10618
+ */
10619
+ channelSendQueues = /* @__PURE__ */ new Map();
9517
10620
  presenceInterval;
9518
10621
  static PRESENCE_TIMEOUT_MS = 3e4;
9519
10622
  dataService;
@@ -9690,6 +10793,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9690
10793
  case "broadcast":
9691
10794
  this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
9692
10795
  break;
10796
+ case "channel_history":
10797
+ await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
10798
+ break;
9693
10799
  case "presence_track":
9694
10800
  this.joinChannel(clientId, payload?.channel);
9695
10801
  this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
@@ -9736,7 +10842,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9736
10842
  startAfter: request.startAfter,
9737
10843
  searchString: request.searchString
9738
10844
  }, authContext);
9739
- this.sendCollectionUpdate(clientId, subscriptionId, rows);
10845
+ this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
9740
10846
  } catch (error) {
9741
10847
  const sanitized = sanitizeErrorForClient(error, request.path);
9742
10848
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -9837,7 +10943,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9837
10943
  if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
9838
10944
  else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
9839
10945
  else if (subscription.type === "collection" && subscription.collectionRequest) {
9840
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
10946
+ if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
9841
10947
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
9842
10948
  }
9843
10949
  } catch (error) {
@@ -9867,7 +10973,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9867
10973
  if (!this._subscriptions.has(subscriptionId)) return;
9868
10974
  try {
9869
10975
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
9870
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
10976
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
9871
10977
  } catch (error) {
9872
10978
  const sanitized = sanitizeErrorForClient(error, notifyPath);
9873
10979
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -9900,12 +11006,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9900
11006
  if (this.driver) {
9901
11007
  const collection = this.registry.getCollectionByPath(notifyPath);
9902
11008
  const activeAuth = authContext || {
9903
- userId: "anon",
11009
+ uid: "anon",
9904
11010
  roles: ["anon"]
9905
11011
  };
9906
11012
  return await this.db.transaction(async (tx) => {
9907
11013
  await applyAuthContext(tx, {
9908
- userId: activeAuth.userId,
11014
+ uid: activeAuth.uid,
9909
11015
  roles: activeAuth.roles
9910
11016
  }, this.rlsUserRole);
9911
11017
  const txEntityService = new DataService(tx, this.registry);
@@ -9937,7 +11043,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9937
11043
  if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
9938
11044
  const contextForCallback = {
9939
11045
  user: {
9940
- uid: activeAuth.userId,
11046
+ uid: activeAuth.uid,
9941
11047
  roles: activeAuth.roles
9942
11048
  },
9943
11049
  driver: this.driver,
@@ -10029,12 +11135,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10029
11135
  if (this.driver) {
10030
11136
  const collection = this.registry.getCollectionByPath(notifyPath);
10031
11137
  const activeAuth = authContext || {
10032
- userId: "anon",
11138
+ uid: "anon",
10033
11139
  roles: ["anon"]
10034
11140
  };
10035
11141
  return await this.db.transaction(async (tx) => {
10036
11142
  await applyAuthContext(tx, {
10037
- userId: activeAuth.userId,
11143
+ uid: activeAuth.uid,
10038
11144
  roles: activeAuth.roles
10039
11145
  }, this.rlsUserRole);
10040
11146
  let processedEntity = await new DataService(tx, this.registry).fetchOne(notifyPath, id, collection?.databaseId);
@@ -10050,7 +11156,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10050
11156
  if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
10051
11157
  const contextForCallback = {
10052
11158
  user: {
10053
- uid: activeAuth.userId,
11159
+ uid: activeAuth.uid,
10054
11160
  roles: activeAuth.roles
10055
11161
  },
10056
11162
  driver: this.driver,
@@ -10081,11 +11187,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10081
11187
  }
10082
11188
  return await this.dataService.fetchOne(notifyPath, id);
10083
11189
  }
10084
- sendCollectionUpdate(clientId, subscriptionId, rows) {
11190
+ sendCollectionUpdate(clientId, subscriptionId, rows, path) {
10085
11191
  const message = {
10086
11192
  type: "collection_update",
10087
11193
  subscriptionId,
10088
- rows
11194
+ rows,
11195
+ pks: this.primaryKeysForPath(path)
10089
11196
  };
10090
11197
  this.sendMessage(clientId, message);
10091
11198
  }
@@ -10100,16 +11207,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10100
11207
  /**
10101
11208
  * Send a lightweight row-level patch to a collection subscriber.
10102
11209
  * The client can merge this into its cached data for instant feedback.
11210
+ *
11211
+ * The key columns ride along: the patch names a row by address, and the
11212
+ * client has to find that row among the ones it cached — which carry
11213
+ * columns and no address. The SDK holds no collection config to derive one
11214
+ * from, so this is the only place the mapping can come from.
10103
11215
  */
10104
- sendCollectionPatch(clientId, subscriptionId, id, row) {
11216
+ sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
10105
11217
  const message = {
10106
11218
  type: "collection_patch",
10107
11219
  subscriptionId,
10108
11220
  id,
10109
- row
11221
+ row,
11222
+ pks: this.primaryKeysForPath(notifyPath)
10110
11223
  };
10111
11224
  this.sendMessage(clientId, message);
10112
11225
  }
11226
+ /** The key columns of the collection at `path`, if they can be resolved. */
11227
+ primaryKeysForPath(path) {
11228
+ try {
11229
+ const collection = this.registry.getCollectionByPath(path);
11230
+ if (!collection) return void 0;
11231
+ const keys = getPrimaryKeys(collection, this.registry);
11232
+ return keys.length > 0 ? keys : void 0;
11233
+ } catch {
11234
+ return;
11235
+ }
11236
+ }
10113
11237
  sendError(clientId, error, subscriptionId, code) {
10114
11238
  const message = {
10115
11239
  type: "error",
@@ -10158,15 +11282,67 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10158
11282
  }
10159
11283
  this.removePresence(clientId, channel);
10160
11284
  }
10161
- /** Broadcast a message to all clients in a channel except sender */
11285
+ /**
11286
+ * Broadcast a message to all clients in a channel except the sender.
11287
+ *
11288
+ * On a channel with no retention rule this is what it always was: a
11289
+ * synchronous fan-out to whoever is connected, with no sequence number, no
11290
+ * SQL and no await — the body below runs to completion before returning.
11291
+ *
11292
+ * On a retained channel the message is durably numbered first and only then
11293
+ * delivered, through a per-channel queue so that delivery order matches
11294
+ * sequence order. That ordering is the whole point: a client that catches up
11295
+ * with `sinceSeq` has to arrive at the same state as one that never
11296
+ * disconnected.
11297
+ */
10162
11298
  broadcastToChannel(clientId, channel, event, payload) {
11299
+ const retention = this.channelHistory?.retentionFor(channel);
11300
+ if (!retention) {
11301
+ this.fanOutBroadcast(clientId, channel, event, payload);
11302
+ return;
11303
+ }
11304
+ const next = (this.channelSendQueues.get(channel) ?? Promise.resolve()).catch(() => {}).then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
11305
+ this.channelSendQueues.set(channel, next);
11306
+ next.finally(() => {
11307
+ if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
11308
+ });
11309
+ }
11310
+ /**
11311
+ * Number a broadcast, store it, then deliver it.
11312
+ *
11313
+ * A message that cannot be stored is **not** delivered. Delivering it would
11314
+ * put it in front of live subscribers while leaving it absent from every
11315
+ * future replay — the two views of the channel would disagree permanently,
11316
+ * and no later message could repair the gap. Failing loudly to the sender
11317
+ * instead lets it retry, which for an operation stream is the only outcome
11318
+ * that keeps clients convergent.
11319
+ */
11320
+ async persistAndFanOut(clientId, channel, event, payload, retention) {
11321
+ let seq;
11322
+ try {
11323
+ ({seq} = await this.channelHistory.append(channel, event, payload, clientId));
11324
+ } catch (error) {
11325
+ logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
11326
+ this.sendError(clientId, `Could not persist broadcast on retained channel "${channel}"`, void 0, "CHANNEL_HISTORY_WRITE_FAILED");
11327
+ return;
11328
+ }
11329
+ this.fanOutBroadcast(clientId, channel, event, payload, seq);
11330
+ try {
11331
+ await this.channelHistory.prune(channel, retention);
11332
+ } catch (error) {
11333
+ logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
11334
+ }
11335
+ }
11336
+ /** Deliver a broadcast frame to every member of a channel but the sender. */
11337
+ fanOutBroadcast(clientId, channel, event, payload, seq) {
10163
11338
  const members = this.channels.get(channel);
10164
11339
  if (!members) return;
10165
11340
  const message = JSON.stringify({
10166
11341
  type: "broadcast",
10167
11342
  channel,
10168
11343
  event,
10169
- payload
11344
+ payload,
11345
+ ...seq !== void 0 ? { seq } : {}
10170
11346
  });
10171
11347
  for (const memberId of members) {
10172
11348
  if (memberId === clientId) continue;
@@ -10174,6 +11350,55 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10174
11350
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(message);
10175
11351
  }
10176
11352
  }
11353
+ /**
11354
+ * Install retention rules and create the tables they need.
11355
+ *
11356
+ * Safe to call with no rules (and safe not to call at all): the store stays
11357
+ * inert, no schema is created, and broadcast keeps its original
11358
+ * fire-and-forget path.
11359
+ */
11360
+ async configureChannelHistory(rules) {
11361
+ this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
11362
+ if (!this.channelHistory.enabled) return;
11363
+ await this.channelHistory.ensureTables();
11364
+ }
11365
+ /** Whether any channel is configured to retain messages. */
11366
+ isChannelHistoryEnabled() {
11367
+ return this.channelHistory?.enabled ?? false;
11368
+ }
11369
+ /**
11370
+ * Answer a client's catch-up request.
11371
+ *
11372
+ * A channel with no retention rule is answered with `retained: false`
11373
+ * rather than an empty list, so the client can tell "you missed nothing"
11374
+ * apart from "this channel never keeps anything" — the second means its
11375
+ * reconnect strategy has to be a full resync, and silence would leave it
11376
+ * guessing.
11377
+ */
11378
+ async handleChannelHistoryRequest(clientId, channel, sinceSeq, limit) {
11379
+ if (!channel) return;
11380
+ if (!this.channelHistory?.retentionFor(channel)) {
11381
+ this.sendChannelHistory(clientId, channel, [], false);
11382
+ return;
11383
+ }
11384
+ try {
11385
+ const { messages, latestSeq } = await this.channelHistory.replay(channel, sinceSeq, limit);
11386
+ this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
11387
+ } catch (error) {
11388
+ logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
11389
+ this.sendError(clientId, `Could not replay history for channel "${channel}"`, void 0, "CHANNEL_HISTORY_READ_FAILED");
11390
+ }
11391
+ }
11392
+ sendChannelHistory(clientId, channel, messages, retained, latestSeq) {
11393
+ const ws = this.clients.get(clientId);
11394
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
11395
+ type: "channel_history",
11396
+ channel,
11397
+ messages,
11398
+ retained,
11399
+ ...latestSeq !== void 0 ? { latestSeq } : {}
11400
+ }));
11401
+ }
10177
11402
  /** Track presence in a channel */
10178
11403
  trackPresence(clientId, channel, state) {
10179
11404
  if (!this.presence.has(channel)) this.presence.set(channel, /* @__PURE__ */ new Map());
@@ -10254,6 +11479,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10254
11479
  this.subscriptionCallbacks.clear();
10255
11480
  this.channels.clear();
10256
11481
  this.presence.clear();
11482
+ await Promise.allSettled([...this.channelSendQueues.values()]);
11483
+ this.channelSendQueues.clear();
11484
+ this.channelHistory?.clear();
10257
11485
  if (this.presenceInterval) {
10258
11486
  clearInterval(this.presenceInterval);
10259
11487
  this.presenceInterval = void 0;
@@ -10357,12 +11585,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10357
11585
  }
10358
11586
  /** Compute the canonical (possibly composite) id string from a captured row. */
10359
11587
  extractIdFromCdcRow(collection, row) {
10360
- try {
10361
- const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
10362
- if (composite && composite !== ":::") return composite;
10363
- } catch {}
10364
- if (row.id !== void 0 && row.id !== null) return String(row.id);
10365
- return "*";
11588
+ return deriveRowAddress(row, collection, this.registry) || "*";
10366
11589
  }
10367
11590
  dedupKey(path, id, databaseId) {
10368
11591
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -10600,20 +11823,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10600
11823
  if (authAdapter) try {
10601
11824
  const adapterUser = authAdapter.verifyToken ? await authAdapter.verifyToken(token) : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", { headers: { Authorization: `Bearer ${token}` } }));
10602
11825
  if (adapterUser) verifiedUser = {
10603
- userId: adapterUser.uid,
11826
+ uid: adapterUser.uid,
10604
11827
  roles: adapterUser.roles,
10605
11828
  isAdmin: adapterUser.isAdmin
10606
11829
  };
10607
11830
  } catch {}
10608
11831
  else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
10609
- userId: "service",
11832
+ uid: "service",
10610
11833
  roles: ["admin"],
10611
11834
  isAdmin: true
10612
11835
  };
10613
11836
  else {
10614
11837
  const jwtPayload = extractUserFromToken(token);
10615
11838
  if (jwtPayload) verifiedUser = {
10616
- userId: jwtPayload.userId,
11839
+ uid: jwtPayload.uid,
10617
11840
  roles: jwtPayload.roles ?? [],
10618
11841
  isAdmin: (jwtPayload.roles ?? []).some((r) => r === "admin")
10619
11842
  };
@@ -10629,11 +11852,11 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10629
11852
  type: "AUTH_SUCCESS",
10630
11853
  requestId,
10631
11854
  payload: {
10632
- userId: verifiedUser.userId,
11855
+ uid: verifiedUser.uid,
10633
11856
  roles: verifiedUser.roles
10634
11857
  }
10635
11858
  }));
10636
- wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
11859
+ wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
10637
11860
  } else {
10638
11861
  wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
10639
11862
  sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
@@ -10671,7 +11894,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10671
11894
  const session = clientSessions.get(clientId);
10672
11895
  if (typeof driver.withAuth === "function") try {
10673
11896
  const userForAuth = session?.user ? {
10674
- uid: session.user.userId,
11897
+ uid: session.user.uid,
10675
11898
  displayName: null,
10676
11899
  email: null,
10677
11900
  photoURL: null,
@@ -10805,7 +12028,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10805
12028
  sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
10806
12029
  options,
10807
12030
  resultRows: Array.isArray(result) ? result.length : "unknown",
10808
- userId: auditSession?.user?.userId ?? "unknown",
12031
+ uid: auditSession?.user?.uid ?? "unknown",
10809
12032
  roles: auditSession?.user?.roles ?? [],
10810
12033
  isAdmin: auditSession?.user?.isAdmin ?? false
10811
12034
  }));
@@ -10850,6 +12073,21 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10850
12073
  ws.send(JSON.stringify(response));
10851
12074
  }
10852
12075
  break;
12076
+ case "FETCH_APPLICATION_ROLES":
12077
+ {
12078
+ wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
12079
+ const admin = (await getScopedDelegate()).admin;
12080
+ let roles = [];
12081
+ if (isSQLAdmin(admin) && admin.fetchApplicationRoles) roles = await admin.fetchApplicationRoles();
12082
+ wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
12083
+ const response = {
12084
+ type: "FETCH_APPLICATION_ROLES_SUCCESS",
12085
+ payload: { roles },
12086
+ requestId
12087
+ };
12088
+ ws.send(JSON.stringify(response));
12089
+ }
12090
+ break;
10853
12091
  case "FETCH_CURRENT_DATABASE":
10854
12092
  {
10855
12093
  wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
@@ -10956,14 +12194,15 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10956
12194
  case "broadcast":
10957
12195
  case "presence_track":
10958
12196
  case "presence_untrack":
10959
- case "presence_state": {
12197
+ case "presence_state":
12198
+ case "channel_history": {
10960
12199
  wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
10961
12200
  const session = clientSessions.get(clientId);
10962
12201
  const authContext = session?.user ? {
10963
- userId: session.user.userId,
12202
+ uid: session.user.uid,
10964
12203
  roles: session.user.roles ?? []
10965
12204
  } : {
10966
- userId: "anon",
12205
+ uid: "anon",
10967
12206
  roles: ["anon"]
10968
12207
  };
10969
12208
  await realtimeService.handleClientMessage(clientId, {
@@ -10978,7 +12217,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10978
12217
  } catch (error) {
10979
12218
  logger.error("💥 [WebSocket Server] Error handling message", { error });
10980
12219
  if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
10981
- const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error instanceof Error ? error.message : "An unexpected error occurred";
12220
+ const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
10982
12221
  const errorResponse = {
10983
12222
  type: "ERROR",
10984
12223
  requestId,
@@ -19822,6 +21061,33 @@ function formatBytes(bytes) {
19822
21061
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
19823
21062
  }
19824
21063
  //#endregion
21064
+ //#region src/collections/buildRegistry.ts
21065
+ /**
21066
+ * Build the collection registry for a driver.
21067
+ *
21068
+ * The order matters and is the reason this is one function rather than a run of
21069
+ * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
21070
+ * anything that inspects them has to run *after* the tables are registered —
21071
+ * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
21072
+ * collection whose table it cannot look up is one it has nothing to say about.
21073
+ * Warned too early, it would skip every collection and report nothing, which
21074
+ * reads exactly like having nothing to report.
21075
+ */
21076
+ function buildCollectionRegistry(schema) {
21077
+ const registry = new PostgresCollectionRegistry();
21078
+ if (schema.collections) {
21079
+ registry.registerMultiple(schema.collections);
21080
+ logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
21081
+ }
21082
+ if (schema.tables) Object.values(schema.tables).forEach((table) => {
21083
+ if (isTable(table)) registry.registerTable(table, getTableName(table));
21084
+ });
21085
+ if (schema.enums) registry.registerEnums(schema.enums);
21086
+ if (schema.relations) registry.registerRelations(schema.relations);
21087
+ warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
21088
+ return registry;
21089
+ }
21090
+ //#endregion
19825
21091
  //#region src/auth/ensure-tables.ts
19826
21092
  /**
19827
21093
  * Auto-create auth tables if they don't exist.
@@ -19892,9 +21158,65 @@ async function ensureAuthTablesExist(db, collection) {
19892
21158
  )
19893
21159
  `);
19894
21160
  await db.execute(sql`
21161
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
21162
+ BEGIN
21163
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
21164
+ NEW.uid := NEW.user_id;
21165
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
21166
+ NEW.user_id := NEW.uid;
21167
+ END IF;
21168
+ RETURN NEW;
21169
+ END $$ LANGUAGE plpgsql
21170
+ `);
21171
+ for (const authTable of [
21172
+ "user_identities",
21173
+ "refresh_tokens",
21174
+ "password_reset_tokens",
21175
+ "magic_link_tokens",
21176
+ "mfa_factors",
21177
+ "recovery_codes"
21178
+ ]) {
21179
+ const qualified = `"${authSchema}"."${authTable}"`;
21180
+ await db.execute(sql`
21181
+ DO $$
21182
+ DECLARE
21183
+ has_legacy boolean;
21184
+ has_uid boolean;
21185
+ BEGIN
21186
+ SELECT
21187
+ bool_or(column_name = 'user_id'),
21188
+ bool_or(column_name = 'uid')
21189
+ INTO has_legacy, has_uid
21190
+ FROM information_schema.columns
21191
+ WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
21192
+ AND table_name = ${sql.raw(`'${authTable}'`)};
21193
+
21194
+ -- Table absent, or already uid-only (a fresh install, or
21195
+ -- phase 2 already run): nothing to do.
21196
+ IF has_legacy IS NOT TRUE THEN
21197
+ RETURN;
21198
+ END IF;
21199
+
21200
+ IF has_uid IS NOT TRUE THEN
21201
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
21202
+ EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
21203
+ EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
21204
+ END IF;
21205
+
21206
+ -- New code inserts uid and never user_id, so the legacy
21207
+ -- column can no longer be NOT NULL. The trigger below
21208
+ -- backfills it, but the constraint is checked first.
21209
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
21210
+
21211
+ EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
21212
+ EXECUTE ${sql.raw(`'CREATE TRIGGER sync_uid_user_id BEFORE INSERT OR UPDATE ON ${qualified} FOR EACH ROW EXECUTE FUNCTION "${authSchema}".sync_uid_user_id()'`)};
21213
+ END $$
21214
+ `);
21215
+ }
21216
+ await db.execute(sql`
19895
21217
  CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
19896
21218
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
19897
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21219
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
19898
21220
  provider TEXT NOT NULL,
19899
21221
  provider_id TEXT NOT NULL,
19900
21222
  profile_data JSONB,
@@ -19905,18 +21227,18 @@ async function ensureAuthTablesExist(db, collection) {
19905
21227
  `);
19906
21228
  await db.execute(sql`
19907
21229
  CREATE INDEX IF NOT EXISTS idx_user_identities_user
19908
- ON ${sql.raw(userIdentitiesTable)}(user_id)
21230
+ ON ${sql.raw(userIdentitiesTable)}(uid)
19909
21231
  `);
19910
21232
  await db.execute(sql`
19911
21233
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
19912
21234
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
19913
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21235
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
19914
21236
  token_hash TEXT NOT NULL UNIQUE,
19915
21237
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
19916
21238
  user_agent TEXT,
19917
21239
  ip_address TEXT,
19918
21240
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
19919
- CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
21241
+ CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
19920
21242
  )
19921
21243
  `);
19922
21244
  await db.execute(sql`
@@ -19925,12 +21247,12 @@ async function ensureAuthTablesExist(db, collection) {
19925
21247
  `);
19926
21248
  await db.execute(sql`
19927
21249
  CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
19928
- ON ${sql.raw(refreshTokensTableName)}(user_id)
21250
+ ON ${sql.raw(refreshTokensTableName)}(uid)
19929
21251
  `);
19930
21252
  await db.execute(sql`
19931
21253
  CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
19932
21254
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
19933
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21255
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
19934
21256
  token_hash TEXT NOT NULL UNIQUE,
19935
21257
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
19936
21258
  used_at TIMESTAMP WITH TIME ZONE,
@@ -19943,13 +21265,13 @@ async function ensureAuthTablesExist(db, collection) {
19943
21265
  `);
19944
21266
  await db.execute(sql`
19945
21267
  CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
19946
- ON ${sql.raw(passwordResetTokensTableName)}(user_id)
21268
+ ON ${sql.raw(passwordResetTokensTableName)}(uid)
19947
21269
  `);
19948
21270
  const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
19949
21271
  await db.execute(sql`
19950
21272
  CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
19951
21273
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
19952
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21274
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
19953
21275
  token_hash TEXT NOT NULL UNIQUE,
19954
21276
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
19955
21277
  used_at TIMESTAMP WITH TIME ZONE,
@@ -19962,7 +21284,7 @@ async function ensureAuthTablesExist(db, collection) {
19962
21284
  `);
19963
21285
  await db.execute(sql`
19964
21286
  CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
19965
- ON ${sql.raw(magicLinkTokensTableName)}(user_id)
21287
+ ON ${sql.raw(magicLinkTokensTableName)}(uid)
19966
21288
  `);
19967
21289
  await db.execute(sql`
19968
21290
  CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
@@ -19976,7 +21298,10 @@ async function ensureAuthTablesExist(db, collection) {
19976
21298
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
19977
21299
  await tx.execute(sql`
19978
21300
  CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
19979
- SELECT NULLIF(current_setting('app.user_id', true), '');
21301
+ SELECT COALESCE(
21302
+ NULLIF(current_setting('app.uid', true), ''),
21303
+ NULLIF(current_setting('app.user_id', true), '')
21304
+ );
19980
21305
  $$ LANGUAGE sql STABLE
19981
21306
  `);
19982
21307
  await tx.execute(sql`
@@ -19993,14 +21318,22 @@ async function ensureAuthTablesExist(db, collection) {
19993
21318
  $$ LANGUAGE sql STABLE
19994
21319
  `);
19995
21320
  });
19996
- await db.execute(sql`
19997
- ALTER TABLE ${sql.raw(usersTableName)}
19998
- ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
19999
- `);
20000
- await db.execute(sql`
20001
- ALTER TABLE ${sql.raw(usersTableName)}
20002
- ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
20003
- `);
21321
+ for (const columnDef of [
21322
+ "display_name VARCHAR(255)",
21323
+ "photo_url VARCHAR(500)",
21324
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
21325
+ "password_hash VARCHAR(255)",
21326
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
21327
+ "email_verification_token VARCHAR(255)",
21328
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
21329
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
21330
+ "metadata JSONB DEFAULT '{}' NOT NULL",
21331
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
21332
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
21333
+ ]) await db.execute(sql`
21334
+ ALTER TABLE ${sql.raw(usersTableName)}
21335
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
21336
+ `);
20004
21337
  try {
20005
21338
  if ((await db.execute(sql`
20006
21339
  SELECT EXISTS (
@@ -20031,7 +21364,7 @@ async function ensureAuthTablesExist(db, collection) {
20031
21364
  await db.execute(sql`
20032
21365
  CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
20033
21366
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20034
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21367
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
20035
21368
  factor_type TEXT NOT NULL DEFAULT 'totp',
20036
21369
  secret_encrypted TEXT NOT NULL,
20037
21370
  friendly_name TEXT,
@@ -20042,7 +21375,7 @@ async function ensureAuthTablesExist(db, collection) {
20042
21375
  `);
20043
21376
  await db.execute(sql`
20044
21377
  CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
20045
- ON ${sql.raw(mfaFactorsTableName)}(user_id)
21378
+ ON ${sql.raw(mfaFactorsTableName)}(uid)
20046
21379
  `);
20047
21380
  await db.execute(sql`
20048
21381
  CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (
@@ -20061,7 +21394,7 @@ async function ensureAuthTablesExist(db, collection) {
20061
21394
  await db.execute(sql`
20062
21395
  CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
20063
21396
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20064
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21397
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
20065
21398
  code_hash TEXT NOT NULL,
20066
21399
  used_at TIMESTAMP WITH TIME ZONE,
20067
21400
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
@@ -20069,8 +21402,36 @@ async function ensureAuthTablesExist(db, collection) {
20069
21402
  `);
20070
21403
  await db.execute(sql`
20071
21404
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20072
- ON ${sql.raw(recoveryCodesTableName)}(user_id)
21405
+ ON ${sql.raw(recoveryCodesTableName)}(uid)
20073
21406
  `);
21407
+ try {
21408
+ const authTablePairs = [
21409
+ [usersSchema, resolvedTable],
21410
+ [authSchema, "user_identities"],
21411
+ [authSchema, "refresh_tokens"],
21412
+ [authSchema, "password_reset_tokens"],
21413
+ [authSchema, "app_config"],
21414
+ [authSchema, "mfa_factors"],
21415
+ [authSchema, "mfa_challenges"],
21416
+ [authSchema, "recovery_codes"]
21417
+ ];
21418
+ for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
21419
+ SELECT 1
21420
+ FROM pg_class c
21421
+ JOIN pg_namespace n ON n.oid = c.relnamespace
21422
+ WHERE n.nspname = ${schemaName}
21423
+ AND c.relname = ${tableName}
21424
+ AND c.relforcerowsecurity
21425
+ `)).rows.length > 0) {
21426
+ await db.execute(sql`
21427
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
21428
+ NO FORCE ROW LEVEL SECURITY
21429
+ `);
21430
+ logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
21431
+ }
21432
+ } catch (rlsReconcileError) {
21433
+ logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
21434
+ }
20074
21435
  logger.info("✅ Auth tables ready");
20075
21436
  } catch (error) {
20076
21437
  logger.error("❌ Failed to create auth tables", { error });
@@ -20118,6 +21479,32 @@ var UserService = class {
20118
21479
  const name = getTableName(this.usersTable);
20119
21480
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20120
21481
  }
21482
+ /**
21483
+ * Run a privileged auth write with an explicitly cleared RLS context.
21484
+ *
21485
+ * The auth services run on the base/owner connection, which by design
21486
+ * carries a NULL `app.uid` so the `auth.uid() IS NULL` server-escape
21487
+ * in the default policies applies. That NULL is normally guaranteed by
21488
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
21489
+ * GUC that survives on a pooled connection (or a connection role that
21490
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
21491
+ * turns the trusted write into an RLS-scoped one and denies it with
21492
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
21493
+ * single chokepoint, makes the server context deterministic instead of
21494
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
21495
+ * NULL via NULLIF, so '' is the server context.
21496
+ */
21497
+ async withServerContext(fn) {
21498
+ return await this.db.transaction(async (tx) => {
21499
+ await tx.execute(sql`
21500
+ SELECT set_config('app.uid', '', true),
21501
+ set_config('app.user_id', '', true),
21502
+ set_config('app.user_roles', '', true),
21503
+ set_config('app.jwt', '', true)
21504
+ `);
21505
+ return await fn(tx);
21506
+ });
21507
+ }
20121
21508
  mapRowToUser(row) {
20122
21509
  if (!row) return row;
20123
21510
  const id = row.id ?? row.uid;
@@ -20215,7 +21602,7 @@ var UserService = class {
20215
21602
  }
20216
21603
  async createUser(data) {
20217
21604
  const payload = this.mapPayload(data);
20218
- const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21605
+ const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20219
21606
  return this.mapRowToUser(row);
20220
21607
  }
20221
21608
  async getUserById(id) {
@@ -20233,19 +21620,19 @@ var UserService = class {
20233
21620
  async getUserByIdentity(provider, providerId) {
20234
21621
  const userIdCol = getColumn(this.usersTable, "id");
20235
21622
  if (!userIdCol) return null;
20236
- const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.userId)).where(sql`${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`).limit(1);
21623
+ const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.uid)).where(sql`${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`).limit(1);
20237
21624
  if (result.length === 0) return null;
20238
21625
  return this.mapRowToUser(result[0].user);
20239
21626
  }
20240
- async getUserIdentities(userId) {
21627
+ async getUserIdentities(uid) {
20241
21628
  const schema = getTableConfig(this.userIdentitiesTable).schema || "public";
20242
21629
  return (await this.db.execute(sql`
20243
- SELECT id, user_id, provider, provider_id, profile_data, created_at, updated_at
21630
+ SELECT id, uid, provider, provider_id, profile_data, created_at, updated_at
20244
21631
  FROM ${sql.raw(`"${schema}"."user_identities"`)}
20245
- WHERE user_id = ${userId}
21632
+ WHERE uid = ${uid}
20246
21633
  `)).rows.map((row) => ({
20247
21634
  id: row.id,
20248
- userId: row.user_id,
21635
+ uid: row.uid,
20249
21636
  provider: row.provider,
20250
21637
  providerId: row.provider_id,
20251
21638
  profileData: row.profile_data ?? null,
@@ -20253,13 +21640,13 @@ var UserService = class {
20253
21640
  updatedAt: row.updated_at
20254
21641
  }));
20255
21642
  }
20256
- async linkUserIdentity(userId, provider, providerId, profileData) {
20257
- await this.db.insert(this.userIdentitiesTable).values({
20258
- userId,
21643
+ async linkUserIdentity(uid, provider, providerId, profileData) {
21644
+ await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
21645
+ uid,
20259
21646
  provider,
20260
21647
  providerId,
20261
21648
  profileData: profileData || null
20262
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21649
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
20263
21650
  }
20264
21651
  async updateUser(id, data) {
20265
21652
  const idCol = getColumn(this.usersTable, "id");
@@ -20267,13 +21654,13 @@ var UserService = class {
20267
21654
  const payload = this.mapPayload(data);
20268
21655
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20269
21656
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
20270
- const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21657
+ const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
20271
21658
  return row ? this.mapRowToUser(row) : null;
20272
21659
  }
20273
21660
  async deleteUser(id) {
20274
21661
  const idCol = getColumn(this.usersTable, "id");
20275
21662
  if (!idCol) return;
20276
- await this.db.delete(this.usersTable).where(eq(idCol, id));
21663
+ await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
20277
21664
  }
20278
21665
  async listUsers() {
20279
21666
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -20327,10 +21714,10 @@ var UserService = class {
20327
21714
  if (!idCol) return;
20328
21715
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
20329
21716
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20330
- await this.db.update(this.usersTable).set({
21717
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20331
21718
  [passwordHashColKey]: passwordHash,
20332
21719
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20333
- }).where(eq(idCol, id));
21720
+ }).where(eq(idCol, id)));
20334
21721
  }
20335
21722
  /**
20336
21723
  * Set email verification status
@@ -20341,11 +21728,11 @@ var UserService = class {
20341
21728
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
20342
21729
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
20343
21730
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20344
- await this.db.update(this.usersTable).set({
21731
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20345
21732
  [emailVerifiedColKey]: verified,
20346
21733
  [emailVerificationTokenColKey]: null,
20347
21734
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20348
- }).where(eq(idCol, id));
21735
+ }).where(eq(idCol, id)));
20349
21736
  }
20350
21737
  /**
20351
21738
  * Set email verification token
@@ -20356,11 +21743,11 @@ var UserService = class {
20356
21743
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
20357
21744
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
20358
21745
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20359
- await this.db.update(this.usersTable).set({
21746
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20360
21747
  [emailVerificationTokenColKey]: token,
20361
21748
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
20362
21749
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20363
- }).where(eq(idCol, id));
21750
+ }).where(eq(idCol, id)));
20364
21751
  }
20365
21752
  /**
20366
21753
  * Find user by email verification token
@@ -20374,10 +21761,10 @@ var UserService = class {
20374
21761
  /**
20375
21762
  * Get roles for a user from database (inline TEXT[] column)
20376
21763
  */
20377
- async getUserRoles(userId) {
21764
+ async getUserRoles(uid) {
20378
21765
  const usersTableName = this.getQualifiedUsersTableName();
20379
21766
  const result = await this.db.execute(sql`
20380
- SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${userId}
21767
+ SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
20381
21768
  `);
20382
21769
  if (result.rows.length === 0) return [];
20383
21770
  return (result.rows[0].roles ?? []).map((id) => ({
@@ -20391,10 +21778,10 @@ var UserService = class {
20391
21778
  /**
20392
21779
  * Get role IDs for a user
20393
21780
  */
20394
- async getUserRoleIds(userId) {
21781
+ async getUserRoleIds(uid) {
20395
21782
  const usersTableName = this.getQualifiedUsersTableName();
20396
21783
  const result = await this.db.execute(sql`
20397
- SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${userId}
21784
+ SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
20398
21785
  `);
20399
21786
  if (result.rows.length === 0) return [];
20400
21787
  return result.rows[0].roles ?? [];
@@ -20402,35 +21789,35 @@ var UserService = class {
20402
21789
  /**
20403
21790
  * Set roles for a user (replaces existing roles)
20404
21791
  */
20405
- async setUserRoles(userId, roleIds) {
21792
+ async setUserRoles(uid, roleIds) {
20406
21793
  const usersTableName = this.getQualifiedUsersTableName();
20407
21794
  const rolesArray = `{${roleIds.join(",")}}`;
20408
- await this.db.execute(sql`
21795
+ await this.withServerContext(async (db) => db.execute(sql`
20409
21796
  UPDATE ${sql.raw(usersTableName)}
20410
21797
  SET roles = ${rolesArray}::text[], updated_at = NOW()
20411
- WHERE id = ${userId}
20412
- `);
21798
+ WHERE id = ${uid}
21799
+ `));
20413
21800
  }
20414
21801
  /**
20415
21802
  * Assign a specific role to new user (appends if not present)
20416
21803
  */
20417
- async assignDefaultRole(userId, roleId) {
21804
+ async assignDefaultRole(uid, roleId) {
20418
21805
  const usersTableName = this.getQualifiedUsersTableName();
20419
- await this.db.execute(sql`
21806
+ await this.withServerContext(async (db) => db.execute(sql`
20420
21807
  UPDATE ${sql.raw(usersTableName)}
20421
21808
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
20422
- WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
20423
- `);
21809
+ WHERE id = ${uid} AND NOT (${roleId} = ANY(roles))
21810
+ `));
20424
21811
  }
20425
21812
  /**
20426
21813
  * Get user with their roles
20427
21814
  */
20428
- async getUserWithRoles(userId) {
20429
- const user = await this.getUserById(userId);
21815
+ async getUserWithRoles(uid) {
21816
+ const user = await this.getUserById(uid);
20430
21817
  if (!user) return null;
20431
21818
  return {
20432
21819
  user,
20433
- roles: await this.getUserRoles(userId)
21820
+ roles: await this.getUserRoles(uid)
20434
21821
  };
20435
21822
  }
20436
21823
  };
@@ -20442,18 +21829,18 @@ var RefreshTokenService = class {
20442
21829
  if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users)) this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
20443
21830
  else this.refreshTokensTable = tableOrTables || refreshTokens;
20444
21831
  }
20445
- async createToken(userId, tokenHash, expiresAt, userAgent, ipAddress) {
21832
+ async createToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
20446
21833
  const safeUserAgent = userAgent || "";
20447
21834
  const safeIpAddress = ipAddress || "";
20448
21835
  await this.db.insert(this.refreshTokensTable).values({
20449
- userId,
21836
+ uid,
20450
21837
  tokenHash,
20451
21838
  expiresAt,
20452
21839
  userAgent: safeUserAgent,
20453
21840
  ipAddress: safeIpAddress
20454
21841
  }).onConflictDoUpdate({
20455
21842
  target: [
20456
- this.refreshTokensTable.userId,
21843
+ this.refreshTokensTable.uid,
20457
21844
  this.refreshTokensTable.userAgent,
20458
21845
  this.refreshTokensTable.ipAddress
20459
21846
  ],
@@ -20466,7 +21853,7 @@ var RefreshTokenService = class {
20466
21853
  async findByHash(tokenHash) {
20467
21854
  const [token] = await this.db.select({
20468
21855
  id: this.refreshTokensTable.id,
20469
- userId: this.refreshTokensTable.userId,
21856
+ uid: this.refreshTokensTable.uid,
20470
21857
  tokenHash: this.refreshTokensTable.tokenHash,
20471
21858
  expiresAt: this.refreshTokensTable.expiresAt,
20472
21859
  createdAt: this.refreshTokensTable.createdAt,
@@ -20478,22 +21865,22 @@ var RefreshTokenService = class {
20478
21865
  async deleteByHash(tokenHash) {
20479
21866
  await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
20480
21867
  }
20481
- async deleteAllForUser(userId) {
20482
- await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.userId, userId));
21868
+ async deleteAllForUser(uid) {
21869
+ await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
20483
21870
  }
20484
- async listForUser(userId) {
21871
+ async listForUser(uid) {
20485
21872
  return await this.db.select({
20486
21873
  id: this.refreshTokensTable.id,
20487
- userId: this.refreshTokensTable.userId,
21874
+ uid: this.refreshTokensTable.uid,
20488
21875
  tokenHash: this.refreshTokensTable.tokenHash,
20489
21876
  expiresAt: this.refreshTokensTable.expiresAt,
20490
21877
  createdAt: this.refreshTokensTable.createdAt,
20491
21878
  userAgent: this.refreshTokensTable.userAgent,
20492
21879
  ipAddress: this.refreshTokensTable.ipAddress
20493
- }).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.userId, userId)).orderBy(this.refreshTokensTable.createdAt);
21880
+ }).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
20494
21881
  }
20495
- async deleteById(id, userId) {
20496
- await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.userId} = ${userId}`);
21882
+ async deleteById(id, uid) {
21883
+ await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
20497
21884
  }
20498
21885
  };
20499
21886
  /**
@@ -20514,14 +21901,14 @@ var PasswordResetTokenService = class {
20514
21901
  /**
20515
21902
  * Create a password reset token
20516
21903
  */
20517
- async createToken(userId, tokenHash, expiresAt) {
21904
+ async createToken(uid, tokenHash, expiresAt) {
20518
21905
  const tableName = this.getQualifiedPasswordResetTokensTableName();
20519
21906
  await this.db.execute(sql`
20520
21907
  DELETE FROM ${sql.raw(tableName)}
20521
- WHERE user_id = ${userId} AND used_at IS NULL
21908
+ WHERE uid = ${uid} AND used_at IS NULL
20522
21909
  `);
20523
21910
  await this.db.insert(this.passwordResetTokensTable).values({
20524
- userId,
21911
+ uid,
20525
21912
  tokenHash,
20526
21913
  expiresAt
20527
21914
  });
@@ -20531,13 +21918,13 @@ var PasswordResetTokenService = class {
20531
21918
  */
20532
21919
  async findValidByHash(tokenHash) {
20533
21920
  const [token] = await this.db.select({
20534
- userId: this.passwordResetTokensTable.userId,
21921
+ uid: this.passwordResetTokensTable.uid,
20535
21922
  expiresAt: this.passwordResetTokensTable.expiresAt
20536
21923
  }).from(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.tokenHash, tokenHash));
20537
21924
  if (!token) return null;
20538
21925
  const tableName = this.getQualifiedPasswordResetTokensTableName();
20539
21926
  const result = await this.db.execute(sql`
20540
- SELECT user_id, expires_at
21927
+ SELECT uid, expires_at
20541
21928
  FROM ${sql.raw(tableName)}
20542
21929
  WHERE token_hash = ${tokenHash}
20543
21930
  AND used_at IS NULL
@@ -20546,7 +21933,7 @@ var PasswordResetTokenService = class {
20546
21933
  if (result.rows.length === 0) return null;
20547
21934
  const row = result.rows[0];
20548
21935
  return {
20549
- userId: row.user_id,
21936
+ uid: row.uid,
20550
21937
  expiresAt: new Date(row.expires_at)
20551
21938
  };
20552
21939
  }
@@ -20559,8 +21946,8 @@ var PasswordResetTokenService = class {
20559
21946
  /**
20560
21947
  * Delete all tokens for a user
20561
21948
  */
20562
- async deleteAllForUser(userId) {
20563
- await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.userId, userId));
21949
+ async deleteAllForUser(uid) {
21950
+ await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.uid, uid));
20564
21951
  }
20565
21952
  /**
20566
21953
  * Clean up expired tokens
@@ -20588,14 +21975,14 @@ var MagicLinkTokenService = class {
20588
21975
  const name = getTableName(this.magicLinkTokensTable);
20589
21976
  return `"${getTableConfig(this.magicLinkTokensTable).schema || "public"}"."${name}"`;
20590
21977
  }
20591
- async createToken(userId, tokenHash, expiresAt) {
21978
+ async createToken(uid, tokenHash, expiresAt) {
20592
21979
  const tableName = this.getQualifiedTableName();
20593
21980
  await this.db.execute(sql`
20594
21981
  DELETE FROM ${sql.raw(tableName)}
20595
- WHERE user_id = ${userId} AND used_at IS NULL
21982
+ WHERE uid = ${uid} AND used_at IS NULL
20596
21983
  `);
20597
21984
  await this.db.insert(this.magicLinkTokensTable).values({
20598
- userId,
21985
+ uid,
20599
21986
  tokenHash,
20600
21987
  expiresAt
20601
21988
  });
@@ -20603,7 +21990,7 @@ var MagicLinkTokenService = class {
20603
21990
  async findValidByHash(tokenHash) {
20604
21991
  const tableName = this.getQualifiedTableName();
20605
21992
  const result = await this.db.execute(sql`
20606
- SELECT user_id, expires_at
21993
+ SELECT uid, expires_at
20607
21994
  FROM ${sql.raw(tableName)}
20608
21995
  WHERE token_hash = ${tokenHash}
20609
21996
  AND used_at IS NULL
@@ -20612,7 +21999,7 @@ var MagicLinkTokenService = class {
20612
21999
  if (result.rows.length === 0) return null;
20613
22000
  const row = result.rows[0];
20614
22001
  return {
20615
- userId: row.user_id,
22002
+ uid: row.uid,
20616
22003
  expiresAt: new Date(row.expires_at)
20617
22004
  };
20618
22005
  }
@@ -20635,8 +22022,8 @@ var PostgresTokenRepository = class {
20635
22022
  this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
20636
22023
  this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
20637
22024
  }
20638
- async createRefreshToken(userId, tokenHash, expiresAt, userAgent, ipAddress) {
20639
- await this.refreshTokenService.createToken(userId, tokenHash, expiresAt, userAgent, ipAddress);
22025
+ async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
22026
+ await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
20640
22027
  }
20641
22028
  async findRefreshTokenByHash(tokenHash) {
20642
22029
  return this.refreshTokenService.findByHash(tokenHash);
@@ -20644,17 +22031,17 @@ var PostgresTokenRepository = class {
20644
22031
  async deleteRefreshToken(tokenHash) {
20645
22032
  await this.refreshTokenService.deleteByHash(tokenHash);
20646
22033
  }
20647
- async deleteAllRefreshTokensForUser(userId) {
20648
- await this.refreshTokenService.deleteAllForUser(userId);
22034
+ async deleteAllRefreshTokensForUser(uid) {
22035
+ await this.refreshTokenService.deleteAllForUser(uid);
20649
22036
  }
20650
- async listRefreshTokensForUser(userId) {
20651
- return this.refreshTokenService.listForUser(userId);
22037
+ async listRefreshTokensForUser(uid) {
22038
+ return this.refreshTokenService.listForUser(uid);
20652
22039
  }
20653
- async deleteRefreshTokenById(id, userId) {
20654
- await this.refreshTokenService.deleteById(id, userId);
22040
+ async deleteRefreshTokenById(id, uid) {
22041
+ await this.refreshTokenService.deleteById(id, uid);
20655
22042
  }
20656
- async createPasswordResetToken(userId, tokenHash, expiresAt) {
20657
- await this.passwordResetTokenService.createToken(userId, tokenHash, expiresAt);
22043
+ async createPasswordResetToken(uid, tokenHash, expiresAt) {
22044
+ await this.passwordResetTokenService.createToken(uid, tokenHash, expiresAt);
20658
22045
  }
20659
22046
  async findValidPasswordResetToken(tokenHash) {
20660
22047
  return this.passwordResetTokenService.findValidByHash(tokenHash);
@@ -20662,14 +22049,14 @@ var PostgresTokenRepository = class {
20662
22049
  async markPasswordResetTokenUsed(tokenHash) {
20663
22050
  await this.passwordResetTokenService.markAsUsed(tokenHash);
20664
22051
  }
20665
- async deleteAllPasswordResetTokensForUser(userId) {
20666
- await this.passwordResetTokenService.deleteAllForUser(userId);
22052
+ async deleteAllPasswordResetTokensForUser(uid) {
22053
+ await this.passwordResetTokenService.deleteAllForUser(uid);
20667
22054
  }
20668
22055
  async deleteExpiredTokens() {
20669
22056
  await this.passwordResetTokenService.deleteExpired();
20670
22057
  }
20671
- async createMagicLinkToken(userId, tokenHash, expiresAt) {
20672
- await this.magicLinkTokenService.createToken(userId, tokenHash, expiresAt);
22058
+ async createMagicLinkToken(uid, tokenHash, expiresAt) {
22059
+ await this.magicLinkTokenService.createToken(uid, tokenHash, expiresAt);
20673
22060
  }
20674
22061
  async findValidMagicLinkToken(tokenHash) {
20675
22062
  return this.magicLinkTokenService.findValidByHash(tokenHash);
@@ -20704,11 +22091,11 @@ var PostgresAuthRepository = class {
20704
22091
  async getUserByIdentity(provider, providerId) {
20705
22092
  return this.userService.getUserByIdentity(provider, providerId);
20706
22093
  }
20707
- async getUserIdentities(userId) {
20708
- return this.userService.getUserIdentities(userId);
22094
+ async getUserIdentities(uid) {
22095
+ return this.userService.getUserIdentities(uid);
20709
22096
  }
20710
- async linkUserIdentity(userId, provider, providerId, profileData) {
20711
- return this.userService.linkUserIdentity(userId, provider, providerId, profileData);
22097
+ async linkUserIdentity(uid, provider, providerId, profileData) {
22098
+ return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
20712
22099
  }
20713
22100
  async updateUser(id, data) {
20714
22101
  return this.userService.updateUser(id, data);
@@ -20734,20 +22121,20 @@ var PostgresAuthRepository = class {
20734
22121
  async getUserByVerificationToken(token) {
20735
22122
  return this.userService.getUserByVerificationToken(token);
20736
22123
  }
20737
- async getUserRoles(userId) {
20738
- return this.userService.getUserRoles(userId);
22124
+ async getUserRoles(uid) {
22125
+ return this.userService.getUserRoles(uid);
20739
22126
  }
20740
- async getUserRoleIds(userId) {
20741
- return this.userService.getUserRoleIds(userId);
22127
+ async getUserRoleIds(uid) {
22128
+ return this.userService.getUserRoleIds(uid);
20742
22129
  }
20743
- async setUserRoles(userId, roleIds) {
20744
- await this.userService.setUserRoles(userId, roleIds);
22130
+ async setUserRoles(uid, roleIds) {
22131
+ await this.userService.setUserRoles(uid, roleIds);
20745
22132
  }
20746
- async assignDefaultRole(userId, roleId) {
20747
- await this.userService.assignDefaultRole(userId, roleId);
22133
+ async assignDefaultRole(uid, roleId) {
22134
+ await this.userService.assignDefaultRole(uid, roleId);
20748
22135
  }
20749
- async getUserWithRoles(userId) {
20750
- return this.userService.getUserWithRoles(userId);
22136
+ async getUserWithRoles(uid) {
22137
+ return this.userService.getUserWithRoles(uid);
20751
22138
  }
20752
22139
  async getRoleById(id) {
20753
22140
  return {
@@ -20802,8 +22189,8 @@ var PostgresAuthRepository = class {
20802
22189
  };
20803
22190
  }
20804
22191
  async deleteRole(_id) {}
20805
- async createRefreshToken(userId, tokenHash, expiresAt, userAgent, ipAddress) {
20806
- await this.tokenRepository.createRefreshToken(userId, tokenHash, expiresAt, userAgent, ipAddress);
22192
+ async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
22193
+ await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
20807
22194
  }
20808
22195
  async findRefreshTokenByHash(tokenHash) {
20809
22196
  return this.tokenRepository.findRefreshTokenByHash(tokenHash);
@@ -20811,17 +22198,17 @@ var PostgresAuthRepository = class {
20811
22198
  async deleteRefreshToken(tokenHash) {
20812
22199
  await this.tokenRepository.deleteRefreshToken(tokenHash);
20813
22200
  }
20814
- async deleteAllRefreshTokensForUser(userId) {
20815
- await this.tokenRepository.deleteAllRefreshTokensForUser(userId);
22201
+ async deleteAllRefreshTokensForUser(uid) {
22202
+ await this.tokenRepository.deleteAllRefreshTokensForUser(uid);
20816
22203
  }
20817
- async listRefreshTokensForUser(userId) {
20818
- return this.tokenRepository.listRefreshTokensForUser(userId);
22204
+ async listRefreshTokensForUser(uid) {
22205
+ return this.tokenRepository.listRefreshTokensForUser(uid);
20819
22206
  }
20820
- async deleteRefreshTokenById(id, userId) {
20821
- await this.tokenRepository.deleteRefreshTokenById(id, userId);
22207
+ async deleteRefreshTokenById(id, uid) {
22208
+ await this.tokenRepository.deleteRefreshTokenById(id, uid);
20822
22209
  }
20823
- async createPasswordResetToken(userId, tokenHash, expiresAt) {
20824
- await this.tokenRepository.createPasswordResetToken(userId, tokenHash, expiresAt);
22210
+ async createPasswordResetToken(uid, tokenHash, expiresAt) {
22211
+ await this.tokenRepository.createPasswordResetToken(uid, tokenHash, expiresAt);
20825
22212
  }
20826
22213
  async findValidPasswordResetToken(tokenHash) {
20827
22214
  return this.tokenRepository.findValidPasswordResetToken(tokenHash);
@@ -20829,14 +22216,14 @@ var PostgresAuthRepository = class {
20829
22216
  async markPasswordResetTokenUsed(tokenHash) {
20830
22217
  await this.tokenRepository.markPasswordResetTokenUsed(tokenHash);
20831
22218
  }
20832
- async deleteAllPasswordResetTokensForUser(userId) {
20833
- await this.tokenRepository.deleteAllPasswordResetTokensForUser(userId);
22219
+ async deleteAllPasswordResetTokensForUser(uid) {
22220
+ await this.tokenRepository.deleteAllPasswordResetTokensForUser(uid);
20834
22221
  }
20835
22222
  async deleteExpiredTokens() {
20836
22223
  await this.tokenRepository.deleteExpiredTokens();
20837
22224
  }
20838
- async createMagicLinkToken(userId, tokenHash, expiresAt) {
20839
- await this.tokenRepository.createMagicLinkToken(userId, tokenHash, expiresAt);
22225
+ async createMagicLinkToken(uid, tokenHash, expiresAt) {
22226
+ await this.tokenRepository.createMagicLinkToken(uid, tokenHash, expiresAt);
20840
22227
  }
20841
22228
  async findValidMagicLinkToken(tokenHash) {
20842
22229
  return this.tokenRepository.findValidMagicLinkToken(tokenHash);
@@ -20849,11 +22236,11 @@ var PostgresAuthRepository = class {
20849
22236
  if (!this._mfaService) this._mfaService = new MfaService(this.db);
20850
22237
  return this._mfaService;
20851
22238
  }
20852
- async createMfaFactor(userId, factorType, secretEncrypted, friendlyName) {
20853
- return this.getMfaService().createMfaFactor(userId, factorType, secretEncrypted, friendlyName);
22239
+ async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
22240
+ return this.getMfaService().createMfaFactor(uid, factorType, secretEncrypted, friendlyName);
20854
22241
  }
20855
- async getMfaFactors(userId) {
20856
- return this.getMfaService().getMfaFactors(userId);
22242
+ async getMfaFactors(uid) {
22243
+ return this.getMfaService().getMfaFactors(uid);
20857
22244
  }
20858
22245
  async getMfaFactorById(factorId) {
20859
22246
  return this.getMfaService().getMfaFactorById(factorId);
@@ -20861,8 +22248,8 @@ var PostgresAuthRepository = class {
20861
22248
  async verifyMfaFactor(factorId) {
20862
22249
  return this.getMfaService().verifyMfaFactor(factorId);
20863
22250
  }
20864
- async deleteMfaFactor(factorId, userId) {
20865
- return this.getMfaService().deleteMfaFactor(factorId, userId);
22251
+ async deleteMfaFactor(factorId, uid) {
22252
+ return this.getMfaService().deleteMfaFactor(factorId, uid);
20866
22253
  }
20867
22254
  async createMfaChallenge(factorId, ipAddress) {
20868
22255
  return this.getMfaService().createMfaChallenge(factorId, ipAddress);
@@ -20873,20 +22260,20 @@ var PostgresAuthRepository = class {
20873
22260
  async verifyMfaChallenge(challengeId) {
20874
22261
  return this.getMfaService().verifyMfaChallenge(challengeId);
20875
22262
  }
20876
- async createRecoveryCodes(userId, codeHashes) {
20877
- return this.getMfaService().createRecoveryCodes(userId, codeHashes);
22263
+ async createRecoveryCodes(uid, codeHashes) {
22264
+ return this.getMfaService().createRecoveryCodes(uid, codeHashes);
20878
22265
  }
20879
- async useRecoveryCode(userId, codeHash) {
20880
- return this.getMfaService().useRecoveryCode(userId, codeHash);
22266
+ async useRecoveryCode(uid, codeHash) {
22267
+ return this.getMfaService().useRecoveryCode(uid, codeHash);
20881
22268
  }
20882
- async getUnusedRecoveryCodeCount(userId) {
20883
- return this.getMfaService().getUnusedRecoveryCodeCount(userId);
22269
+ async getUnusedRecoveryCodeCount(uid) {
22270
+ return this.getMfaService().getUnusedRecoveryCodeCount(uid);
20884
22271
  }
20885
- async deleteAllRecoveryCodes(userId) {
20886
- return this.getMfaService().deleteAllRecoveryCodes(userId);
22272
+ async deleteAllRecoveryCodes(uid) {
22273
+ return this.getMfaService().deleteAllRecoveryCodes(uid);
20887
22274
  }
20888
- async hasVerifiedMfaFactors(userId) {
20889
- return this.getMfaService().hasVerifiedMfaFactors(userId);
22275
+ async hasVerifiedMfaFactors(uid) {
22276
+ return this.getMfaService().hasVerifiedMfaFactors(uid);
20890
22277
  }
20891
22278
  };
20892
22279
  /**
@@ -20903,16 +22290,16 @@ var MfaService = class {
20903
22290
  qualify(tableName) {
20904
22291
  return `"${this.schemaName}"."${tableName}"`;
20905
22292
  }
20906
- async createMfaFactor(userId, factorType, secretEncrypted, friendlyName) {
22293
+ async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
20907
22294
  const tableName = this.qualify("mfa_factors");
20908
22295
  const row = (await this.db.execute(sql`
20909
- INSERT INTO ${sql.raw(tableName)} (user_id, factor_type, secret_encrypted, friendly_name)
20910
- VALUES (${userId}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
20911
- RETURNING id, user_id, factor_type, friendly_name, verified, created_at, updated_at
22296
+ INSERT INTO ${sql.raw(tableName)} (uid, factor_type, secret_encrypted, friendly_name)
22297
+ VALUES (${uid}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
22298
+ RETURNING id, uid, factor_type, friendly_name, verified, created_at, updated_at
20912
22299
  `)).rows[0];
20913
22300
  return {
20914
22301
  id: row.id,
20915
- userId: row.user_id,
22302
+ uid: row.uid,
20916
22303
  factorType: row.factor_type,
20917
22304
  friendlyName: row.friendly_name ?? void 0,
20918
22305
  verified: row.verified,
@@ -20920,16 +22307,16 @@ var MfaService = class {
20920
22307
  updatedAt: new Date(row.updated_at)
20921
22308
  };
20922
22309
  }
20923
- async getMfaFactors(userId) {
22310
+ async getMfaFactors(uid) {
20924
22311
  const tableName = this.qualify("mfa_factors");
20925
22312
  return (await this.db.execute(sql`
20926
- SELECT id, user_id, factor_type, friendly_name, verified, created_at, updated_at
22313
+ SELECT id, uid, factor_type, friendly_name, verified, created_at, updated_at
20927
22314
  FROM ${sql.raw(tableName)}
20928
- WHERE user_id = ${userId}
22315
+ WHERE uid = ${uid}
20929
22316
  ORDER BY created_at
20930
22317
  `)).rows.map((row) => ({
20931
22318
  id: row.id,
20932
- userId: row.user_id,
22319
+ uid: row.uid,
20933
22320
  factorType: row.factor_type,
20934
22321
  friendlyName: row.friendly_name ?? void 0,
20935
22322
  verified: row.verified,
@@ -20940,7 +22327,7 @@ var MfaService = class {
20940
22327
  async getMfaFactorById(factorId) {
20941
22328
  const tableName = this.qualify("mfa_factors");
20942
22329
  const result = await this.db.execute(sql`
20943
- SELECT id, user_id, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
22330
+ SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
20944
22331
  FROM ${sql.raw(tableName)}
20945
22332
  WHERE id = ${factorId}
20946
22333
  `);
@@ -20948,7 +22335,7 @@ var MfaService = class {
20948
22335
  const row = result.rows[0];
20949
22336
  return {
20950
22337
  id: row.id,
20951
- userId: row.user_id,
22338
+ uid: row.uid,
20952
22339
  factorType: row.factor_type,
20953
22340
  secretEncrypted: row.secret_encrypted,
20954
22341
  friendlyName: row.friendly_name ?? void 0,
@@ -20965,11 +22352,11 @@ var MfaService = class {
20965
22352
  WHERE id = ${factorId}
20966
22353
  `);
20967
22354
  }
20968
- async deleteMfaFactor(factorId, userId) {
22355
+ async deleteMfaFactor(factorId, uid) {
20969
22356
  const tableName = this.qualify("mfa_factors");
20970
22357
  await this.db.execute(sql`
20971
22358
  DELETE FROM ${sql.raw(tableName)}
20972
- WHERE id = ${factorId} AND user_id = ${userId}
22359
+ WHERE id = ${factorId} AND uid = ${uid}
20973
22360
  `);
20974
22361
  }
20975
22362
  async createMfaChallenge(factorId, ipAddress) {
@@ -21013,43 +22400,43 @@ var MfaService = class {
21013
22400
  WHERE id = ${challengeId}
21014
22401
  `);
21015
22402
  }
21016
- async createRecoveryCodes(userId, codeHashes) {
22403
+ async createRecoveryCodes(uid, codeHashes) {
21017
22404
  const tableName = this.qualify("recovery_codes");
21018
22405
  await this.db.execute(sql`
21019
- DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
22406
+ DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
21020
22407
  `);
21021
22408
  for (const hash of codeHashes) await this.db.execute(sql`
21022
- INSERT INTO ${sql.raw(tableName)} (user_id, code_hash)
21023
- VALUES (${userId}, ${hash})
22409
+ INSERT INTO ${sql.raw(tableName)} (uid, code_hash)
22410
+ VALUES (${uid}, ${hash})
21024
22411
  `);
21025
22412
  }
21026
- async useRecoveryCode(userId, codeHash) {
22413
+ async useRecoveryCode(uid, codeHash) {
21027
22414
  const tableName = this.qualify("recovery_codes");
21028
22415
  return (await this.db.execute(sql`
21029
22416
  UPDATE ${sql.raw(tableName)}
21030
22417
  SET used_at = NOW()
21031
- WHERE user_id = ${userId} AND code_hash = ${codeHash} AND used_at IS NULL
22418
+ WHERE uid = ${uid} AND code_hash = ${codeHash} AND used_at IS NULL
21032
22419
  RETURNING id
21033
22420
  `)).rows.length > 0;
21034
22421
  }
21035
- async getUnusedRecoveryCodeCount(userId) {
22422
+ async getUnusedRecoveryCodeCount(uid) {
21036
22423
  const tableName = this.qualify("recovery_codes");
21037
22424
  return (await this.db.execute(sql`
21038
22425
  SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
21039
- WHERE user_id = ${userId} AND used_at IS NULL
22426
+ WHERE uid = ${uid} AND used_at IS NULL
21040
22427
  `)).rows[0].count;
21041
22428
  }
21042
- async deleteAllRecoveryCodes(userId) {
22429
+ async deleteAllRecoveryCodes(uid) {
21043
22430
  const tableName = this.qualify("recovery_codes");
21044
22431
  await this.db.execute(sql`
21045
- DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
22432
+ DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
21046
22433
  `);
21047
22434
  }
21048
- async hasVerifiedMfaFactors(userId) {
22435
+ async hasVerifiedMfaFactors(uid) {
21049
22436
  const tableName = this.qualify("mfa_factors");
21050
22437
  return (await this.db.execute(sql`
21051
22438
  SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
21052
- WHERE user_id = ${userId} AND verified = TRUE
22439
+ WHERE uid = ${uid} AND verified = TRUE
21053
22440
  `)).rows[0].count > 0;
21054
22441
  }
21055
22442
  };
@@ -21289,6 +22676,20 @@ function patchPgArrayNullSafety(tables) {
21289
22676
  if (patchedCount > 0) logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
21290
22677
  }
21291
22678
  //#endregion
22679
+ //#region src/schema/introspect-db-naming.ts
22680
+ /**
22681
+ * Naming helpers shared by the introspection modules. These live apart from
22682
+ * `introspect-db-logic.ts` because the inference pass needs them too, and
22683
+ * importing them from there would close a cycle back through this module.
22684
+ */
22685
+ /**
22686
+ * Convert a snake_case name to a human-readable Title Case label.
22687
+ * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
22688
+ */
22689
+ function humanize(snakeName) {
22690
+ return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
22691
+ }
22692
+ //#endregion
21292
22693
  //#region src/schema/introspect-db-logic.ts
21293
22694
  var IRREGULAR_SINGULARS = {
21294
22695
  people: "person",
@@ -21348,13 +22749,6 @@ function singularize(word) {
21348
22749
  if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
21349
22750
  return word;
21350
22751
  }
21351
- /**
21352
- * Convert a snake_case name to a human-readable Title Case label.
21353
- * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
21354
- */
21355
- function humanize(snakeName) {
21356
- return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
21357
- }
21358
22752
  function getIconForTable(tableName) {
21359
22753
  const table = tableName.toLowerCase();
21360
22754
  if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
@@ -21804,21 +23198,14 @@ function createPostgresBootstrapper(pgConfig) {
21804
23198
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
21805
23199
  }
21806
23200
  const activeCollections = introspectedCollections ?? collections;
21807
- const registry = new PostgresCollectionRegistry();
21808
- if (activeCollections) {
21809
- registry.registerMultiple(activeCollections);
21810
- logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
21811
- }
21812
23201
  const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
21813
- if (schemaTables) Object.values(schemaTables).forEach((table) => {
21814
- if (isTable(table)) {
21815
- const tableName = getTableName(table);
21816
- registry.registerTable(table, tableName);
21817
- }
21818
- });
21819
- if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
21820
23202
  const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
21821
- if (schemaRelations) registry.registerRelations(schemaRelations);
23203
+ const registry = buildCollectionRegistry({
23204
+ collections: activeCollections,
23205
+ tables: schemaTables,
23206
+ enums: pgConfig.schema?.enums,
23207
+ relations: schemaRelations
23208
+ });
21822
23209
  if (schemaTables) patchPgArrayNullSafety(schemaTables);
21823
23210
  const mergedSchema = {
21824
23211
  ...schemaTables,
@@ -21882,6 +23269,11 @@ function createPostgresBootstrapper(pgConfig) {
21882
23269
  } catch (err) {
21883
23270
  logger.warn("⚠️ Could not initialize branch metadata table", { error: err });
21884
23271
  }
23272
+ try {
23273
+ await realtimeService.configureChannelHistory(pgConfig.realtime?.channels);
23274
+ } catch (err) {
23275
+ logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
23276
+ }
21885
23277
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
21886
23278
  const validModes = new Set([
21887
23279
  "auto",
@@ -21897,6 +23289,7 @@ function createPostgresBootstrapper(pgConfig) {
21897
23289
  const wantsCdc = cdcMode !== "off";
21898
23290
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
21899
23291
  let cdcEnabled = false;
23292
+ let provisionCdcForTables;
21900
23293
  if (wantsCdc && !directUrl) {
21901
23294
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
21902
23295
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -21913,6 +23306,9 @@ function createPostgresBootstrapper(pgConfig) {
21913
23306
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
21914
23307
  await realtimeService.enableCdc(directUrl);
21915
23308
  cdcEnabled = true;
23309
+ provisionCdcForTables = async (tables) => {
23310
+ await provisionTriggerCdc(cdcRunSql, tables);
23311
+ };
21916
23312
  logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
21917
23313
  } catch (err) {
21918
23314
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -21937,13 +23333,14 @@ function createPostgresBootstrapper(pgConfig) {
21937
23333
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
21938
23334
  const missing = [];
21939
23335
  for (const col of registeredCollections) {
23336
+ if (col.auth?.enabled) continue;
21940
23337
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
21941
23338
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
21942
23339
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
21943
23340
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
21944
23341
  if (!dbTables.has(fullCheckName)) missing.push({
21945
23342
  slug: col.slug,
21946
- table: checkName
23343
+ table: fullCheckName
21947
23344
  });
21948
23345
  }
21949
23346
  if (missing.length > 0) {
@@ -21977,7 +23374,8 @@ function createPostgresBootstrapper(pgConfig) {
21977
23374
  registry,
21978
23375
  realtimeService,
21979
23376
  driver,
21980
- poolManager
23377
+ poolManager,
23378
+ provisionCdcForTables
21981
23379
  }
21982
23380
  };
21983
23381
  },
@@ -21989,6 +23387,18 @@ function createPostgresBootstrapper(pgConfig) {
21989
23387
  const registry = internals.registry;
21990
23388
  const authCollection = authConfig.collection;
21991
23389
  await ensureAuthTablesExist(db, authCollection);
23390
+ if (authCollection && internals.provisionCdcForTables) {
23391
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
23392
+ const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
23393
+ if (authTable) try {
23394
+ await internals.provisionCdcForTables([{
23395
+ schema: authSchema,
23396
+ table: authTable
23397
+ }]);
23398
+ } catch (err) {
23399
+ logger.warn(`⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — writes to it won't emit database-level events.`, { detail: err instanceof Error ? err.message : String(err) });
23400
+ }
23401
+ }
21992
23402
  let emailService;
21993
23403
  if (authConfig.email) emailService = createEmailService(authConfig.email);
21994
23404
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
@@ -22059,6 +23469,6 @@ function createPostgresAdapter(pgConfig) {
22059
23469
  };
22060
23470
  }
22061
23471
  //#endregion
22062
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
23472
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
22063
23473
 
22064
23474
  //# sourceMappingURL=index.es.js.map