@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.5809e39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/PostgresBackendDriver.d.ts +25 -2
  2. package/dist/PostgresBootstrapper.d.ts +10 -0
  3. package/dist/auth/services.d.ts +16 -0
  4. package/dist/collections/buildRegistry.d.ts +27 -0
  5. package/dist/connection.d.ts +21 -0
  6. package/dist/data-transformer.d.ts +9 -2
  7. package/dist/index.es.js +1329 -548
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/schema/doctor.d.ts +1 -1
  10. package/dist/services/FetchService.d.ts +4 -24
  11. package/dist/services/PersistService.d.ts +9 -1
  12. package/dist/services/RelationService.d.ts +34 -1
  13. package/dist/services/collection-helpers.d.ts +79 -14
  14. package/dist/services/dataService.d.ts +3 -1
  15. package/dist/services/index.d.ts +1 -1
  16. package/dist/services/realtimeService.d.ts +7 -0
  17. package/dist/services/row-pipeline.d.ts +63 -0
  18. package/package.json +10 -8
  19. package/src/PostgresBackendDriver.ts +127 -13
  20. package/src/PostgresBootstrapper.ts +62 -25
  21. package/src/auth/ensure-tables.ts +73 -11
  22. package/src/auth/services.ts +49 -19
  23. package/src/collections/buildRegistry.ts +59 -0
  24. package/src/connection.ts +61 -1
  25. package/src/data-transformer.ts +11 -9
  26. package/src/databasePoolManager.ts +2 -0
  27. package/src/schema/doctor.ts +45 -20
  28. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  29. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  30. package/src/schema/introspect-db.ts +19 -2
  31. package/src/services/BranchService.ts +42 -10
  32. package/src/services/FetchService.ts +65 -270
  33. package/src/services/PersistService.ts +62 -9
  34. package/src/services/RelationService.ts +153 -94
  35. package/src/services/collection-helpers.ts +164 -47
  36. package/src/services/dataService.ts +3 -2
  37. package/src/services/index.ts +1 -0
  38. package/src/services/realtimeService.ts +40 -19
  39. package/src/services/row-pipeline.ts +239 -0
  40. package/src/utils/drizzle-conditions.ts +13 -0
  41. package/src/websocket.ts +4 -1
  42. package/dist/schema/auth-default-policies.d.ts +0 -10
  43. package/src/schema/auth-default-policies.ts +0 -132
package/dist/index.es.js CHANGED
@@ -6,12 +6,12 @@ import { drizzle } from "drizzle-orm/node-postgres";
6
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,
@@ -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) {
@@ -2284,6 +2559,307 @@ var buildPropertyCallbacks = (properties) => {
2284
2559
  };
2285
2560
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
2286
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
+ }
2287
2863
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
2288
2864
  (function(root, factory) {
2289
2865
  if (typeof define === "function" && define.amd) define(factory);
@@ -3553,18 +4129,45 @@ function deserializeFilter(query) {
3553
4129
  }
3554
4130
  //#endregion
3555
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
+ }
3556
4152
  /**
3557
- * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
3558
- * 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.
3559
4162
  */
3560
- function rowToEntity(row, slug) {
4163
+ function rowToEntity(row, slug, primaryKeys = []) {
3561
4164
  return {
3562
- id: row.id,
4165
+ id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
3563
4166
  path: slug,
3564
4167
  values: row
3565
4168
  };
3566
4169
  }
3567
- function createDriverAccessor(driver, slug) {
4170
+ function createDriverAccessor(driver, slug, getPks = () => []) {
3568
4171
  const accessor = {
3569
4172
  async find(params) {
3570
4173
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
@@ -3597,7 +4200,7 @@ function createDriverAccessor(driver, slug) {
3597
4200
  hasMore = offset + rows.length < total;
3598
4201
  }
3599
4202
  return {
3600
- data: rows.map((row) => rowToEntity(row, slug)),
4203
+ data: rows.map((row) => rowToEntity(row, slug, getPks())),
3601
4204
  meta: {
3602
4205
  total,
3603
4206
  limit,
@@ -3611,7 +4214,7 @@ function createDriverAccessor(driver, slug) {
3611
4214
  path: slug,
3612
4215
  id
3613
4216
  });
3614
- return row ? rowToEntity(row, slug) : void 0;
4217
+ return row ? rowToEntity(row, slug, getPks()) : void 0;
3615
4218
  },
3616
4219
  async create(data, id) {
3617
4220
  return rowToEntity(await driver.save({
@@ -3619,15 +4222,22 @@ function createDriverAccessor(driver, slug) {
3619
4222
  values: data,
3620
4223
  id,
3621
4224
  status: "new"
3622
- }), slug);
4225
+ }), slug, getPks());
3623
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,
3624
4234
  async update(id, data) {
3625
4235
  return rowToEntity(await driver.save({
3626
4236
  path: slug,
3627
4237
  values: data,
3628
4238
  id,
3629
4239
  status: "existing"
3630
- }), slug);
4240
+ }), slug, getPks());
3631
4241
  },
3632
4242
  async delete(id) {
3633
4243
  return driver.delete({ row: {
@@ -3656,7 +4266,7 @@ function createDriverAccessor(driver, slug) {
3656
4266
  searchString: params?.searchString,
3657
4267
  onUpdate: (entities) => {
3658
4268
  onUpdate({
3659
- data: entities.map((row) => rowToEntity(row, slug)),
4269
+ data: entities.map((row) => rowToEntity(row, slug, getPks())),
3660
4270
  meta: {
3661
4271
  total: entities.length,
3662
4272
  limit,
@@ -3672,7 +4282,7 @@ function createDriverAccessor(driver, slug) {
3672
4282
  return driver.listenOne({
3673
4283
  path: slug,
3674
4284
  id,
3675
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
4285
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
3676
4286
  onError
3677
4287
  });
3678
4288
  } : void 0,
@@ -3711,12 +4321,13 @@ function createDriverAccessor(driver, slug) {
3711
4321
  * await data.products.create({ name: "Camera", price: 299 });
3712
4322
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
3713
4323
  */
3714
- function buildRebaseData(driver) {
4324
+ function buildRebaseData(driver, options) {
3715
4325
  const cache = /* @__PURE__ */ new Map();
4326
+ const primaryKeysFor = createPrimaryKeyResolver(options);
3716
4327
  function getAccessor(slug) {
3717
4328
  let accessor = cache.get(slug);
3718
4329
  if (!accessor) {
3719
- accessor = createDriverAccessor(driver, slug);
4330
+ accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
3720
4331
  cache.set(slug, accessor);
3721
4332
  }
3722
4333
  return accessor;
@@ -3729,8 +4340,9 @@ function buildRebaseData(driver) {
3729
4340
  } });
3730
4341
  }
3731
4342
  /**
3732
- * Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
3733
- * (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.
3734
4346
  */
3735
4347
  function entityToRow(entity) {
3736
4348
  return entity.values;
@@ -3817,6 +4429,12 @@ function toSdkCollectionClient(snap) {
3817
4429
  async create(data, id) {
3818
4430
  return entityToRow(await snap.create(data, id));
3819
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
+ },
3820
4438
  async update(id, data) {
3821
4439
  return entityToRow(await snap.update(id, data));
3822
4440
  },
@@ -3969,8 +4587,26 @@ function getTableForCollection(collection, registry) {
3969
4587
  if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
3970
4588
  return table;
3971
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
+ */
3972
4609
  function getPrimaryKeys(collection, registry) {
3973
- const table = getTableForCollection(collection, registry);
3974
4610
  if (collection.properties) {
3975
4611
  const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
3976
4612
  fieldName: key,
@@ -3979,6 +4615,8 @@ function getPrimaryKeys(collection, registry) {
3979
4615
  }));
3980
4616
  if (idProps.length > 0) return idProps;
3981
4617
  }
4618
+ const table = registry.getTable(getTableName$1(collection));
4619
+ if (!table) return [];
3982
4620
  const keys = [];
3983
4621
  for (const [key, colRaw] of Object.entries(table)) {
3984
4622
  const col = colRaw;
@@ -4006,35 +4644,89 @@ function getPrimaryKeys(collection, registry) {
4006
4644
  }
4007
4645
  return keys;
4008
4646
  }
4009
- function parseIdValues(idValue, primaryKeys) {
4010
- const result = {};
4011
- if (primaryKeys.length === 0) return result;
4012
- if (primaryKeys.length === 1) {
4013
- const pk = primaryKeys[0];
4014
- if (pk.type === "number" && !pk.isUUID) {
4015
- const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
4016
- if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
4017
- result[pk.fieldName] = parsed;
4018
- } else result[pk.fieldName] = String(idValue);
4019
- return result;
4020
- }
4021
- const parts = String(idValue).split(":::");
4022
- if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
4023
- for (let i = 0; i < primaryKeys.length; i++) {
4024
- const pk = primaryKeys[i];
4025
- const val = parts[i];
4026
- if (pk.type === "number" && !pk.isUUID) {
4027
- const parsed = parseInt(val, 10);
4028
- if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
4029
- result[pk.fieldName] = parsed;
4030
- } 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
+ });
4031
4697
  }
4032
- return result;
4698
+ return findings;
4033
4699
  }
4034
- function buildCompositeId(values, primaryKeys) {
4035
- if (primaryKeys.length === 0) return "";
4036
- if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
4037
- 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 "";
4038
4730
  }
4039
4731
  //#endregion
4040
4732
  //#region src/utils/drizzle-conditions.ts
@@ -4574,6 +5266,7 @@ var DrizzleConditionBuilder = class {
4574
5266
  static buildVectorSearchConditions(table, vectorSearch) {
4575
5267
  const column = table[vectorSearch.property];
4576
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");
4577
5270
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
4578
5271
  const distanceFn = vectorSearch.distance || "cosine";
4579
5272
  let operator;
@@ -4657,12 +5350,10 @@ function serializeDataToServer(row, properties, collection, registry) {
4657
5350
  continue;
4658
5351
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
4659
5352
  const serializedValue = serializePropertyToServer(effectiveValue, property);
4660
- const pks = getPrimaryKeys(collection, registry);
4661
5353
  inverseRelationUpdates.push({
4662
5354
  relationKey: key,
4663
5355
  relation,
4664
- newValue: serializedValue,
4665
- currentId: row.id || buildCompositeId(row, pks)
5356
+ newValue: serializedValue
4666
5357
  });
4667
5358
  continue;
4668
5359
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
@@ -4672,15 +5363,11 @@ function serializeDataToServer(row, properties, collection, registry) {
4672
5363
  relation,
4673
5364
  newTargetId: serializedValue
4674
5365
  });
4675
- else {
4676
- const pks = getPrimaryKeys(collection, registry);
4677
- inverseRelationUpdates.push({
4678
- relationKey: key,
4679
- relation,
4680
- newValue: serializedValue,
4681
- currentId: row.id || buildCompositeId(row, pks)
4682
- });
4683
- }
5366
+ else inverseRelationUpdates.push({
5367
+ relationKey: key,
5368
+ relation,
5369
+ newValue: serializedValue
5370
+ });
4684
5371
  continue;
4685
5372
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
4686
5373
  const serializedValue = serializePropertyToServer(effectiveValue, property);
@@ -5080,6 +5767,63 @@ var RelationService = class {
5080
5767
  this.registry = registry;
5081
5768
  }
5082
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
+ /**
5083
5827
  * Fetch rows related to a parent row through a specific relation
5084
5828
  */
5085
5829
  async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
@@ -5098,9 +5842,9 @@ var RelationService = class {
5098
5842
  async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
5099
5843
  const targetCollection = relation.target();
5100
5844
  const targetTable = getTableForCollection(targetCollection, this.registry);
5101
- const idInfo = getPrimaryKeys(targetCollection, this.registry);
5845
+ const idInfo = requirePrimaryKeys(targetCollection, this.registry);
5102
5846
  const idField = targetTable[idInfo[0].fieldName];
5103
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5847
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5104
5848
  const parentIdInfo = parentPks[0];
5105
5849
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5106
5850
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5124,7 +5868,7 @@ var RelationService = class {
5124
5868
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5125
5869
  currentTable = joinTable;
5126
5870
  }
5127
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5871
+ const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
5128
5872
  query = query.where(eq(parentIdField, parsedParentId));
5129
5873
  if (options.limit) query = query.limit(options.limit);
5130
5874
  const results = await query;
@@ -5132,13 +5876,7 @@ var RelationService = class {
5132
5876
  const rows = [];
5133
5877
  for (const row of results) {
5134
5878
  const targetRow = row[targetTableName] || row;
5135
- const id = targetRow[idInfo[0].fieldName];
5136
- const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5137
- rows.push({
5138
- id: id?.toString() || "",
5139
- path: targetCollection.slug,
5140
- values: parsedValues
5141
- });
5879
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5142
5880
  }
5143
5881
  return rows;
5144
5882
  }
@@ -5157,13 +5895,7 @@ var RelationService = class {
5157
5895
  const rows = [];
5158
5896
  for (const row of results) {
5159
5897
  const targetRow = row[getTableName$1(targetCollection)] || row;
5160
- const id = targetRow[idInfo[0].fieldName];
5161
- const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5162
- rows.push({
5163
- id: id?.toString() || "",
5164
- path: targetCollection.slug,
5165
- values: parsedValues
5166
- });
5898
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5167
5899
  }
5168
5900
  return rows;
5169
5901
  }
@@ -5180,8 +5912,8 @@ var RelationService = class {
5180
5912
  }
5181
5913
  const targetCollection = relation.target();
5182
5914
  const targetTable = getTableForCollection(targetCollection, this.registry);
5183
- const targetIdField = targetTable[getPrimaryKeys(targetCollection, this.registry)[0].fieldName];
5184
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5915
+ const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
5916
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5185
5917
  const parentIdInfo = parentPks[0];
5186
5918
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5187
5919
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5200,9 +5932,10 @@ var RelationService = class {
5200
5932
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5201
5933
  const targetCollection = relation.target();
5202
5934
  const targetTable = getTableForCollection(targetCollection, this.registry);
5203
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5935
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5936
+ const targetIdInfo = targetPks[0];
5204
5937
  const targetIdField = targetTable[targetIdInfo.fieldName];
5205
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5938
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5206
5939
  const parentIdInfo = parentPks[0];
5207
5940
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5208
5941
  if (!parentTable) throw new Error("Parent table not found");
@@ -5226,25 +5959,19 @@ var RelationService = class {
5226
5959
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5227
5960
  currentTable = joinTable;
5228
5961
  }
5229
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5230
- query = query.where(inArray(parentIdField, parsedParentIds));
5962
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5231
5963
  const results = await query;
5232
5964
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5233
5965
  const resultMap = /* @__PURE__ */ new Map();
5234
5966
  for (const row of results) {
5235
5967
  const parentRow = row[getTableName$1(parentCollection)] || row;
5236
5968
  const targetRow = row[targetTableName] || row;
5237
- const parentId = parentRow[parentIdInfo.fieldName];
5238
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5239
- resultMap.set(String(parentId), {
5240
- id: String(targetRow[targetIdInfo.fieldName]),
5241
- path: targetCollection.slug,
5242
- values: parsedValues
5243
- });
5969
+ resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5244
5970
  }
5245
5971
  return resultMap;
5246
5972
  }
5247
5973
  if (relation.direction === "owning" && relation.localKey) {
5974
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
5248
5975
  const localKeyCol = parentTable[relation.localKey];
5249
5976
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
5250
5977
  const fkRows = await this.db.select({
@@ -5273,17 +6000,11 @@ var RelationService = class {
5273
6000
  const resultMap = /* @__PURE__ */ new Map();
5274
6001
  for (const [parentIdStr, fkValue] of parentToFk) {
5275
6002
  const targetRow = targetById.get(String(fkValue));
5276
- if (targetRow) {
5277
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5278
- resultMap.set(parentIdStr, {
5279
- id: String(targetRow[targetIdInfo.fieldName]),
5280
- path: targetCollection.slug,
5281
- values: parsedValues
5282
- });
5283
- }
6003
+ if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
5284
6004
  }
5285
6005
  return resultMap;
5286
6006
  }
6007
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5287
6008
  let query = this.db.select().from(targetTable).$dynamic();
5288
6009
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5289
6010
  const results = await query;
@@ -5294,14 +6015,7 @@ var RelationService = class {
5294
6015
  let parentId;
5295
6016
  if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5296
6017
  else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
5297
- if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5298
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5299
- resultMap.set(String(parentId), {
5300
- id: String(targetRow[targetIdInfo.fieldName]),
5301
- path: targetCollection.slug,
5302
- values: parsedValues
5303
- });
5304
- }
6018
+ if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5305
6019
  }
5306
6020
  return resultMap;
5307
6021
  }
@@ -5315,9 +6029,9 @@ var RelationService = class {
5315
6029
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5316
6030
  const targetCollection = relation.target();
5317
6031
  const targetTable = getTableForCollection(targetCollection, this.registry);
5318
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5319
- const targetIdField = targetTable[targetIdInfo.fieldName];
5320
- 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);
5321
6035
  const parentIdInfo = parentPks[0];
5322
6036
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5323
6037
  if (!parentTable) throw new Error("Parent table not found");
@@ -5339,27 +6053,22 @@ var RelationService = class {
5339
6053
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5340
6054
  currentTable = joinTable;
5341
6055
  }
5342
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5343
- query = query.where(inArray(parentIdField, parsedParentIds));
6056
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5344
6057
  const results = await query;
5345
6058
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5346
6059
  const resultMap = /* @__PURE__ */ new Map();
5347
6060
  for (const row of results) {
5348
6061
  const parentRow = row[getTableName$1(parentCollection)] || row;
5349
6062
  const targetRow = row[targetTableName] || row;
5350
- const parentId = String(parentRow[parentIdInfo.fieldName]);
5351
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6063
+ const parentId = buildCompositeId(parentRow, parentPks);
5352
6064
  const arr = resultMap.get(parentId) || [];
5353
- arr.push({
5354
- id: String(targetRow[targetIdInfo.fieldName]),
5355
- path: targetCollection.slug,
5356
- values: parsedValues
5357
- });
6065
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5358
6066
  resultMap.set(parentId, arr);
5359
6067
  }
5360
6068
  return resultMap;
5361
6069
  }
5362
6070
  if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
6071
+ this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
5363
6072
  const junctionTable = this.registry.getTable(relation.through.table);
5364
6073
  if (!junctionTable) {
5365
6074
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -5378,17 +6087,13 @@ var RelationService = class {
5378
6087
  const junctionData = row[relation.through.table] || row;
5379
6088
  const targetData = row[targetTableName] || row;
5380
6089
  const parentId = String(junctionData[relation.through.sourceColumn]);
5381
- const parsedValues = await parseDataFromServer(targetData, targetCollection);
5382
6090
  const arr = resultMap.get(parentId) || [];
5383
- arr.push({
5384
- id: String(targetData[targetIdInfo.fieldName]),
5385
- path: targetCollection.slug,
5386
- values: parsedValues
5387
- });
6091
+ arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
5388
6092
  resultMap.set(parentId, arr);
5389
6093
  }
5390
6094
  return resultMap;
5391
6095
  }
6096
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5392
6097
  let query = this.db.select().from(targetTable).$dynamic();
5393
6098
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5394
6099
  const results = await query;
@@ -5401,14 +6106,9 @@ var RelationService = class {
5401
6106
  else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5402
6107
  else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
5403
6108
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5404
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5405
6109
  const key = String(parentId);
5406
6110
  const arr = resultMap.get(key) || [];
5407
- arr.push({
5408
- id: String(targetRow[targetIdInfo.fieldName]),
5409
- path: targetCollection.slug,
5410
- values: parsedValues
5411
- });
6111
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5412
6112
  resultMap.set(key, arr);
5413
6113
  }
5414
6114
  }
@@ -5456,12 +6156,12 @@ var RelationService = class {
5456
6156
  logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
5457
6157
  continue;
5458
6158
  }
5459
- const parentPks = getPrimaryKeys(collection, this.registry);
6159
+ const parentPks = requirePrimaryKeys(collection, this.registry);
5460
6160
  const parentIdInfo = parentPks[0];
5461
6161
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
5462
6162
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
5463
6163
  if (targetEntityIds.length > 0) {
5464
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6164
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5465
6165
  const targetIdInfo = targetPks[0];
5466
6166
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
5467
6167
  [sourceJunctionColumn.name]: parsedParentId,
@@ -5481,12 +6181,12 @@ var RelationService = class {
5481
6181
  logger.warn(`Junction columns not found for relation '${key}'`);
5482
6182
  continue;
5483
6183
  }
5484
- const parentPks = getPrimaryKeys(collection, this.registry);
6184
+ const parentPks = requirePrimaryKeys(collection, this.registry);
5485
6185
  const parentIdInfo = parentPks[0];
5486
6186
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
5487
6187
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
5488
6188
  if (targetEntityIds.length > 0) {
5489
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6189
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5490
6190
  const targetIdInfo = targetPks[0];
5491
6191
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
5492
6192
  [sourceJunctionColumn.name]: parsedParentId,
@@ -5497,7 +6197,7 @@ var RelationService = class {
5497
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.`);
5498
6198
  else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
5499
6199
  const targetTable = getTableForCollection(targetCollection, this.registry);
5500
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6200
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5501
6201
  const targetIdInfo = targetPks[0];
5502
6202
  const targetIdCol = targetTable[targetIdInfo.fieldName];
5503
6203
  const fkCol = targetTable[relation.foreignKeyOnTarget];
@@ -5505,7 +6205,7 @@ var RelationService = class {
5505
6205
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
5506
6206
  continue;
5507
6207
  }
5508
- const parentPks = getPrimaryKeys(collection, this.registry);
6208
+ const parentPks = requirePrimaryKeys(collection, this.registry);
5509
6209
  const parentIdInfo = parentPks[0];
5510
6210
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
5511
6211
  if (targetEntityIds.length > 0) {
@@ -5525,9 +6225,9 @@ var RelationService = class {
5525
6225
  try {
5526
6226
  const targetCollection = relation.target();
5527
6227
  const targetTable = getTableForCollection(targetCollection, this.registry);
5528
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6228
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5529
6229
  const targetIdInfo = targetPks[0];
5530
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6230
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5531
6231
  const sourceIdInfo = sourcePks[0];
5532
6232
  if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
5533
6233
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
@@ -5608,12 +6308,12 @@ var RelationService = class {
5608
6308
  logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
5609
6309
  return;
5610
6310
  }
5611
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6311
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5612
6312
  const sourceIdInfo = sourcePks[0];
5613
6313
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
5614
6314
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
5615
6315
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
5616
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6316
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5617
6317
  const targetIdInfo = targetPks[0];
5618
6318
  const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
5619
6319
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -5621,7 +6321,7 @@ var RelationService = class {
5621
6321
  }));
5622
6322
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
5623
6323
  } else if (newValue && !Array.isArray(newValue)) {
5624
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6324
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5625
6325
  const targetIdInfo = targetPks[0];
5626
6326
  const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
5627
6327
  const newLink = {
@@ -5652,12 +6352,12 @@ var RelationService = class {
5652
6352
  logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
5653
6353
  return;
5654
6354
  }
5655
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6355
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5656
6356
  const sourceIdInfo = sourcePks[0];
5657
6357
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
5658
6358
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
5659
6359
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
5660
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6360
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5661
6361
  const targetIdInfo = targetPks[0];
5662
6362
  const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
5663
6363
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -5678,12 +6378,12 @@ var RelationService = class {
5678
6378
  const { relation, newTargetId } = upd;
5679
6379
  const targetCollection = relation.target();
5680
6380
  const targetTable = getTableForCollection(targetCollection, this.registry);
5681
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6381
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5682
6382
  const targetIdInfo = targetPks[0];
5683
6383
  const targetIdCol = targetTable[targetIdInfo.fieldName];
5684
6384
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
5685
6385
  const parentTable = getTableForCollection(parentCollection, this.registry);
5686
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
6386
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5687
6387
  const parentIdInfo = parentPks[0];
5688
6388
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5689
6389
  const parentIdCol = parentTable[parentIdInfo.fieldName];
@@ -5754,7 +6454,7 @@ var RelationService = class {
5754
6454
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
5755
6455
  return;
5756
6456
  }
5757
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6457
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5758
6458
  const targetIdInfo = targetPks[0];
5759
6459
  const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
5760
6460
  const junctionData = {
@@ -5770,6 +6470,150 @@ var RelationService = class {
5770
6470
  }
5771
6471
  };
5772
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
+ }
6616
+ //#endregion
5773
6617
  //#region src/services/FetchService.ts
5774
6618
  /**
5775
6619
  * Service for handling all row read operations.
@@ -5828,7 +6672,7 @@ var FetchService = class {
5828
6672
  if (!shouldInclude(key)) continue;
5829
6673
  const drizzleRelName = relation.relationName || key;
5830
6674
  if (relation.joinPath && relation.joinPath.length > 0) continue;
5831
- if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
6675
+ if (relation.cardinality === "many" && isJunctionRelation(relation)) {
5832
6676
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
5833
6677
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
5834
6678
  else withConfig[drizzleRelName] = true;
@@ -5837,14 +6681,6 @@ var FetchService = class {
5837
6681
  return withConfig;
5838
6682
  }
5839
6683
  /**
5840
- * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
5841
- */
5842
- isJunctionRelation(relation, _collection) {
5843
- if (relation.through) return true;
5844
- if (relation.joinPath && relation.joinPath.length > 1) return true;
5845
- return false;
5846
- }
5847
- /**
5848
6684
  * Get the Drizzle relation name on the junction table that points to the actual target row.
5849
6685
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
5850
6686
  */
@@ -5853,55 +6689,6 @@ var FetchService = class {
5853
6689
  return null;
5854
6690
  }
5855
6691
  /**
5856
- * Convert a db.query result row (with nested relation objects) to a flat row.
5857
- * Handles:
5858
- * - Placing `id` at the top level as a string
5859
- * - Type normalization (dates, numbers, NaN) via normalizeDbValues
5860
- * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
5861
- * - Flattening junction-table many-to-many results
5862
- */
5863
- drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
5864
- const resolvedRelations = resolveCollectionRelations(collection);
5865
- const normalizedValues = normalizeDbValues(row, collection);
5866
- for (const [key, relation] of Object.entries(resolvedRelations)) {
5867
- const relData = row[relation.relationName || key];
5868
- if (relData === void 0 || relData === null) continue;
5869
- if (relation.cardinality === "many" && Array.isArray(relData)) {
5870
- const targetCollection = relation.target();
5871
- const targetPath = targetCollection.slug;
5872
- const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
5873
- normalizedValues[key] = relData.map((item) => {
5874
- let targetRow = item;
5875
- if (this.isJunctionRelation(relation, collection)) {
5876
- const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
5877
- if (nestedKey) targetRow = item[nestedKey];
5878
- }
5879
- const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
5880
- return createRelationRefWithData(relId, targetPath, {
5881
- id: relId,
5882
- path: targetPath,
5883
- values: normalizeDbValues(targetRow, targetCollection)
5884
- });
5885
- });
5886
- } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
5887
- const targetCollection = relation.target();
5888
- const targetPath = targetCollection.slug;
5889
- const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
5890
- const relObj = relData;
5891
- const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
5892
- normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
5893
- id: relId,
5894
- path: targetPath,
5895
- values: normalizeDbValues(relObj, targetCollection)
5896
- });
5897
- }
5898
- }
5899
- return {
5900
- ...normalizedValues,
5901
- id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
5902
- };
5903
- }
5904
- /**
5905
6692
  * Post-fetch joinPath relations for a single flat row.
5906
6693
  * joinPath relations cannot be expressed via Drizzle's `with` config,
5907
6694
  * so they must be loaded separately after the primary query.
@@ -5922,31 +6709,6 @@ var FetchService = class {
5922
6709
  await Promise.all(promises);
5923
6710
  }
5924
6711
  /**
5925
- * Post-fetch joinPath relations for a batch of flat rows.
5926
- * Uses batch fetching to avoid N+1 queries for list views.
5927
- */
5928
- async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
5929
- if (rows.length === 0) return;
5930
- const resolvedRelations = resolveCollectionRelations(collection);
5931
- const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
5932
- if (joinPathRelations.length === 0) return;
5933
- for (const [key, relation] of joinPathRelations) try {
5934
- const rowIds = rows.map((r) => {
5935
- return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
5936
- });
5937
- const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
5938
- for (const row of rows) {
5939
- const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
5940
- const relatedRow = resultMap.get(String(id));
5941
- if (relatedRow) {
5942
- if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
5943
- }
5944
- }
5945
- } catch (e) {
5946
- logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
5947
- }
5948
- }
5949
- /**
5950
6712
  * Resolves joinPath relations for raw REST rows and directly injects them.
5951
6713
  * Uses RelationService to query the database and maps results back to the flattened objects.
5952
6714
  */
@@ -5957,72 +6719,29 @@ var FetchService = class {
5957
6719
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
5958
6720
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
5959
6721
  if (joinPathRelations.length === 0) return;
5960
- 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
+ };
5961
6726
  for (const [key, relation] of joinPathRelations) try {
5962
- const rowIds = rows.map((r) => {
5963
- return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
5964
- });
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));
5965
6730
  if (relation.cardinality === "one") {
5966
6731
  const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
5967
- for (const row of rows) {
5968
- const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
5969
- const relatedRow = resultMap.get(String(id));
5970
- if (relatedRow) row[key] = {
5971
- ...relatedRow.values,
5972
- id: relatedRow.id
5973
- };
5974
- 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;
5975
6735
  }
5976
6736
  } else if (relation.cardinality === "many") {
5977
6737
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
5978
- for (const row of rows) {
5979
- const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
5980
- row[key] = (resultMap.get(String(id)) || []).map((e) => ({
5981
- ...e.values,
5982
- id: e.id
5983
- }));
5984
- }
6738
+ for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
5985
6739
  }
5986
6740
  } catch (e) {
5987
6741
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
5988
6742
  }
5989
6743
  }
5990
6744
  /**
5991
- * Convert a db.query result row to a flat REST-style object with populated relations.
5992
- */
5993
- drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
5994
- const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
5995
- const resolvedRelations = resolveCollectionRelations(collection);
5996
- for (const [k, v] of Object.entries(row)) {
5997
- if (k === idInfo.fieldName) continue;
5998
- const relation = findRelation(resolvedRelations, k);
5999
- if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
6000
- if (this.isJunctionRelation(relation, collection)) {
6001
- const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6002
- if (nestedKey) {
6003
- const nested = item[nestedKey];
6004
- return {
6005
- ...nested,
6006
- id: String(nested.id ?? nested[Object.keys(nested)[0]])
6007
- };
6008
- }
6009
- }
6010
- return {
6011
- ...item,
6012
- id: String(item.id ?? item[Object.keys(item)[0]])
6013
- };
6014
- });
6015
- else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
6016
- const relObj = v;
6017
- flat[k] = {
6018
- ...relObj,
6019
- id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
6020
- };
6021
- } else flat[k] = v;
6022
- }
6023
- return flat;
6024
- }
6025
- /**
6026
6745
  * Build db.query-compatible options from standard fetch options.
6027
6746
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
6028
6747
  */
@@ -6092,7 +6811,7 @@ var FetchService = class {
6092
6811
  async fetchOne(collectionPath, id, databaseId) {
6093
6812
  const collection = getCollectionByPath(collectionPath, this.registry);
6094
6813
  const table = getTableForCollection(collection, this.registry);
6095
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6814
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6096
6815
  const idInfo = idInfoArray[0];
6097
6816
  const idField = table[idInfo.fieldName];
6098
6817
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6106,7 +6825,7 @@ var FetchService = class {
6106
6825
  with: withConfig
6107
6826
  });
6108
6827
  if (!row) return void 0;
6109
- const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
6828
+ const flatRow = toCmsRow(row, collection, this.registry);
6110
6829
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6111
6830
  return flatRow;
6112
6831
  } catch (e) {
@@ -6148,7 +6867,7 @@ var FetchService = class {
6148
6867
  async fetchRowsWithConditions(collectionPath, options = {}) {
6149
6868
  const collection = getCollectionByPath(collectionPath, this.registry);
6150
6869
  const table = getTableForCollection(collection, this.registry);
6151
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6870
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6152
6871
  const idInfo = idInfoArray[0];
6153
6872
  const idField = table[idInfo.fieldName];
6154
6873
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6158,7 +6877,7 @@ var FetchService = class {
6158
6877
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6159
6878
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6160
6879
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6161
- 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));
6162
6881
  } catch (e) {
6163
6882
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6164
6883
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6219,8 +6938,10 @@ var FetchService = class {
6219
6938
  }
6220
6939
  /**
6221
6940
  * Fallback path used when db.query is unavailable.
6222
- * The primary path uses drizzleResultToRow which handles relation
6223
- * 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.
6224
6945
  *
6225
6946
  * Process raw database results into flat rows with relations.
6226
6947
  */
@@ -6229,8 +6950,7 @@ var FetchService = class {
6229
6950
  const parsedRows = await Promise.all(results.map(async (rawRow) => {
6230
6951
  return {
6231
6952
  rawRow,
6232
- values: await parseDataFromServer(rawRow, collection),
6233
- id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
6953
+ values: await parseDataFromServer(rawRow, collection)
6234
6954
  };
6235
6955
  }));
6236
6956
  if (!skipRelations) {
@@ -6270,10 +6990,7 @@ var FetchService = class {
6270
6990
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
6271
6991
  }
6272
6992
  }
6273
- return parsedRows.map((item) => ({
6274
- ...item.values,
6275
- id: item.id
6276
- }));
6993
+ return parsedRows.map((item) => item.values);
6277
6994
  }
6278
6995
  /**
6279
6996
  * Fetch a collection of rows
@@ -6304,10 +7021,7 @@ var FetchService = class {
6304
7021
  const relationKey = pathSegments[i];
6305
7022
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
6306
7023
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
6307
- if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
6308
- ...row.values,
6309
- id: row.id
6310
- }));
7024
+ if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
6311
7025
  if (i + 1 < pathSegments.length) {
6312
7026
  const nextEntityId = pathSegments[i + 1];
6313
7027
  currentCollection = relation.target();
@@ -6369,7 +7083,7 @@ var FetchService = class {
6369
7083
  if (value === void 0 || value === null) return true;
6370
7084
  const collection = getCollectionByPath(collectionPath, this.registry);
6371
7085
  const table = getTableForCollection(collection, this.registry);
6372
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7086
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6373
7087
  const idInfo = idInfoArray[0];
6374
7088
  const idField = table[idInfo.fieldName];
6375
7089
  const field = table[fieldName];
@@ -6396,7 +7110,7 @@ var FetchService = class {
6396
7110
  async fetchCollectionForRest(collectionPath, options = {}, include) {
6397
7111
  const collection = getCollectionByPath(collectionPath, this.registry);
6398
7112
  const table = getTableForCollection(collection, this.registry);
6399
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7113
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6400
7114
  const idInfo = idInfoArray[0];
6401
7115
  const idField = table[idInfo.fieldName];
6402
7116
  const tableName = getTableName(table);
@@ -6404,7 +7118,7 @@ var FetchService = class {
6404
7118
  if (qb && !options.searchString && !options.vectorSearch) try {
6405
7119
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
6406
7120
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
6407
- 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));
6408
7122
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
6409
7123
  return restRows;
6410
7124
  } catch (e) {
@@ -6415,10 +7129,7 @@ var FetchService = class {
6415
7129
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
6416
7130
  }
6417
7131
  const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
6418
- if (!include || include.length === 0) return rows.map((row) => ({
6419
- ...row,
6420
- id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6421
- }));
7132
+ if (!include || include.length === 0) return rows;
6422
7133
  const resolvedRelations = resolveCollectionRelations(collection);
6423
7134
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
6424
7135
  const shouldInclude = (key) => include[0] === "*" || include.includes(key);
@@ -6430,10 +7141,7 @@ var FetchService = class {
6430
7141
  for (const row of rows) {
6431
7142
  const eid = row[idInfo.fieldName];
6432
7143
  const related = batchResults.get(String(eid));
6433
- if (related) row[key] = {
6434
- ...related.values,
6435
- id: related.id
6436
- };
7144
+ if (related) row[key] = { ...related.values };
6437
7145
  }
6438
7146
  } catch (e) {
6439
7147
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
@@ -6451,10 +7159,7 @@ var FetchService = class {
6451
7159
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
6452
7160
  }
6453
7161
  }
6454
- return rows.map((row) => ({
6455
- ...row,
6456
- id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6457
- }));
7162
+ return rows;
6458
7163
  }
6459
7164
  /**
6460
7165
  * Fetch a single row with optional relation includes for REST API.
@@ -6462,7 +7167,7 @@ var FetchService = class {
6462
7167
  async fetchOneForRest(collectionPath, id, include, databaseId) {
6463
7168
  const collection = getCollectionByPath(collectionPath, this.registry);
6464
7169
  const table = getTableForCollection(collection, this.registry);
6465
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7170
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6466
7171
  const idInfo = idInfoArray[0];
6467
7172
  const idField = table[idInfo.fieldName];
6468
7173
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
@@ -6475,7 +7180,7 @@ var FetchService = class {
6475
7180
  ...withConfig ? { with: withConfig } : {}
6476
7181
  });
6477
7182
  if (!row) return null;
6478
- const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
7183
+ const restRow = toRestRow(row, collection, this.registry);
6479
7184
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
6480
7185
  return restRow;
6481
7186
  } catch (e) {
@@ -6487,11 +7192,7 @@ var FetchService = class {
6487
7192
  }
6488
7193
  const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
6489
7194
  if (result.length === 0) return null;
6490
- const raw = result[0];
6491
- const flatEntity = {
6492
- ...raw,
6493
- id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
6494
- };
7195
+ const flatEntity = { ...result[0] };
6495
7196
  if (!include || include.length === 0) return flatEntity;
6496
7197
  const resolvedRelations = resolveCollectionRelations(collection);
6497
7198
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
@@ -6524,7 +7225,7 @@ var FetchService = class {
6524
7225
  async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
6525
7226
  const collection = getCollectionByPath(collectionPath, this.registry);
6526
7227
  const table = getTableForCollection(collection, this.registry);
6527
- const idField = table[getPrimaryKeys(collection, this.registry)[0].fieldName];
7228
+ const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
6528
7229
  let vectorMeta;
6529
7230
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
6530
7231
  let query = vectorMeta ? this.db.select({
@@ -6603,32 +7304,15 @@ var FetchService = class {
6603
7304
  if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
6604
7305
  }
6605
7306
  return (await queryTarget.findMany(queryOpts)).map((row) => {
6606
- const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
6607
- for (const [k, v] of Object.entries(row)) {
6608
- if (k === idInfo.fieldName) continue;
6609
- if (Array.isArray(v)) flat[k] = v.map((item) => {
6610
- const keys = Object.keys(item);
6611
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6612
- if (nestedObj && keys.length <= 3) {
6613
- const nested = item[nestedObj];
6614
- return {
6615
- ...nested,
6616
- id: String(nested.id ?? nested[Object.keys(nested)[0]])
6617
- };
6618
- }
6619
- return {
6620
- ...item,
6621
- id: String(item.id ?? item[Object.keys(item)[0]])
6622
- };
6623
- });
6624
- else if (typeof v === "object" && v !== null) {
6625
- const relObj = v;
6626
- flat[k] = {
6627
- ...relObj,
6628
- id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
6629
- };
6630
- } else flat[k] = v;
6631
- }
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;
6632
7316
  return flat;
6633
7317
  });
6634
7318
  } catch (e) {
@@ -6889,8 +7573,14 @@ var PersistService = class {
6889
7573
  }
6890
7574
  /**
6891
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.
6892
7582
  */
6893
- async save(collectionPath, values, id, databaseId) {
7583
+ async save(collectionPath, values, id, databaseId, options) {
6894
7584
  let effectiveCollectionPath = collectionPath;
6895
7585
  const effectiveValues = { ...values };
6896
7586
  let junctionTableInfo;
@@ -6981,7 +7671,7 @@ var PersistService = class {
6981
7671
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
6982
7672
  savedId = await this.db.transaction(async (tx) => {
6983
7673
  let currentId;
6984
- if (id) {
7674
+ if (id && !options?.upsert) {
6985
7675
  currentId = id;
6986
7676
  const idValues = parseIdValues(id, idInfoArray);
6987
7677
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
@@ -6996,9 +7686,24 @@ var PersistService = class {
6996
7686
  }
6997
7687
  } else {
6998
7688
  const dataForInsert = { ...entityData };
7689
+ if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
6999
7690
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
7000
- const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
7001
- 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);
7002
7707
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
7003
7708
  }
7004
7709
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
@@ -7009,7 +7714,7 @@ var PersistService = class {
7009
7714
  } catch (error) {
7010
7715
  throw this.toUserFriendlyError(error, collection.slug);
7011
7716
  }
7012
- const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
7717
+ const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
7013
7718
  if (!finalEntity) throw new Error("Could not fetch row after save.");
7014
7719
  return finalEntity;
7015
7720
  }
@@ -7107,8 +7812,8 @@ var DataService = class {
7107
7812
  /**
7108
7813
  * Save an row (create or update)
7109
7814
  */
7110
- async save(collectionPath, values, id, databaseId) {
7111
- 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);
7112
7817
  }
7113
7818
  /**
7114
7819
  * Delete an row by ID
@@ -7176,6 +7881,26 @@ var DataService = class {
7176
7881
  */
7177
7882
  /** Internal prefix applied to branch database names to avoid collisions. */
7178
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
+ }
7179
7904
  /** Fully-qualified metadata table in the rebase schema. */
7180
7905
  var BRANCHES_TABLE = "rebase.branches";
7181
7906
  /**
@@ -7244,10 +7969,8 @@ var BranchService = class {
7244
7969
  try {
7245
7970
  await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
7246
7971
  } catch (err) {
7247
- const msg = err instanceof Error ? err.message : String(err);
7248
- if (msg.includes("already exists")) throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
7249
- 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.`);
7250
- 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);
7251
7974
  }
7252
7975
  const now = /* @__PURE__ */ new Date();
7253
7976
  await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
@@ -7272,8 +7995,8 @@ var BranchService = class {
7272
7995
  try {
7273
7996
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
7274
7997
  } catch (err) {
7275
- 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.`);
7276
- 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);
7277
8000
  }
7278
8001
  await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
7279
8002
  }
@@ -7905,17 +8628,19 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7905
8628
  this.realtimeService.subscriptions.delete(subscriptionId);
7906
8629
  };
7907
8630
  }
7908
- async save({ path, id, values, collection, status }) {
8631
+ async save({ path, id, values, collection, status, upsert }) {
7909
8632
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
7910
8633
  let updatedValues = values;
7911
8634
  const contextForCallback = this.buildCallContext();
7912
8635
  let previousValuesForHistory;
7913
- if (status === "existing" && id) {
7914
- const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8636
+ if (status === "existing" && id) try {
8637
+ const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
7915
8638
  if (existing) {
7916
8639
  const { id: _existingId, ...existingValues } = existing;
7917
8640
  previousValuesForHistory = existingValues;
7918
8641
  }
8642
+ } catch (err) {
8643
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
7919
8644
  }
7920
8645
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
7921
8646
  if (globalCallbacks?.beforeSave) {
@@ -7962,7 +8687,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7962
8687
  timestampNowValue: /* @__PURE__ */ new Date()
7963
8688
  });
7964
8689
  try {
7965
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
8690
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
7966
8691
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
7967
8692
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
7968
8693
  collection: resolvedCollection,
@@ -7983,8 +8708,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7983
8708
  context: contextForCallback
7984
8709
  });
7985
8710
  }
7986
- const savedId = savedRow.id;
7987
- const { id: _savedId, ...savedValues } = savedRow;
8711
+ const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
8712
+ const savedValues = savedRow;
7988
8713
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
7989
8714
  if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
7990
8715
  collection: resolvedCollection,
@@ -8016,7 +8741,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8016
8741
  }
8017
8742
  if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
8018
8743
  tableName: path,
8019
- id: savedId.toString(),
8744
+ id: savedId,
8020
8745
  action: status === "new" ? "create" : "update",
8021
8746
  values: savedValues,
8022
8747
  previousValues: previousValuesForHistory,
@@ -8024,11 +8749,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8024
8749
  });
8025
8750
  if (this._deferNotifications) this._pendingNotifications.push({
8026
8751
  path,
8027
- id: savedId.toString(),
8752
+ id: savedId,
8028
8753
  row: savedRow,
8029
8754
  databaseId: resolvedCollection?.databaseId
8030
8755
  });
8031
- else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
8756
+ else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
8032
8757
  return savedRow;
8033
8758
  } catch (error) {
8034
8759
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
@@ -8063,12 +8788,52 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8063
8788
  throw error;
8064
8789
  }
8065
8790
  }
8791
+ /**
8792
+ * Write many rows through the same pipeline as {@link save}.
8793
+ *
8794
+ * The batch runs in one transaction of its own, so a failure part-way leaves
8795
+ * nothing behind — the point of a batch is that a re-run starts from a known
8796
+ * state. When this driver is already inside a transaction (the authenticated
8797
+ * path, via `withTransaction`) the nested call becomes a savepoint, which is
8798
+ * still atomic and still commits once.
8799
+ *
8800
+ * Rows are applied in order, so a batch that touches the same key twice ends
8801
+ * with the last write winning, exactly as separate calls would.
8802
+ */
8803
+ async saveMany({ path, rows, collection, upsert }) {
8804
+ return this.db.transaction(async (tx) => {
8805
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
8806
+ txDriver.dataService = new DataService(tx, this.registry);
8807
+ txDriver.client = this.client;
8808
+ txDriver._deferNotifications = this._deferNotifications;
8809
+ txDriver._pendingNotifications = this._pendingNotifications;
8810
+ const saved = [];
8811
+ for (let i = 0; i < rows.length; i++) {
8812
+ const values = rows[i];
8813
+ const id = values?.id;
8814
+ try {
8815
+ saved.push(await txDriver.save({
8816
+ path,
8817
+ values,
8818
+ collection,
8819
+ status: "new",
8820
+ upsert
8821
+ }));
8822
+ } catch (error) {
8823
+ const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
8824
+ throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
8825
+ statusCode: error?.statusCode,
8826
+ code: error?.code,
8827
+ name: error?.name
8828
+ });
8829
+ }
8830
+ }
8831
+ return saved;
8832
+ });
8833
+ }
8066
8834
  async delete({ row, collection }) {
8067
8835
  const targetPath = row.path;
8068
- const targetRow = {
8069
- id: row.id,
8070
- ...row.values ?? {}
8071
- };
8836
+ const targetRow = { ...row.values ?? {} };
8072
8837
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
8073
8838
  const contextForCallback = this.buildCallContext();
8074
8839
  if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -8427,6 +9192,18 @@ var AuthenticatedPostgresBackendDriver = class {
8427
9192
  async save(props) {
8428
9193
  return this.withTransaction((delegate) => delegate.save(props));
8429
9194
  }
9195
+ /**
9196
+ * One transaction for the whole batch, rather than one per row.
9197
+ *
9198
+ * This is the point of the method: `save` opens a transaction per call, so
9199
+ * importing 10k rows through it means 10k transactions (and, over HTTP, 10k
9200
+ * round trips). Here the RLS context is established once and every row lands
9201
+ * or none does. Realtime notifications are already deferred to commit by
9202
+ * `withTransaction`, so a batch does not flood subscribers mid-flight.
9203
+ */
9204
+ async saveMany(props) {
9205
+ return this.withTransaction((delegate) => delegate.saveMany(props));
9206
+ }
8430
9207
  async delete(props) {
8431
9208
  return this.withTransaction((delegate) => delegate.delete(props));
8432
9209
  }
@@ -8476,6 +9253,7 @@ var DatabasePoolManager = class {
8476
9253
  pool.on("error", (err) => {
8477
9254
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
8478
9255
  });
9256
+ guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
8479
9257
  this.pools.set(databaseName, pool);
8480
9258
  return pool;
8481
9259
  }
@@ -8673,105 +9451,6 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
8673
9451
  references: [users.id]
8674
9452
  }) }));
8675
9453
  //#endregion
8676
- //#region src/schema/auth-default-policies.ts
8677
- /**
8678
- * Default RLS policies injected by the schema generator.
8679
- *
8680
- * Rebase's enforcement model is unified: authenticated (user-context) requests
8681
- * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
8682
- * statement — reads and writes. A collection's `securityRules` are the whole
8683
- * authorization model. The server context (auth flows, migrations,
8684
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
8685
- *
8686
- * Because RLS default-denies, every collection is **locked by default**: with
8687
- * no rules, only the server context and admins can touch it. The generator
8688
- * injects that safe baseline:
8689
- *
8690
- * **For every collection**
8691
- * 1. A permissive **server-or-admin SELECT** grant.
8692
- * 2. A permissive **server-or-admin write** grant (insert/update/delete).
8693
- *
8694
- * Author `securityRules` are permissive and OR together, so explicit rules only
8695
- * *broaden* access from this locked baseline (e.g. "users read/write their own
8696
- * rows").
8697
- *
8698
- * **For auth collections additionally**
8699
- * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
8700
- * their own row (profile, session bootstrap) without every app re-declaring
8701
- * it.
8702
- * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
8703
- * every other policy, so a write is rejected unless the caller is an admin
8704
- * (or the server context) — even if the author also wrote a permissive rule
8705
- * such as "a user may edit their own row". Without this, a permissive owner
8706
- * rule would let a user change their own `roles`.
8707
- *
8708
- * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
8709
- * — the built-in flows that run without a user (signup, migrations) set no user
8710
- * GUC — which also lets the owner connection satisfy these policies even under
8711
- * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
8712
- * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
8713
- *
8714
- * Opt out with `disableDefaultPolicies: true` to take full responsibility for
8715
- * the collection's RLS.
8716
- */
8717
- var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
8718
- /** Write operations that must be admin-gated by default on auth collections. */
8719
- var DEFAULT_GUARDED_OPS = [
8720
- "insert",
8721
- "update",
8722
- "delete"
8723
- ];
8724
- /** Whether a collection is flagged as an authentication collection. */
8725
- function isAuthCollection(collection) {
8726
- const auth = collection.auth;
8727
- return auth === true || typeof auth === "object" && auth?.enabled === true;
8728
- }
8729
- /** The property marked as the row id (falls back to `id`). */
8730
- function getIdPropertyName(collection) {
8731
- for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
8732
- return "id";
8733
- }
8734
- /**
8735
- * Returns the security rules that should be applied to a collection: the
8736
- * author's explicit `securityRules` plus the framework defaults described in
8737
- * the module doc (baseline server/admin read for all collections; self-read
8738
- * and the admin write gate for auth collections).
8739
- *
8740
- * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
8741
- */
8742
- function getEffectiveSecurityRules(collection) {
8743
- const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
8744
- if (collection.disableDefaultPolicies) return explicit;
8745
- const tableName = getTableName$1(collection);
8746
- const injected = [];
8747
- injected.push({
8748
- name: `${tableName}_default_admin_read`,
8749
- operations: ["select"],
8750
- condition: SERVER_OR_ADMIN_EXPR
8751
- });
8752
- injected.push({
8753
- name: `${tableName}_default_admin_write`,
8754
- operations: [...DEFAULT_GUARDED_OPS],
8755
- condition: SERVER_OR_ADMIN_EXPR,
8756
- check: SERVER_OR_ADMIN_EXPR
8757
- });
8758
- if (isAuthCollection(collection)) {
8759
- injected.push({
8760
- name: `${tableName}_default_self_read`,
8761
- operations: ["select"],
8762
- condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
8763
- });
8764
- injected.push({
8765
- name: `${tableName}_require_admin_write`,
8766
- mode: "restrictive",
8767
- operations: [...DEFAULT_GUARDED_OPS],
8768
- condition: SERVER_OR_ADMIN_EXPR,
8769
- check: SERVER_OR_ADMIN_EXPR
8770
- });
8771
- }
8772
- return [...explicit, ...injected];
8773
- }
8774
- //#endregion
8775
9454
  //#region src/schema/generate-drizzle-schema-logic.ts
8776
9455
  /**
8777
9456
  * Resolve the SQL column name for a property.
@@ -8969,31 +9648,12 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
8969
9648
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
8970
9649
  */
8971
9650
  var wrapSql = (clause) => `sql\`${clause}\``;
8972
- /**
8973
- * Generates a deterministic hash based on the rule configuration.
8974
- */
8975
- var getPolicyNameHash = (rule) => {
8976
- const data = JSON.stringify({
8977
- a: rule.access,
8978
- m: rule.mode,
8979
- op: rule.operation,
8980
- ops: rule.operations?.slice().sort(),
8981
- own: rule.ownerField,
8982
- rol: rule.roles?.slice().sort(),
8983
- pg: rule.pgRoles?.slice().sort(),
8984
- u: rule.using,
8985
- w: rule.withCheck,
8986
- c: rule.condition,
8987
- ch: rule.check
8988
- });
8989
- return createHash("sha1").update(data).digest("hex").substring(0, 7);
8990
- };
8991
9651
  var generatePolicyCode = (collection, rule, index, resolveCollection) => {
8992
9652
  const tableName = getTableName$1(collection);
8993
9653
  const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
8994
- const ruleHash = getPolicyNameHash(rule);
9654
+ const policyNames = getPolicyNamesForRule(rule, tableName);
8995
9655
  return ops.map((op, opIdx) => {
8996
- return generateSinglePolicyCode(collection, rule, op, rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`, resolveCollection);
9656
+ return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
8997
9657
  }).join("");
8998
9658
  };
8999
9659
  /**
@@ -9122,6 +9782,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
9122
9782
  });
9123
9783
  });
9124
9784
  schemaContent += "\n";
9785
+ const junctionSpecs = resolveJunctionSpecs(collections);
9125
9786
  for (const collection of collections) {
9126
9787
  const tableName = getTableName$1(collection);
9127
9788
  if (tableName) allTablesToGenerate.set(tableName, { collection });
@@ -9155,9 +9816,17 @@ var generateSchema = async (collections, stripPolicies = false) => {
9155
9816
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
9156
9817
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
9157
9818
  schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
9158
- schemaContent += "}, (table) => ({\n";
9159
- schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
9160
- schemaContent += "}));\n\n";
9819
+ schemaContent += "}, (table) => ([\n";
9820
+ schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
9821
+ const junctionSpec = junctionSpecs.get(baseTableName);
9822
+ if (!stripPolicies && junctionSpec) {
9823
+ const junctionCollection = getJunctionCollectionConfig(junctionSpec);
9824
+ const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
9825
+ getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
9826
+ schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
9827
+ });
9828
+ }
9829
+ schemaContent += "])).enableRLS();\n\n";
9161
9830
  } else if (!isJunction) {
9162
9831
  const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
9163
9832
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -9848,7 +10517,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9848
10517
  startAfter: request.startAfter,
9849
10518
  searchString: request.searchString
9850
10519
  }, authContext);
9851
- this.sendCollectionUpdate(clientId, subscriptionId, rows);
10520
+ this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
9852
10521
  } catch (error) {
9853
10522
  const sanitized = sanitizeErrorForClient(error, request.path);
9854
10523
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -9949,7 +10618,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9949
10618
  if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
9950
10619
  else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
9951
10620
  else if (subscription.type === "collection" && subscription.collectionRequest) {
9952
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
10621
+ if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
9953
10622
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
9954
10623
  }
9955
10624
  } catch (error) {
@@ -9979,7 +10648,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9979
10648
  if (!this._subscriptions.has(subscriptionId)) return;
9980
10649
  try {
9981
10650
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
9982
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
10651
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
9983
10652
  } catch (error) {
9984
10653
  const sanitized = sanitizeErrorForClient(error, notifyPath);
9985
10654
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10193,11 +10862,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10193
10862
  }
10194
10863
  return await this.dataService.fetchOne(notifyPath, id);
10195
10864
  }
10196
- sendCollectionUpdate(clientId, subscriptionId, rows) {
10865
+ sendCollectionUpdate(clientId, subscriptionId, rows, path) {
10197
10866
  const message = {
10198
10867
  type: "collection_update",
10199
10868
  subscriptionId,
10200
- rows
10869
+ rows,
10870
+ pks: this.primaryKeysForPath(path)
10201
10871
  };
10202
10872
  this.sendMessage(clientId, message);
10203
10873
  }
@@ -10212,16 +10882,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10212
10882
  /**
10213
10883
  * Send a lightweight row-level patch to a collection subscriber.
10214
10884
  * The client can merge this into its cached data for instant feedback.
10885
+ *
10886
+ * The key columns ride along: the patch names a row by address, and the
10887
+ * client has to find that row among the ones it cached — which carry
10888
+ * columns and no address. The SDK holds no collection config to derive one
10889
+ * from, so this is the only place the mapping can come from.
10215
10890
  */
10216
- sendCollectionPatch(clientId, subscriptionId, id, row) {
10891
+ sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
10217
10892
  const message = {
10218
10893
  type: "collection_patch",
10219
10894
  subscriptionId,
10220
10895
  id,
10221
- row
10896
+ row,
10897
+ pks: this.primaryKeysForPath(notifyPath)
10222
10898
  };
10223
10899
  this.sendMessage(clientId, message);
10224
10900
  }
10901
+ /** The key columns of the collection at `path`, if they can be resolved. */
10902
+ primaryKeysForPath(path) {
10903
+ try {
10904
+ const collection = this.registry.getCollectionByPath(path);
10905
+ if (!collection) return void 0;
10906
+ const keys = getPrimaryKeys(collection, this.registry);
10907
+ return keys.length > 0 ? keys : void 0;
10908
+ } catch {
10909
+ return;
10910
+ }
10911
+ }
10225
10912
  sendError(clientId, error, subscriptionId, code) {
10226
10913
  const message = {
10227
10914
  type: "error",
@@ -10469,12 +11156,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10469
11156
  }
10470
11157
  /** Compute the canonical (possibly composite) id string from a captured row. */
10471
11158
  extractIdFromCdcRow(collection, row) {
10472
- try {
10473
- const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
10474
- if (composite && composite !== ":::") return composite;
10475
- } catch {}
10476
- if (row.id !== void 0 && row.id !== null) return String(row.id);
10477
- return "*";
11159
+ return deriveRowAddress(row, collection, this.registry) || "*";
10478
11160
  }
10479
11161
  dedupKey(path, id, databaseId) {
10480
11162
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -11090,7 +11772,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11090
11772
  } catch (error) {
11091
11773
  logger.error("💥 [WebSocket Server] Error handling message", { error });
11092
11774
  if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
11093
- const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error instanceof Error ? error.message : "An unexpected error occurred";
11775
+ const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
11094
11776
  const errorResponse = {
11095
11777
  type: "ERROR",
11096
11778
  requestId,
@@ -19934,6 +20616,33 @@ function formatBytes(bytes) {
19934
20616
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
19935
20617
  }
19936
20618
  //#endregion
20619
+ //#region src/collections/buildRegistry.ts
20620
+ /**
20621
+ * Build the collection registry for a driver.
20622
+ *
20623
+ * The order matters and is the reason this is one function rather than a run of
20624
+ * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
20625
+ * anything that inspects them has to run *after* the tables are registered —
20626
+ * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
20627
+ * collection whose table it cannot look up is one it has nothing to say about.
20628
+ * Warned too early, it would skip every collection and report nothing, which
20629
+ * reads exactly like having nothing to report.
20630
+ */
20631
+ function buildCollectionRegistry(schema) {
20632
+ const registry = new PostgresCollectionRegistry();
20633
+ if (schema.collections) {
20634
+ registry.registerMultiple(schema.collections);
20635
+ logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
20636
+ }
20637
+ if (schema.tables) Object.values(schema.tables).forEach((table) => {
20638
+ if (isTable(table)) registry.registerTable(table, getTableName(table));
20639
+ });
20640
+ if (schema.enums) registry.registerEnums(schema.enums);
20641
+ if (schema.relations) registry.registerRelations(schema.relations);
20642
+ warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
20643
+ return registry;
20644
+ }
20645
+ //#endregion
19937
20646
  //#region src/auth/ensure-tables.ts
19938
20647
  /**
19939
20648
  * Auto-create auth tables if they don't exist.
@@ -20105,14 +20814,22 @@ async function ensureAuthTablesExist(db, collection) {
20105
20814
  $$ LANGUAGE sql STABLE
20106
20815
  `);
20107
20816
  });
20108
- await db.execute(sql`
20109
- ALTER TABLE ${sql.raw(usersTableName)}
20110
- ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
20111
- `);
20112
- await db.execute(sql`
20113
- ALTER TABLE ${sql.raw(usersTableName)}
20114
- ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
20115
- `);
20817
+ for (const columnDef of [
20818
+ "display_name VARCHAR(255)",
20819
+ "photo_url VARCHAR(500)",
20820
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
20821
+ "password_hash VARCHAR(255)",
20822
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
20823
+ "email_verification_token VARCHAR(255)",
20824
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
20825
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
20826
+ "metadata JSONB DEFAULT '{}' NOT NULL",
20827
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
20828
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
20829
+ ]) await db.execute(sql`
20830
+ ALTER TABLE ${sql.raw(usersTableName)}
20831
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
20832
+ `);
20116
20833
  try {
20117
20834
  if ((await db.execute(sql`
20118
20835
  SELECT EXISTS (
@@ -20183,6 +20900,34 @@ async function ensureAuthTablesExist(db, collection) {
20183
20900
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20184
20901
  ON ${sql.raw(recoveryCodesTableName)}(user_id)
20185
20902
  `);
20903
+ try {
20904
+ const authTablePairs = [
20905
+ [usersSchema, resolvedTable],
20906
+ [authSchema, "user_identities"],
20907
+ [authSchema, "refresh_tokens"],
20908
+ [authSchema, "password_reset_tokens"],
20909
+ [authSchema, "app_config"],
20910
+ [authSchema, "mfa_factors"],
20911
+ [authSchema, "mfa_challenges"],
20912
+ [authSchema, "recovery_codes"]
20913
+ ];
20914
+ for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
20915
+ SELECT 1
20916
+ FROM pg_class c
20917
+ JOIN pg_namespace n ON n.oid = c.relnamespace
20918
+ WHERE n.nspname = ${schemaName}
20919
+ AND c.relname = ${tableName}
20920
+ AND c.relforcerowsecurity
20921
+ `)).rows.length > 0) {
20922
+ await db.execute(sql`
20923
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
20924
+ NO FORCE ROW LEVEL SECURITY
20925
+ `);
20926
+ logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
20927
+ }
20928
+ } catch (rlsReconcileError) {
20929
+ logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
20930
+ }
20186
20931
  logger.info("✅ Auth tables ready");
20187
20932
  } catch (error) {
20188
20933
  logger.error("❌ Failed to create auth tables", { error });
@@ -20230,6 +20975,31 @@ var UserService = class {
20230
20975
  const name = getTableName(this.usersTable);
20231
20976
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20232
20977
  }
20978
+ /**
20979
+ * Run a privileged auth write with an explicitly cleared RLS context.
20980
+ *
20981
+ * The auth services run on the base/owner connection, which by design
20982
+ * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
20983
+ * in the default policies applies. That NULL is normally guaranteed by
20984
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
20985
+ * GUC that survives on a pooled connection (or a connection role that
20986
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
20987
+ * turns the trusted write into an RLS-scoped one and denies it with
20988
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
20989
+ * single chokepoint, makes the server context deterministic instead of
20990
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
20991
+ * NULL via NULLIF, so '' is the server context.
20992
+ */
20993
+ async withServerContext(fn) {
20994
+ return await this.db.transaction(async (tx) => {
20995
+ await tx.execute(sql`
20996
+ SELECT set_config('app.user_id', '', true),
20997
+ set_config('app.user_roles', '', true),
20998
+ set_config('app.jwt', '', true)
20999
+ `);
21000
+ return await fn(tx);
21001
+ });
21002
+ }
20233
21003
  mapRowToUser(row) {
20234
21004
  if (!row) return row;
20235
21005
  const id = row.id ?? row.uid;
@@ -20327,7 +21097,7 @@ var UserService = class {
20327
21097
  }
20328
21098
  async createUser(data) {
20329
21099
  const payload = this.mapPayload(data);
20330
- const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21100
+ const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20331
21101
  return this.mapRowToUser(row);
20332
21102
  }
20333
21103
  async getUserById(id) {
@@ -20366,12 +21136,12 @@ var UserService = class {
20366
21136
  }));
20367
21137
  }
20368
21138
  async linkUserIdentity(userId, provider, providerId, profileData) {
20369
- await this.db.insert(this.userIdentitiesTable).values({
21139
+ await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
20370
21140
  userId,
20371
21141
  provider,
20372
21142
  providerId,
20373
21143
  profileData: profileData || null
20374
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21144
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
20375
21145
  }
20376
21146
  async updateUser(id, data) {
20377
21147
  const idCol = getColumn(this.usersTable, "id");
@@ -20379,13 +21149,13 @@ var UserService = class {
20379
21149
  const payload = this.mapPayload(data);
20380
21150
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20381
21151
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
20382
- const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21152
+ const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
20383
21153
  return row ? this.mapRowToUser(row) : null;
20384
21154
  }
20385
21155
  async deleteUser(id) {
20386
21156
  const idCol = getColumn(this.usersTable, "id");
20387
21157
  if (!idCol) return;
20388
- await this.db.delete(this.usersTable).where(eq(idCol, id));
21158
+ await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
20389
21159
  }
20390
21160
  async listUsers() {
20391
21161
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -20439,10 +21209,10 @@ var UserService = class {
20439
21209
  if (!idCol) return;
20440
21210
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
20441
21211
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20442
- await this.db.update(this.usersTable).set({
21212
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20443
21213
  [passwordHashColKey]: passwordHash,
20444
21214
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20445
- }).where(eq(idCol, id));
21215
+ }).where(eq(idCol, id)));
20446
21216
  }
20447
21217
  /**
20448
21218
  * Set email verification status
@@ -20453,11 +21223,11 @@ var UserService = class {
20453
21223
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
20454
21224
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
20455
21225
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20456
- await this.db.update(this.usersTable).set({
21226
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20457
21227
  [emailVerifiedColKey]: verified,
20458
21228
  [emailVerificationTokenColKey]: null,
20459
21229
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20460
- }).where(eq(idCol, id));
21230
+ }).where(eq(idCol, id)));
20461
21231
  }
20462
21232
  /**
20463
21233
  * Set email verification token
@@ -20468,11 +21238,11 @@ var UserService = class {
20468
21238
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
20469
21239
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
20470
21240
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20471
- await this.db.update(this.usersTable).set({
21241
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20472
21242
  [emailVerificationTokenColKey]: token,
20473
21243
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
20474
21244
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20475
- }).where(eq(idCol, id));
21245
+ }).where(eq(idCol, id)));
20476
21246
  }
20477
21247
  /**
20478
21248
  * Find user by email verification token
@@ -20517,22 +21287,22 @@ var UserService = class {
20517
21287
  async setUserRoles(userId, roleIds) {
20518
21288
  const usersTableName = this.getQualifiedUsersTableName();
20519
21289
  const rolesArray = `{${roleIds.join(",")}}`;
20520
- await this.db.execute(sql`
21290
+ await this.withServerContext(async (db) => db.execute(sql`
20521
21291
  UPDATE ${sql.raw(usersTableName)}
20522
21292
  SET roles = ${rolesArray}::text[], updated_at = NOW()
20523
21293
  WHERE id = ${userId}
20524
- `);
21294
+ `));
20525
21295
  }
20526
21296
  /**
20527
21297
  * Assign a specific role to new user (appends if not present)
20528
21298
  */
20529
21299
  async assignDefaultRole(userId, roleId) {
20530
21300
  const usersTableName = this.getQualifiedUsersTableName();
20531
- await this.db.execute(sql`
21301
+ await this.withServerContext(async (db) => db.execute(sql`
20532
21302
  UPDATE ${sql.raw(usersTableName)}
20533
21303
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
20534
21304
  WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
20535
- `);
21305
+ `));
20536
21306
  }
20537
21307
  /**
20538
21308
  * Get user with their roles
@@ -21916,21 +22686,14 @@ function createPostgresBootstrapper(pgConfig) {
21916
22686
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
21917
22687
  }
21918
22688
  const activeCollections = introspectedCollections ?? collections;
21919
- const registry = new PostgresCollectionRegistry();
21920
- if (activeCollections) {
21921
- registry.registerMultiple(activeCollections);
21922
- logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
21923
- }
21924
22689
  const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
21925
- if (schemaTables) Object.values(schemaTables).forEach((table) => {
21926
- if (isTable(table)) {
21927
- const tableName = getTableName(table);
21928
- registry.registerTable(table, tableName);
21929
- }
21930
- });
21931
- if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
21932
22690
  const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
21933
- if (schemaRelations) registry.registerRelations(schemaRelations);
22691
+ const registry = buildCollectionRegistry({
22692
+ collections: activeCollections,
22693
+ tables: schemaTables,
22694
+ enums: pgConfig.schema?.enums,
22695
+ relations: schemaRelations
22696
+ });
21934
22697
  if (schemaTables) patchPgArrayNullSafety(schemaTables);
21935
22698
  const mergedSchema = {
21936
22699
  ...schemaTables,
@@ -22009,6 +22772,7 @@ function createPostgresBootstrapper(pgConfig) {
22009
22772
  const wantsCdc = cdcMode !== "off";
22010
22773
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
22011
22774
  let cdcEnabled = false;
22775
+ let provisionCdcForTables;
22012
22776
  if (wantsCdc && !directUrl) {
22013
22777
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
22014
22778
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -22025,6 +22789,9 @@ function createPostgresBootstrapper(pgConfig) {
22025
22789
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
22026
22790
  await realtimeService.enableCdc(directUrl);
22027
22791
  cdcEnabled = true;
22792
+ provisionCdcForTables = async (tables) => {
22793
+ await provisionTriggerCdc(cdcRunSql, tables);
22794
+ };
22028
22795
  logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
22029
22796
  } catch (err) {
22030
22797
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -22049,13 +22816,14 @@ function createPostgresBootstrapper(pgConfig) {
22049
22816
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
22050
22817
  const missing = [];
22051
22818
  for (const col of registeredCollections) {
22819
+ if (col.auth?.enabled) continue;
22052
22820
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
22053
22821
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
22054
22822
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
22055
22823
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
22056
22824
  if (!dbTables.has(fullCheckName)) missing.push({
22057
22825
  slug: col.slug,
22058
- table: checkName
22826
+ table: fullCheckName
22059
22827
  });
22060
22828
  }
22061
22829
  if (missing.length > 0) {
@@ -22089,7 +22857,8 @@ function createPostgresBootstrapper(pgConfig) {
22089
22857
  registry,
22090
22858
  realtimeService,
22091
22859
  driver,
22092
- poolManager
22860
+ poolManager,
22861
+ provisionCdcForTables
22093
22862
  }
22094
22863
  };
22095
22864
  },
@@ -22101,6 +22870,18 @@ function createPostgresBootstrapper(pgConfig) {
22101
22870
  const registry = internals.registry;
22102
22871
  const authCollection = authConfig.collection;
22103
22872
  await ensureAuthTablesExist(db, authCollection);
22873
+ if (authCollection && internals.provisionCdcForTables) {
22874
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
22875
+ const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
22876
+ if (authTable) try {
22877
+ await internals.provisionCdcForTables([{
22878
+ schema: authSchema,
22879
+ table: authTable
22880
+ }]);
22881
+ } catch (err) {
22882
+ 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) });
22883
+ }
22884
+ }
22104
22885
  let emailService;
22105
22886
  if (authConfig.email) emailService = createEmailService(authConfig.email);
22106
22887
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
@@ -22171,6 +22952,6 @@ function createPostgresAdapter(pgConfig) {
22171
22952
  };
22172
22953
  }
22173
22954
  //#endregion
22174
- 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 };
22955
+ 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 };
22175
22956
 
22176
22957
  //# sourceMappingURL=index.es.js.map