@rebasepro/server-postgres 0.9.1-canary.0fce67c → 0.9.1-canary.1d2d8b5

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 +2 -25
  2. package/dist/PostgresBootstrapper.d.ts +0 -10
  3. package/dist/auth/services.d.ts +0 -16
  4. package/dist/connection.d.ts +0 -21
  5. package/dist/data-transformer.d.ts +2 -9
  6. package/dist/index.es.js +548 -1329
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/schema/auth-default-policies.d.ts +10 -0
  9. package/dist/schema/doctor.d.ts +1 -1
  10. package/dist/services/FetchService.d.ts +24 -4
  11. package/dist/services/PersistService.d.ts +1 -9
  12. package/dist/services/RelationService.d.ts +1 -34
  13. package/dist/services/collection-helpers.d.ts +14 -79
  14. package/dist/services/dataService.d.ts +1 -3
  15. package/dist/services/index.d.ts +1 -1
  16. package/dist/services/realtimeService.d.ts +0 -7
  17. package/package.json +8 -10
  18. package/src/PostgresBackendDriver.ts +13 -127
  19. package/src/PostgresBootstrapper.ts +25 -62
  20. package/src/auth/ensure-tables.ts +11 -73
  21. package/src/auth/services.ts +19 -49
  22. package/src/connection.ts +1 -61
  23. package/src/data-transformer.ts +9 -11
  24. package/src/databasePoolManager.ts +0 -2
  25. package/src/schema/auth-default-policies.ts +132 -0
  26. package/src/schema/doctor.ts +20 -45
  27. package/src/schema/generate-drizzle-schema-logic.ts +29 -24
  28. package/src/schema/generate-postgres-ddl-logic.ts +28 -76
  29. package/src/schema/introspect-db.ts +2 -19
  30. package/src/services/BranchService.ts +10 -42
  31. package/src/services/FetchService.ts +270 -65
  32. package/src/services/PersistService.ts +9 -62
  33. package/src/services/RelationService.ts +94 -153
  34. package/src/services/collection-helpers.ts +47 -164
  35. package/src/services/dataService.ts +2 -3
  36. package/src/services/index.ts +0 -1
  37. package/src/services/realtimeService.ts +19 -40
  38. package/src/utils/drizzle-conditions.ts +0 -13
  39. package/src/websocket.ts +1 -4
  40. package/dist/collections/buildRegistry.d.ts +0 -27
  41. package/dist/services/row-pipeline.d.ts +0 -63
  42. package/src/collections/buildRegistry.ts +0 -59
  43. package/src/services/row-pipeline.ts +0 -239
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";
9
10
  import fs, { promises } from "fs";
10
11
  import path from "path";
11
12
  import chokidar from "chokidar";
12
13
  import { WebSocket, WebSocketServer } from "ws";
13
14
  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,51 +71,16 @@ 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,
75
- guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
74
+ createReadReplicaConnection: () => createReadReplicaConnection
76
75
  });
77
76
  var DEFAULT_POOL = {
78
77
  max: 20,
79
78
  idleTimeoutMillis: 3e4,
80
79
  connectionTimeoutMillis: 1e4,
81
- queryTimeout: 6e4,
80
+ queryTimeout: 3e4,
82
81
  statementTimeout: 3e4,
83
82
  keepAlive: true
84
83
  };
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
- }
119
84
  /**
120
85
  * Create a Drizzle-backed Postgres connection with a production-grade
121
86
  * connection pool.
@@ -147,7 +112,6 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
147
112
  logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
148
113
  if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
149
114
  });
150
- guardPoolAgainstDirtyRelease(pool, "pg-pool");
151
115
  return {
152
116
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
153
117
  pool,
@@ -180,7 +144,6 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
180
144
  pool.on("error", (err) => {
181
145
  logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
182
146
  });
183
- guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
184
147
  return {
185
148
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
186
149
  pool,
@@ -210,7 +173,6 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
210
173
  pool.on("error", (err) => {
211
174
  logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
212
175
  });
213
- guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
214
176
  return {
215
177
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
216
178
  pool,
@@ -1540,140 +1502,6 @@ function removeFunctions(o) {
1540
1502
  return o;
1541
1503
  }
1542
1504
  //#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
1677
1505
  //#region ../utils/src/names.ts
1678
1506
  /**
1679
1507
  * Generates a foreign key column name from a given string, typically a collection slug or name.
@@ -1798,109 +1626,6 @@ function createRelationRefWithData(id, path, data) {
1798
1626
  data
1799
1627
  };
1800
1628
  }
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
- }
1904
1629
  //#endregion
1905
1630
  //#region ../common/src/util/enums.ts
1906
1631
  function enumToObjectEntries(enumValues) {
@@ -2559,307 +2284,6 @@ var buildPropertyCallbacks = (properties) => {
2559
2284
  };
2560
2285
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
2561
2286
  };
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
- }
2863
2287
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
2864
2288
  (function(root, factory) {
2865
2289
  if (typeof define === "function" && define.amd) define(factory);
@@ -4129,45 +3553,18 @@ function deserializeFilter(query) {
4129
3553
  }
4130
3554
  //#endregion
4131
3555
  //#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
- }
4152
3556
  /**
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.
3557
+ * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
3558
+ * Mirrors the client SDK's rowToEntity conversion.
4162
3559
  */
4163
- function rowToEntity(row, slug, primaryKeys = []) {
3560
+ function rowToEntity(row, slug) {
4164
3561
  return {
4165
- id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
3562
+ id: row.id,
4166
3563
  path: slug,
4167
3564
  values: row
4168
3565
  };
4169
3566
  }
4170
- function createDriverAccessor(driver, slug, getPks = () => []) {
3567
+ function createDriverAccessor(driver, slug) {
4171
3568
  const accessor = {
4172
3569
  async find(params) {
4173
3570
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
@@ -4200,7 +3597,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4200
3597
  hasMore = offset + rows.length < total;
4201
3598
  }
4202
3599
  return {
4203
- data: rows.map((row) => rowToEntity(row, slug, getPks())),
3600
+ data: rows.map((row) => rowToEntity(row, slug)),
4204
3601
  meta: {
4205
3602
  total,
4206
3603
  limit,
@@ -4214,7 +3611,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4214
3611
  path: slug,
4215
3612
  id
4216
3613
  });
4217
- return row ? rowToEntity(row, slug, getPks()) : void 0;
3614
+ return row ? rowToEntity(row, slug) : void 0;
4218
3615
  },
4219
3616
  async create(data, id) {
4220
3617
  return rowToEntity(await driver.save({
@@ -4222,22 +3619,15 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4222
3619
  values: data,
4223
3620
  id,
4224
3621
  status: "new"
4225
- }), slug, getPks());
3622
+ }), slug);
4226
3623
  },
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,
4234
3624
  async update(id, data) {
4235
3625
  return rowToEntity(await driver.save({
4236
3626
  path: slug,
4237
3627
  values: data,
4238
3628
  id,
4239
3629
  status: "existing"
4240
- }), slug, getPks());
3630
+ }), slug);
4241
3631
  },
4242
3632
  async delete(id) {
4243
3633
  return driver.delete({ row: {
@@ -4266,7 +3656,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4266
3656
  searchString: params?.searchString,
4267
3657
  onUpdate: (entities) => {
4268
3658
  onUpdate({
4269
- data: entities.map((row) => rowToEntity(row, slug, getPks())),
3659
+ data: entities.map((row) => rowToEntity(row, slug)),
4270
3660
  meta: {
4271
3661
  total: entities.length,
4272
3662
  limit,
@@ -4282,7 +3672,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4282
3672
  return driver.listenOne({
4283
3673
  path: slug,
4284
3674
  id,
4285
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
3675
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
4286
3676
  onError
4287
3677
  });
4288
3678
  } : void 0,
@@ -4321,13 +3711,12 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4321
3711
  * await data.products.create({ name: "Camera", price: 299 });
4322
3712
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
4323
3713
  */
4324
- function buildRebaseData(driver, options) {
3714
+ function buildRebaseData(driver) {
4325
3715
  const cache = /* @__PURE__ */ new Map();
4326
- const primaryKeysFor = createPrimaryKeyResolver(options);
4327
3716
  function getAccessor(slug) {
4328
3717
  let accessor = cache.get(slug);
4329
3718
  if (!accessor) {
4330
- accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
3719
+ accessor = createDriverAccessor(driver, slug);
4331
3720
  cache.set(slug, accessor);
4332
3721
  }
4333
3722
  return accessor;
@@ -4340,9 +3729,8 @@ function buildRebaseData(driver, options) {
4340
3729
  } });
4341
3730
  }
4342
3731
  /**
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.
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.
4346
3734
  */
4347
3735
  function entityToRow(entity) {
4348
3736
  return entity.values;
@@ -4429,12 +3817,6 @@ function toSdkCollectionClient(snap) {
4429
3817
  async create(data, id) {
4430
3818
  return entityToRow(await snap.create(data, id));
4431
3819
  },
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
- },
4438
3820
  async update(id, data) {
4439
3821
  return entityToRow(await snap.update(id, data));
4440
3822
  },
@@ -4587,26 +3969,8 @@ function getTableForCollection(collection, registry) {
4587
3969
  if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
4588
3970
  return table;
4589
3971
  }
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
- */
4609
3972
  function getPrimaryKeys(collection, registry) {
3973
+ const table = getTableForCollection(collection, registry);
4610
3974
  if (collection.properties) {
4611
3975
  const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
4612
3976
  fieldName: key,
@@ -4615,8 +3979,6 @@ function getPrimaryKeys(collection, registry) {
4615
3979
  }));
4616
3980
  if (idProps.length > 0) return idProps;
4617
3981
  }
4618
- const table = registry.getTable(getTableName$1(collection));
4619
- if (!table) return [];
4620
3982
  const keys = [];
4621
3983
  for (const [key, colRaw] of Object.entries(table)) {
4622
3984
  const col = colRaw;
@@ -4644,89 +4006,35 @@ function getPrimaryKeys(collection, registry) {
4644
4006
  }
4645
4007
  return keys;
4646
4008
  }
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
- });
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;
4697
4020
  }
4698
- return findings;
4699
- }
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");
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;
4031
+ }
4032
+ return result;
4715
4033
  }
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 "";
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(":::");
4730
4038
  }
4731
4039
  //#endregion
4732
4040
  //#region src/utils/drizzle-conditions.ts
@@ -5266,7 +4574,6 @@ var DrizzleConditionBuilder = class {
5266
4574
  static buildVectorSearchConditions(table, vectorSearch) {
5267
4575
  const column = table[vectorSearch.property];
5268
4576
  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");
5270
4577
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
5271
4578
  const distanceFn = vectorSearch.distance || "cosine";
5272
4579
  let operator;
@@ -5350,10 +4657,12 @@ function serializeDataToServer(row, properties, collection, registry) {
5350
4657
  continue;
5351
4658
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
5352
4659
  const serializedValue = serializePropertyToServer(effectiveValue, property);
4660
+ const pks = getPrimaryKeys(collection, registry);
5353
4661
  inverseRelationUpdates.push({
5354
4662
  relationKey: key,
5355
4663
  relation,
5356
- newValue: serializedValue
4664
+ newValue: serializedValue,
4665
+ currentId: row.id || buildCompositeId(row, pks)
5357
4666
  });
5358
4667
  continue;
5359
4668
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
@@ -5363,11 +4672,15 @@ function serializeDataToServer(row, properties, collection, registry) {
5363
4672
  relation,
5364
4673
  newTargetId: serializedValue
5365
4674
  });
5366
- else inverseRelationUpdates.push({
5367
- relationKey: key,
5368
- relation,
5369
- newValue: serializedValue
5370
- });
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
+ }
5371
4684
  continue;
5372
4685
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
5373
4686
  const serializedValue = serializePropertyToServer(effectiveValue, property);
@@ -5767,63 +5080,6 @@ var RelationService = class {
5767
5080
  this.registry = registry;
5768
5081
  }
5769
5082
  /**
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
- /**
5827
5083
  * Fetch rows related to a parent row through a specific relation
5828
5084
  */
5829
5085
  async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
@@ -5842,9 +5098,9 @@ var RelationService = class {
5842
5098
  async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
5843
5099
  const targetCollection = relation.target();
5844
5100
  const targetTable = getTableForCollection(targetCollection, this.registry);
5845
- const idInfo = requirePrimaryKeys(targetCollection, this.registry);
5101
+ const idInfo = getPrimaryKeys(targetCollection, this.registry);
5846
5102
  const idField = targetTable[idInfo[0].fieldName];
5847
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5103
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
5848
5104
  const parentIdInfo = parentPks[0];
5849
5105
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5850
5106
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5868,7 +5124,7 @@ var RelationService = class {
5868
5124
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5869
5125
  currentTable = joinTable;
5870
5126
  }
5871
- const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
5127
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5872
5128
  query = query.where(eq(parentIdField, parsedParentId));
5873
5129
  if (options.limit) query = query.limit(options.limit);
5874
5130
  const results = await query;
@@ -5876,7 +5132,13 @@ var RelationService = class {
5876
5132
  const rows = [];
5877
5133
  for (const row of results) {
5878
5134
  const targetRow = row[targetTableName] || row;
5879
- rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
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
+ });
5880
5142
  }
5881
5143
  return rows;
5882
5144
  }
@@ -5895,7 +5157,13 @@ var RelationService = class {
5895
5157
  const rows = [];
5896
5158
  for (const row of results) {
5897
5159
  const targetRow = row[getTableName$1(targetCollection)] || row;
5898
- rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
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
+ });
5899
5167
  }
5900
5168
  return rows;
5901
5169
  }
@@ -5912,8 +5180,8 @@ var RelationService = class {
5912
5180
  }
5913
5181
  const targetCollection = relation.target();
5914
5182
  const targetTable = getTableForCollection(targetCollection, this.registry);
5915
- const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
5916
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5183
+ const targetIdField = targetTable[getPrimaryKeys(targetCollection, this.registry)[0].fieldName];
5184
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
5917
5185
  const parentIdInfo = parentPks[0];
5918
5186
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5919
5187
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5932,10 +5200,9 @@ var RelationService = class {
5932
5200
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5933
5201
  const targetCollection = relation.target();
5934
5202
  const targetTable = getTableForCollection(targetCollection, this.registry);
5935
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5936
- const targetIdInfo = targetPks[0];
5203
+ const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5937
5204
  const targetIdField = targetTable[targetIdInfo.fieldName];
5938
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5205
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
5939
5206
  const parentIdInfo = parentPks[0];
5940
5207
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5941
5208
  if (!parentTable) throw new Error("Parent table not found");
@@ -5959,19 +5226,25 @@ var RelationService = class {
5959
5226
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5960
5227
  currentTable = joinTable;
5961
5228
  }
5962
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5229
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5230
+ query = query.where(inArray(parentIdField, parsedParentIds));
5963
5231
  const results = await query;
5964
5232
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5965
5233
  const resultMap = /* @__PURE__ */ new Map();
5966
5234
  for (const row of results) {
5967
5235
  const parentRow = row[getTableName$1(parentCollection)] || row;
5968
5236
  const targetRow = row[targetTableName] || row;
5969
- resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
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
+ });
5970
5244
  }
5971
5245
  return resultMap;
5972
5246
  }
5973
5247
  if (relation.direction === "owning" && relation.localKey) {
5974
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
5975
5248
  const localKeyCol = parentTable[relation.localKey];
5976
5249
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
5977
5250
  const fkRows = await this.db.select({
@@ -6000,11 +5273,17 @@ var RelationService = class {
6000
5273
  const resultMap = /* @__PURE__ */ new Map();
6001
5274
  for (const [parentIdStr, fkValue] of parentToFk) {
6002
5275
  const targetRow = targetById.get(String(fkValue));
6003
- if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
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
+ }
6004
5284
  }
6005
5285
  return resultMap;
6006
5286
  }
6007
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
6008
5287
  let query = this.db.select().from(targetTable).$dynamic();
6009
5288
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
6010
5289
  const results = await query;
@@ -6015,7 +5294,14 @@ var RelationService = class {
6015
5294
  let parentId;
6016
5295
  if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
6017
5296
  else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
6018
- if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
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
+ }
6019
5305
  }
6020
5306
  return resultMap;
6021
5307
  }
@@ -6029,9 +5315,9 @@ var RelationService = class {
6029
5315
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
6030
5316
  const targetCollection = relation.target();
6031
5317
  const targetTable = getTableForCollection(targetCollection, this.registry);
6032
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6033
- const targetIdField = targetTable[targetPks[0].fieldName];
6034
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5318
+ const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5319
+ const targetIdField = targetTable[targetIdInfo.fieldName];
5320
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
6035
5321
  const parentIdInfo = parentPks[0];
6036
5322
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
6037
5323
  if (!parentTable) throw new Error("Parent table not found");
@@ -6053,22 +5339,27 @@ var RelationService = class {
6053
5339
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
6054
5340
  currentTable = joinTable;
6055
5341
  }
6056
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5342
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5343
+ query = query.where(inArray(parentIdField, parsedParentIds));
6057
5344
  const results = await query;
6058
5345
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
6059
5346
  const resultMap = /* @__PURE__ */ new Map();
6060
5347
  for (const row of results) {
6061
5348
  const parentRow = row[getTableName$1(parentCollection)] || row;
6062
5349
  const targetRow = row[targetTableName] || row;
6063
- const parentId = buildCompositeId(parentRow, parentPks);
5350
+ const parentId = String(parentRow[parentIdInfo.fieldName]);
5351
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6064
5352
  const arr = resultMap.get(parentId) || [];
6065
- arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5353
+ arr.push({
5354
+ id: String(targetRow[targetIdInfo.fieldName]),
5355
+ path: targetCollection.slug,
5356
+ values: parsedValues
5357
+ });
6066
5358
  resultMap.set(parentId, arr);
6067
5359
  }
6068
5360
  return resultMap;
6069
5361
  }
6070
5362
  if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
6071
- this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
6072
5363
  const junctionTable = this.registry.getTable(relation.through.table);
6073
5364
  if (!junctionTable) {
6074
5365
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -6087,13 +5378,17 @@ var RelationService = class {
6087
5378
  const junctionData = row[relation.through.table] || row;
6088
5379
  const targetData = row[targetTableName] || row;
6089
5380
  const parentId = String(junctionData[relation.through.sourceColumn]);
5381
+ const parsedValues = await parseDataFromServer(targetData, targetCollection);
6090
5382
  const arr = resultMap.get(parentId) || [];
6091
- arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
5383
+ arr.push({
5384
+ id: String(targetData[targetIdInfo.fieldName]),
5385
+ path: targetCollection.slug,
5386
+ values: parsedValues
5387
+ });
6092
5388
  resultMap.set(parentId, arr);
6093
5389
  }
6094
5390
  return resultMap;
6095
5391
  }
6096
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
6097
5392
  let query = this.db.select().from(targetTable).$dynamic();
6098
5393
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
6099
5394
  const results = await query;
@@ -6106,9 +5401,14 @@ var RelationService = class {
6106
5401
  else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
6107
5402
  else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
6108
5403
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5404
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6109
5405
  const key = String(parentId);
6110
5406
  const arr = resultMap.get(key) || [];
6111
- arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5407
+ arr.push({
5408
+ id: String(targetRow[targetIdInfo.fieldName]),
5409
+ path: targetCollection.slug,
5410
+ values: parsedValues
5411
+ });
6112
5412
  resultMap.set(key, arr);
6113
5413
  }
6114
5414
  }
@@ -6156,12 +5456,12 @@ var RelationService = class {
6156
5456
  logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
6157
5457
  continue;
6158
5458
  }
6159
- const parentPks = requirePrimaryKeys(collection, this.registry);
5459
+ const parentPks = getPrimaryKeys(collection, this.registry);
6160
5460
  const parentIdInfo = parentPks[0];
6161
5461
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6162
5462
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6163
5463
  if (targetEntityIds.length > 0) {
6164
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5464
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6165
5465
  const targetIdInfo = targetPks[0];
6166
5466
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6167
5467
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6181,12 +5481,12 @@ var RelationService = class {
6181
5481
  logger.warn(`Junction columns not found for relation '${key}'`);
6182
5482
  continue;
6183
5483
  }
6184
- const parentPks = requirePrimaryKeys(collection, this.registry);
5484
+ const parentPks = getPrimaryKeys(collection, this.registry);
6185
5485
  const parentIdInfo = parentPks[0];
6186
5486
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6187
5487
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6188
5488
  if (targetEntityIds.length > 0) {
6189
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5489
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6190
5490
  const targetIdInfo = targetPks[0];
6191
5491
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6192
5492
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6197,7 +5497,7 @@ var RelationService = class {
6197
5497
  } 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.`);
6198
5498
  else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
6199
5499
  const targetTable = getTableForCollection(targetCollection, this.registry);
6200
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5500
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6201
5501
  const targetIdInfo = targetPks[0];
6202
5502
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6203
5503
  const fkCol = targetTable[relation.foreignKeyOnTarget];
@@ -6205,7 +5505,7 @@ var RelationService = class {
6205
5505
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
6206
5506
  continue;
6207
5507
  }
6208
- const parentPks = requirePrimaryKeys(collection, this.registry);
5508
+ const parentPks = getPrimaryKeys(collection, this.registry);
6209
5509
  const parentIdInfo = parentPks[0];
6210
5510
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6211
5511
  if (targetEntityIds.length > 0) {
@@ -6225,9 +5525,9 @@ var RelationService = class {
6225
5525
  try {
6226
5526
  const targetCollection = relation.target();
6227
5527
  const targetTable = getTableForCollection(targetCollection, this.registry);
6228
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5528
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6229
5529
  const targetIdInfo = targetPks[0];
6230
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5530
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6231
5531
  const sourceIdInfo = sourcePks[0];
6232
5532
  if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
6233
5533
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
@@ -6308,12 +5608,12 @@ var RelationService = class {
6308
5608
  logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
6309
5609
  return;
6310
5610
  }
6311
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5611
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6312
5612
  const sourceIdInfo = sourcePks[0];
6313
5613
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6314
5614
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6315
5615
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6316
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5616
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6317
5617
  const targetIdInfo = targetPks[0];
6318
5618
  const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6319
5619
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6321,7 +5621,7 @@ var RelationService = class {
6321
5621
  }));
6322
5622
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
6323
5623
  } else if (newValue && !Array.isArray(newValue)) {
6324
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5624
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6325
5625
  const targetIdInfo = targetPks[0];
6326
5626
  const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
6327
5627
  const newLink = {
@@ -6352,12 +5652,12 @@ var RelationService = class {
6352
5652
  logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
6353
5653
  return;
6354
5654
  }
6355
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5655
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6356
5656
  const sourceIdInfo = sourcePks[0];
6357
5657
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6358
5658
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6359
5659
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6360
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5660
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6361
5661
  const targetIdInfo = targetPks[0];
6362
5662
  const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6363
5663
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6378,12 +5678,12 @@ var RelationService = class {
6378
5678
  const { relation, newTargetId } = upd;
6379
5679
  const targetCollection = relation.target();
6380
5680
  const targetTable = getTableForCollection(targetCollection, this.registry);
6381
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5681
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6382
5682
  const targetIdInfo = targetPks[0];
6383
5683
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6384
5684
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
6385
5685
  const parentTable = getTableForCollection(parentCollection, this.registry);
6386
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5686
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
6387
5687
  const parentIdInfo = parentPks[0];
6388
5688
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
6389
5689
  const parentIdCol = parentTable[parentIdInfo.fieldName];
@@ -6454,7 +5754,7 @@ var RelationService = class {
6454
5754
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
6455
5755
  return;
6456
5756
  }
6457
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5757
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6458
5758
  const targetIdInfo = targetPks[0];
6459
5759
  const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
6460
5760
  const junctionData = {
@@ -6470,150 +5770,6 @@ var RelationService = class {
6470
5770
  }
6471
5771
  };
6472
5772
  //#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
6617
5773
  //#region src/services/FetchService.ts
6618
5774
  /**
6619
5775
  * Service for handling all row read operations.
@@ -6672,7 +5828,7 @@ var FetchService = class {
6672
5828
  if (!shouldInclude(key)) continue;
6673
5829
  const drizzleRelName = relation.relationName || key;
6674
5830
  if (relation.joinPath && relation.joinPath.length > 0) continue;
6675
- if (relation.cardinality === "many" && isJunctionRelation(relation)) {
5831
+ if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
6676
5832
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
6677
5833
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
6678
5834
  else withConfig[drizzleRelName] = true;
@@ -6681,6 +5837,14 @@ var FetchService = class {
6681
5837
  return withConfig;
6682
5838
  }
6683
5839
  /**
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
+ /**
6684
5848
  * Get the Drizzle relation name on the junction table that points to the actual target row.
6685
5849
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
6686
5850
  */
@@ -6689,6 +5853,55 @@ var FetchService = class {
6689
5853
  return null;
6690
5854
  }
6691
5855
  /**
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
+ /**
6692
5905
  * Post-fetch joinPath relations for a single flat row.
6693
5906
  * joinPath relations cannot be expressed via Drizzle's `with` config,
6694
5907
  * so they must be loaded separately after the primary query.
@@ -6709,6 +5922,31 @@ var FetchService = class {
6709
5922
  await Promise.all(promises);
6710
5923
  }
6711
5924
  /**
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
+ /**
6712
5950
  * Resolves joinPath relations for raw REST rows and directly injects them.
6713
5951
  * Uses RelationService to query the database and maps results back to the flattened objects.
6714
5952
  */
@@ -6719,29 +5957,72 @@ var FetchService = class {
6719
5957
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
6720
5958
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
6721
5959
  if (joinPathRelations.length === 0) return;
6722
- const parentIdOf = (row) => {
6723
- const address = buildCompositeId(row, idInfoArray);
6724
- return address && address.split(":::").some((part) => part !== "") ? address : void 0;
6725
- };
5960
+ const idInfo = idInfoArray[0];
6726
5961
  for (const [key, relation] of joinPathRelations) try {
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));
5962
+ const rowIds = rows.map((r) => {
5963
+ return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
5964
+ });
6730
5965
  if (relation.cardinality === "one") {
6731
5966
  const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
6732
- for (const row of addressable) {
6733
- const relatedRow = resultMap.get(String(parentIdOf(row)));
6734
- row[key] = relatedRow ? { ...relatedRow.values } : null;
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;
6735
5975
  }
6736
5976
  } else if (relation.cardinality === "many") {
6737
5977
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
6738
- for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
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
+ }
6739
5985
  }
6740
5986
  } catch (e) {
6741
5987
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
6742
5988
  }
6743
5989
  }
6744
5990
  /**
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
+ /**
6745
6026
  * Build db.query-compatible options from standard fetch options.
6746
6027
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
6747
6028
  */
@@ -6811,7 +6092,7 @@ var FetchService = class {
6811
6092
  async fetchOne(collectionPath, id, databaseId) {
6812
6093
  const collection = getCollectionByPath(collectionPath, this.registry);
6813
6094
  const table = getTableForCollection(collection, this.registry);
6814
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6095
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
6815
6096
  const idInfo = idInfoArray[0];
6816
6097
  const idField = table[idInfo.fieldName];
6817
6098
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6825,7 +6106,7 @@ var FetchService = class {
6825
6106
  with: withConfig
6826
6107
  });
6827
6108
  if (!row) return void 0;
6828
- const flatRow = toCmsRow(row, collection, this.registry);
6109
+ const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
6829
6110
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6830
6111
  return flatRow;
6831
6112
  } catch (e) {
@@ -6867,7 +6148,7 @@ var FetchService = class {
6867
6148
  async fetchRowsWithConditions(collectionPath, options = {}) {
6868
6149
  const collection = getCollectionByPath(collectionPath, this.registry);
6869
6150
  const table = getTableForCollection(collection, this.registry);
6870
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6151
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
6871
6152
  const idInfo = idInfoArray[0];
6872
6153
  const idField = table[idInfo.fieldName];
6873
6154
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6877,7 +6158,7 @@ var FetchService = class {
6877
6158
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6878
6159
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6879
6160
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6880
- return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
6161
+ return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray));
6881
6162
  } catch (e) {
6882
6163
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6883
6164
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6938,10 +6219,8 @@ var FetchService = class {
6938
6219
  }
6939
6220
  /**
6940
6221
  * Fallback path used when db.query is unavailable.
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.
6222
+ * The primary path uses drizzleResultToRow which handles relation
6223
+ * mapping without N+1 queries.
6945
6224
  *
6946
6225
  * Process raw database results into flat rows with relations.
6947
6226
  */
@@ -6950,7 +6229,8 @@ var FetchService = class {
6950
6229
  const parsedRows = await Promise.all(results.map(async (rawRow) => {
6951
6230
  return {
6952
6231
  rawRow,
6953
- values: await parseDataFromServer(rawRow, collection)
6232
+ values: await parseDataFromServer(rawRow, collection),
6233
+ id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
6954
6234
  };
6955
6235
  }));
6956
6236
  if (!skipRelations) {
@@ -6990,7 +6270,10 @@ var FetchService = class {
6990
6270
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
6991
6271
  }
6992
6272
  }
6993
- return parsedRows.map((item) => item.values);
6273
+ return parsedRows.map((item) => ({
6274
+ ...item.values,
6275
+ id: item.id
6276
+ }));
6994
6277
  }
6995
6278
  /**
6996
6279
  * Fetch a collection of rows
@@ -7021,7 +6304,10 @@ var FetchService = class {
7021
6304
  const relationKey = pathSegments[i];
7022
6305
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
7023
6306
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
7024
- if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
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
+ }));
7025
6311
  if (i + 1 < pathSegments.length) {
7026
6312
  const nextEntityId = pathSegments[i + 1];
7027
6313
  currentCollection = relation.target();
@@ -7083,7 +6369,7 @@ var FetchService = class {
7083
6369
  if (value === void 0 || value === null) return true;
7084
6370
  const collection = getCollectionByPath(collectionPath, this.registry);
7085
6371
  const table = getTableForCollection(collection, this.registry);
7086
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6372
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
7087
6373
  const idInfo = idInfoArray[0];
7088
6374
  const idField = table[idInfo.fieldName];
7089
6375
  const field = table[fieldName];
@@ -7110,7 +6396,7 @@ var FetchService = class {
7110
6396
  async fetchCollectionForRest(collectionPath, options = {}, include) {
7111
6397
  const collection = getCollectionByPath(collectionPath, this.registry);
7112
6398
  const table = getTableForCollection(collection, this.registry);
7113
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6399
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
7114
6400
  const idInfo = idInfoArray[0];
7115
6401
  const idField = table[idInfo.fieldName];
7116
6402
  const tableName = getTableName(table);
@@ -7118,7 +6404,7 @@ var FetchService = class {
7118
6404
  if (qb && !options.searchString && !options.vectorSearch) try {
7119
6405
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
7120
6406
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
7121
- const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
6407
+ const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray));
7122
6408
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
7123
6409
  return restRows;
7124
6410
  } catch (e) {
@@ -7129,7 +6415,10 @@ var FetchService = class {
7129
6415
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
7130
6416
  }
7131
6417
  const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
7132
- if (!include || include.length === 0) return rows;
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
+ }));
7133
6422
  const resolvedRelations = resolveCollectionRelations(collection);
7134
6423
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
7135
6424
  const shouldInclude = (key) => include[0] === "*" || include.includes(key);
@@ -7141,7 +6430,10 @@ var FetchService = class {
7141
6430
  for (const row of rows) {
7142
6431
  const eid = row[idInfo.fieldName];
7143
6432
  const related = batchResults.get(String(eid));
7144
- if (related) row[key] = { ...related.values };
6433
+ if (related) row[key] = {
6434
+ ...related.values,
6435
+ id: related.id
6436
+ };
7145
6437
  }
7146
6438
  } catch (e) {
7147
6439
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
@@ -7159,7 +6451,10 @@ var FetchService = class {
7159
6451
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
7160
6452
  }
7161
6453
  }
7162
- return rows;
6454
+ return rows.map((row) => ({
6455
+ ...row,
6456
+ id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6457
+ }));
7163
6458
  }
7164
6459
  /**
7165
6460
  * Fetch a single row with optional relation includes for REST API.
@@ -7167,7 +6462,7 @@ var FetchService = class {
7167
6462
  async fetchOneForRest(collectionPath, id, include, databaseId) {
7168
6463
  const collection = getCollectionByPath(collectionPath, this.registry);
7169
6464
  const table = getTableForCollection(collection, this.registry);
7170
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6465
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
7171
6466
  const idInfo = idInfoArray[0];
7172
6467
  const idField = table[idInfo.fieldName];
7173
6468
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
@@ -7180,7 +6475,7 @@ var FetchService = class {
7180
6475
  ...withConfig ? { with: withConfig } : {}
7181
6476
  });
7182
6477
  if (!row) return null;
7183
- const restRow = toRestRow(row, collection, this.registry);
6478
+ const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
7184
6479
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
7185
6480
  return restRow;
7186
6481
  } catch (e) {
@@ -7192,7 +6487,11 @@ var FetchService = class {
7192
6487
  }
7193
6488
  const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
7194
6489
  if (result.length === 0) return null;
7195
- const flatEntity = { ...result[0] };
6490
+ const raw = result[0];
6491
+ const flatEntity = {
6492
+ ...raw,
6493
+ id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
6494
+ };
7196
6495
  if (!include || include.length === 0) return flatEntity;
7197
6496
  const resolvedRelations = resolveCollectionRelations(collection);
7198
6497
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
@@ -7225,7 +6524,7 @@ var FetchService = class {
7225
6524
  async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
7226
6525
  const collection = getCollectionByPath(collectionPath, this.registry);
7227
6526
  const table = getTableForCollection(collection, this.registry);
7228
- const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
6527
+ const idField = table[getPrimaryKeys(collection, this.registry)[0].fieldName];
7229
6528
  let vectorMeta;
7230
6529
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
7231
6530
  let query = vectorMeta ? this.db.select({
@@ -7304,15 +6603,32 @@ var FetchService = class {
7304
6603
  if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
7305
6604
  }
7306
6605
  return (await queryTarget.findMany(queryOpts)).map((row) => {
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;
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
+ }
7316
6632
  return flat;
7317
6633
  });
7318
6634
  } catch (e) {
@@ -7573,14 +6889,8 @@ var PersistService = class {
7573
6889
  }
7574
6890
  /**
7575
6891
  * 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.
7582
6892
  */
7583
- async save(collectionPath, values, id, databaseId, options) {
6893
+ async save(collectionPath, values, id, databaseId) {
7584
6894
  let effectiveCollectionPath = collectionPath;
7585
6895
  const effectiveValues = { ...values };
7586
6896
  let junctionTableInfo;
@@ -7671,7 +6981,7 @@ var PersistService = class {
7671
6981
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
7672
6982
  savedId = await this.db.transaction(async (tx) => {
7673
6983
  let currentId;
7674
- if (id && !options?.upsert) {
6984
+ if (id) {
7675
6985
  currentId = id;
7676
6986
  const idValues = parseIdValues(id, idInfoArray);
7677
6987
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
@@ -7686,24 +6996,9 @@ var PersistService = class {
7686
6996
  }
7687
6997
  } else {
7688
6998
  const dataForInsert = { ...entityData };
7689
- if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
7690
6999
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
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);
7000
+ const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
7001
+ currentId = buildCompositeId(resultRow, idInfoArray);
7707
7002
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
7708
7003
  }
7709
7004
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
@@ -7714,7 +7009,7 @@ var PersistService = class {
7714
7009
  } catch (error) {
7715
7010
  throw this.toUserFriendlyError(error, collection.slug);
7716
7011
  }
7717
- const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
7012
+ const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
7718
7013
  if (!finalEntity) throw new Error("Could not fetch row after save.");
7719
7014
  return finalEntity;
7720
7015
  }
@@ -7812,8 +7107,8 @@ var DataService = class {
7812
7107
  /**
7813
7108
  * Save an row (create or update)
7814
7109
  */
7815
- async save(collectionPath, values, id, databaseId, options) {
7816
- return this.persistService.save(collectionPath, values, id, databaseId, options);
7110
+ async save(collectionPath, values, id, databaseId) {
7111
+ return this.persistService.save(collectionPath, values, id, databaseId);
7817
7112
  }
7818
7113
  /**
7819
7114
  * Delete an row by ID
@@ -7881,26 +7176,6 @@ var DataService = class {
7881
7176
  */
7882
7177
  /** Internal prefix applied to branch database names to avoid collisions. */
7883
7178
  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
- }
7904
7179
  /** Fully-qualified metadata table in the rebase schema. */
7905
7180
  var BRANCHES_TABLE = "rebase.branches";
7906
7181
  /**
@@ -7969,8 +7244,10 @@ var BranchService = class {
7969
7244
  try {
7970
7245
  await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
7971
7246
  } catch (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);
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;
7974
7251
  }
7975
7252
  const now = /* @__PURE__ */ new Date();
7976
7253
  await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
@@ -7995,8 +7272,8 @@ var BranchService = class {
7995
7272
  try {
7996
7273
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
7997
7274
  } catch (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);
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;
8000
7277
  }
8001
7278
  await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
8002
7279
  }
@@ -8628,19 +7905,17 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8628
7905
  this.realtimeService.subscriptions.delete(subscriptionId);
8629
7906
  };
8630
7907
  }
8631
- async save({ path, id, values, collection, status, upsert }) {
7908
+ async save({ path, id, values, collection, status }) {
8632
7909
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
8633
7910
  let updatedValues = values;
8634
7911
  const contextForCallback = this.buildCallContext();
8635
7912
  let previousValuesForHistory;
8636
- if (status === "existing" && id) try {
8637
- const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
7913
+ if (status === "existing" && id) {
7914
+ const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8638
7915
  if (existing) {
8639
7916
  const { id: _existingId, ...existingValues } = existing;
8640
7917
  previousValuesForHistory = existingValues;
8641
7918
  }
8642
- } catch (err) {
8643
- logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
8644
7919
  }
8645
7920
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
8646
7921
  if (globalCallbacks?.beforeSave) {
@@ -8687,7 +7962,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8687
7962
  timestampNowValue: /* @__PURE__ */ new Date()
8688
7963
  });
8689
7964
  try {
8690
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
7965
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
8691
7966
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
8692
7967
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
8693
7968
  collection: resolvedCollection,
@@ -8708,8 +7983,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8708
7983
  context: contextForCallback
8709
7984
  });
8710
7985
  }
8711
- const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
8712
- const savedValues = savedRow;
7986
+ const savedId = savedRow.id;
7987
+ const { id: _savedId, ...savedValues } = savedRow;
8713
7988
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
8714
7989
  if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
8715
7990
  collection: resolvedCollection,
@@ -8741,7 +8016,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8741
8016
  }
8742
8017
  if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
8743
8018
  tableName: path,
8744
- id: savedId,
8019
+ id: savedId.toString(),
8745
8020
  action: status === "new" ? "create" : "update",
8746
8021
  values: savedValues,
8747
8022
  previousValues: previousValuesForHistory,
@@ -8749,11 +8024,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8749
8024
  });
8750
8025
  if (this._deferNotifications) this._pendingNotifications.push({
8751
8026
  path,
8752
- id: savedId,
8027
+ id: savedId.toString(),
8753
8028
  row: savedRow,
8754
8029
  databaseId: resolvedCollection?.databaseId
8755
8030
  });
8756
- else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
8031
+ else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
8757
8032
  return savedRow;
8758
8033
  } catch (error) {
8759
8034
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
@@ -8788,52 +8063,12 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8788
8063
  throw error;
8789
8064
  }
8790
8065
  }
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
- }
8834
8066
  async delete({ row, collection }) {
8835
8067
  const targetPath = row.path;
8836
- const targetRow = { ...row.values ?? {} };
8068
+ const targetRow = {
8069
+ id: row.id,
8070
+ ...row.values ?? {}
8071
+ };
8837
8072
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
8838
8073
  const contextForCallback = this.buildCallContext();
8839
8074
  if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -9192,18 +8427,6 @@ var AuthenticatedPostgresBackendDriver = class {
9192
8427
  async save(props) {
9193
8428
  return this.withTransaction((delegate) => delegate.save(props));
9194
8429
  }
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
- }
9207
8430
  async delete(props) {
9208
8431
  return this.withTransaction((delegate) => delegate.delete(props));
9209
8432
  }
@@ -9253,7 +8476,6 @@ var DatabasePoolManager = class {
9253
8476
  pool.on("error", (err) => {
9254
8477
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
9255
8478
  });
9256
- guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
9257
8479
  this.pools.set(databaseName, pool);
9258
8480
  return pool;
9259
8481
  }
@@ -9451,6 +8673,105 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
9451
8673
  references: [users.id]
9452
8674
  }) }));
9453
8675
  //#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
9454
8775
  //#region src/schema/generate-drizzle-schema-logic.ts
9455
8776
  /**
9456
8777
  * Resolve the SQL column name for a property.
@@ -9648,12 +8969,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
9648
8969
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
9649
8970
  */
9650
8971
  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
+ };
9651
8991
  var generatePolicyCode = (collection, rule, index, resolveCollection) => {
9652
8992
  const tableName = getTableName$1(collection);
9653
8993
  const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
9654
- const policyNames = getPolicyNamesForRule(rule, tableName);
8994
+ const ruleHash = getPolicyNameHash(rule);
9655
8995
  return ops.map((op, opIdx) => {
9656
- return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
8996
+ return generateSinglePolicyCode(collection, rule, op, rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`, resolveCollection);
9657
8997
  }).join("");
9658
8998
  };
9659
8999
  /**
@@ -9782,7 +9122,6 @@ var generateSchema = async (collections, stripPolicies = false) => {
9782
9122
  });
9783
9123
  });
9784
9124
  schemaContent += "\n";
9785
- const junctionSpecs = resolveJunctionSpecs(collections);
9786
9125
  for (const collection of collections) {
9787
9126
  const tableName = getTableName$1(collection);
9788
9127
  if (tableName) allTablesToGenerate.set(tableName, { collection });
@@ -9816,17 +9155,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
9816
9155
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
9817
9156
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
9818
9157
  schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\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";
9158
+ schemaContent += "}, (table) => ({\n";
9159
+ schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
9160
+ schemaContent += "}));\n\n";
9830
9161
  } else if (!isJunction) {
9831
9162
  const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
9832
9163
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -10517,7 +9848,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10517
9848
  startAfter: request.startAfter,
10518
9849
  searchString: request.searchString
10519
9850
  }, authContext);
10520
- this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
9851
+ this.sendCollectionUpdate(clientId, subscriptionId, rows);
10521
9852
  } catch (error) {
10522
9853
  const sanitized = sanitizeErrorForClient(error, request.path);
10523
9854
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10618,7 +9949,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10618
9949
  if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
10619
9950
  else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
10620
9951
  else if (subscription.type === "collection" && subscription.collectionRequest) {
10621
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
9952
+ if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
10622
9953
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
10623
9954
  }
10624
9955
  } catch (error) {
@@ -10648,7 +9979,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10648
9979
  if (!this._subscriptions.has(subscriptionId)) return;
10649
9980
  try {
10650
9981
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
10651
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
9982
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
10652
9983
  } catch (error) {
10653
9984
  const sanitized = sanitizeErrorForClient(error, notifyPath);
10654
9985
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10862,12 +10193,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10862
10193
  }
10863
10194
  return await this.dataService.fetchOne(notifyPath, id);
10864
10195
  }
10865
- sendCollectionUpdate(clientId, subscriptionId, rows, path) {
10196
+ sendCollectionUpdate(clientId, subscriptionId, rows) {
10866
10197
  const message = {
10867
10198
  type: "collection_update",
10868
10199
  subscriptionId,
10869
- rows,
10870
- pks: this.primaryKeysForPath(path)
10200
+ rows
10871
10201
  };
10872
10202
  this.sendMessage(clientId, message);
10873
10203
  }
@@ -10882,33 +10212,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10882
10212
  /**
10883
10213
  * Send a lightweight row-level patch to a collection subscriber.
10884
10214
  * 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.
10890
10215
  */
10891
- sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
10216
+ sendCollectionPatch(clientId, subscriptionId, id, row) {
10892
10217
  const message = {
10893
10218
  type: "collection_patch",
10894
10219
  subscriptionId,
10895
10220
  id,
10896
- row,
10897
- pks: this.primaryKeysForPath(notifyPath)
10221
+ row
10898
10222
  };
10899
10223
  this.sendMessage(clientId, message);
10900
10224
  }
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
- }
10912
10225
  sendError(clientId, error, subscriptionId, code) {
10913
10226
  const message = {
10914
10227
  type: "error",
@@ -11156,7 +10469,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11156
10469
  }
11157
10470
  /** Compute the canonical (possibly composite) id string from a captured row. */
11158
10471
  extractIdFromCdcRow(collection, row) {
11159
- return deriveRowAddress(row, collection, this.registry) || "*";
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 "*";
11160
10478
  }
11161
10479
  dedupKey(path, id, databaseId) {
11162
10480
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -11772,7 +11090,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11772
11090
  } catch (error) {
11773
11091
  logger.error("💥 [WebSocket Server] Error handling message", { error });
11774
11092
  if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
11775
- const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
11093
+ const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error instanceof Error ? error.message : "An unexpected error occurred";
11776
11094
  const errorResponse = {
11777
11095
  type: "ERROR",
11778
11096
  requestId,
@@ -20616,33 +19934,6 @@ function formatBytes(bytes) {
20616
19934
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
20617
19935
  }
20618
19936
  //#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
20646
19937
  //#region src/auth/ensure-tables.ts
20647
19938
  /**
20648
19939
  * Auto-create auth tables if they don't exist.
@@ -20814,22 +20105,14 @@ async function ensureAuthTablesExist(db, collection) {
20814
20105
  $$ LANGUAGE sql STABLE
20815
20106
  `);
20816
20107
  });
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
- `);
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
+ `);
20833
20116
  try {
20834
20117
  if ((await db.execute(sql`
20835
20118
  SELECT EXISTS (
@@ -20900,34 +20183,6 @@ async function ensureAuthTablesExist(db, collection) {
20900
20183
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20901
20184
  ON ${sql.raw(recoveryCodesTableName)}(user_id)
20902
20185
  `);
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
- }
20931
20186
  logger.info("✅ Auth tables ready");
20932
20187
  } catch (error) {
20933
20188
  logger.error("❌ Failed to create auth tables", { error });
@@ -20975,31 +20230,6 @@ var UserService = class {
20975
20230
  const name = getTableName(this.usersTable);
20976
20231
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20977
20232
  }
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
- }
21003
20233
  mapRowToUser(row) {
21004
20234
  if (!row) return row;
21005
20235
  const id = row.id ?? row.uid;
@@ -21097,7 +20327,7 @@ var UserService = class {
21097
20327
  }
21098
20328
  async createUser(data) {
21099
20329
  const payload = this.mapPayload(data);
21100
- const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20330
+ const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21101
20331
  return this.mapRowToUser(row);
21102
20332
  }
21103
20333
  async getUserById(id) {
@@ -21136,12 +20366,12 @@ var UserService = class {
21136
20366
  }));
21137
20367
  }
21138
20368
  async linkUserIdentity(userId, provider, providerId, profileData) {
21139
- await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
20369
+ await this.db.insert(this.userIdentitiesTable).values({
21140
20370
  userId,
21141
20371
  provider,
21142
20372
  providerId,
21143
20373
  profileData: profileData || null
21144
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
20374
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21145
20375
  }
21146
20376
  async updateUser(id, data) {
21147
20377
  const idCol = getColumn(this.usersTable, "id");
@@ -21149,13 +20379,13 @@ var UserService = class {
21149
20379
  const payload = this.mapPayload(data);
21150
20380
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21151
20381
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
21152
- const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
20382
+ const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21153
20383
  return row ? this.mapRowToUser(row) : null;
21154
20384
  }
21155
20385
  async deleteUser(id) {
21156
20386
  const idCol = getColumn(this.usersTable, "id");
21157
20387
  if (!idCol) return;
21158
- await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
20388
+ await this.db.delete(this.usersTable).where(eq(idCol, id));
21159
20389
  }
21160
20390
  async listUsers() {
21161
20391
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -21209,10 +20439,10 @@ var UserService = class {
21209
20439
  if (!idCol) return;
21210
20440
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
21211
20441
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21212
- await this.withServerContext(async (db) => db.update(this.usersTable).set({
20442
+ await this.db.update(this.usersTable).set({
21213
20443
  [passwordHashColKey]: passwordHash,
21214
20444
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21215
- }).where(eq(idCol, id)));
20445
+ }).where(eq(idCol, id));
21216
20446
  }
21217
20447
  /**
21218
20448
  * Set email verification status
@@ -21223,11 +20453,11 @@ var UserService = class {
21223
20453
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
21224
20454
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21225
20455
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21226
- await this.withServerContext(async (db) => db.update(this.usersTable).set({
20456
+ await this.db.update(this.usersTable).set({
21227
20457
  [emailVerifiedColKey]: verified,
21228
20458
  [emailVerificationTokenColKey]: null,
21229
20459
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21230
- }).where(eq(idCol, id)));
20460
+ }).where(eq(idCol, id));
21231
20461
  }
21232
20462
  /**
21233
20463
  * Set email verification token
@@ -21238,11 +20468,11 @@ var UserService = class {
21238
20468
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21239
20469
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
21240
20470
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21241
- await this.withServerContext(async (db) => db.update(this.usersTable).set({
20471
+ await this.db.update(this.usersTable).set({
21242
20472
  [emailVerificationTokenColKey]: token,
21243
20473
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
21244
20474
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21245
- }).where(eq(idCol, id)));
20475
+ }).where(eq(idCol, id));
21246
20476
  }
21247
20477
  /**
21248
20478
  * Find user by email verification token
@@ -21287,22 +20517,22 @@ var UserService = class {
21287
20517
  async setUserRoles(userId, roleIds) {
21288
20518
  const usersTableName = this.getQualifiedUsersTableName();
21289
20519
  const rolesArray = `{${roleIds.join(",")}}`;
21290
- await this.withServerContext(async (db) => db.execute(sql`
20520
+ await this.db.execute(sql`
21291
20521
  UPDATE ${sql.raw(usersTableName)}
21292
20522
  SET roles = ${rolesArray}::text[], updated_at = NOW()
21293
20523
  WHERE id = ${userId}
21294
- `));
20524
+ `);
21295
20525
  }
21296
20526
  /**
21297
20527
  * Assign a specific role to new user (appends if not present)
21298
20528
  */
21299
20529
  async assignDefaultRole(userId, roleId) {
21300
20530
  const usersTableName = this.getQualifiedUsersTableName();
21301
- await this.withServerContext(async (db) => db.execute(sql`
20531
+ await this.db.execute(sql`
21302
20532
  UPDATE ${sql.raw(usersTableName)}
21303
20533
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
21304
20534
  WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
21305
- `));
20535
+ `);
21306
20536
  }
21307
20537
  /**
21308
20538
  * Get user with their roles
@@ -22686,14 +21916,21 @@ function createPostgresBootstrapper(pgConfig) {
22686
21916
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
22687
21917
  }
22688
21918
  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
+ }
22689
21924
  const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
22690
- const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
22691
- const registry = buildCollectionRegistry({
22692
- collections: activeCollections,
22693
- tables: schemaTables,
22694
- enums: pgConfig.schema?.enums,
22695
- relations: schemaRelations
21925
+ if (schemaTables) Object.values(schemaTables).forEach((table) => {
21926
+ if (isTable(table)) {
21927
+ const tableName = getTableName(table);
21928
+ registry.registerTable(table, tableName);
21929
+ }
22696
21930
  });
21931
+ if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
21932
+ const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
21933
+ if (schemaRelations) registry.registerRelations(schemaRelations);
22697
21934
  if (schemaTables) patchPgArrayNullSafety(schemaTables);
22698
21935
  const mergedSchema = {
22699
21936
  ...schemaTables,
@@ -22772,7 +22009,6 @@ function createPostgresBootstrapper(pgConfig) {
22772
22009
  const wantsCdc = cdcMode !== "off";
22773
22010
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
22774
22011
  let cdcEnabled = false;
22775
- let provisionCdcForTables;
22776
22012
  if (wantsCdc && !directUrl) {
22777
22013
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
22778
22014
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -22789,9 +22025,6 @@ function createPostgresBootstrapper(pgConfig) {
22789
22025
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
22790
22026
  await realtimeService.enableCdc(directUrl);
22791
22027
  cdcEnabled = true;
22792
- provisionCdcForTables = async (tables) => {
22793
- await provisionTriggerCdc(cdcRunSql, tables);
22794
- };
22795
22028
  logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
22796
22029
  } catch (err) {
22797
22030
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -22816,14 +22049,13 @@ function createPostgresBootstrapper(pgConfig) {
22816
22049
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
22817
22050
  const missing = [];
22818
22051
  for (const col of registeredCollections) {
22819
- if (col.auth?.enabled) continue;
22820
22052
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
22821
22053
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
22822
22054
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
22823
22055
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
22824
22056
  if (!dbTables.has(fullCheckName)) missing.push({
22825
22057
  slug: col.slug,
22826
- table: fullCheckName
22058
+ table: checkName
22827
22059
  });
22828
22060
  }
22829
22061
  if (missing.length > 0) {
@@ -22857,8 +22089,7 @@ function createPostgresBootstrapper(pgConfig) {
22857
22089
  registry,
22858
22090
  realtimeService,
22859
22091
  driver,
22860
- poolManager,
22861
- provisionCdcForTables
22092
+ poolManager
22862
22093
  }
22863
22094
  };
22864
22095
  },
@@ -22870,18 +22101,6 @@ function createPostgresBootstrapper(pgConfig) {
22870
22101
  const registry = internals.registry;
22871
22102
  const authCollection = authConfig.collection;
22872
22103
  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
- }
22885
22104
  let emailService;
22886
22105
  if (authConfig.email) emailService = createEmailService(authConfig.email);
22887
22106
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
@@ -22952,6 +22171,6 @@ function createPostgresAdapter(pgConfig) {
22952
22171
  };
22953
22172
  }
22954
22173
  //#endregion
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 };
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 };
22956
22175
 
22957
22176
  //# sourceMappingURL=index.es.js.map