@rebasepro/server-postgres 0.9.1-canary.58368ce → 0.9.1-canary.73476f2

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.
package/dist/index.es.js CHANGED
@@ -3,15 +3,15 @@ import process from "process";
3
3
  const require = __createRequire(import.meta.url);
4
4
  import { Client, Pool } from "pg";
5
5
  import { drizzle } from "drizzle-orm/node-postgres";
6
- import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
6
+ import { 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";
@@ -1502,140 +1502,6 @@ function removeFunctions(o) {
1502
1502
  return o;
1503
1503
  }
1504
1504
  //#endregion
1505
- //#region ../utils/src/sha1.ts
1506
- /**
1507
- * Minimal SHA-1 implementation that runs in both Node and the browser.
1508
- *
1509
- * This exists because generated Postgres policy names embed a SHA-1 digest of
1510
- * the security rule. The DDL generator runs on the server (where `node:crypto`
1511
- * is available) but the Studio has to derive the same names in the browser to
1512
- * tell a policy it generated apart from one it did not. `node:crypto` cannot be
1513
- * bundled for the browser, so the shared derivation needs a portable digest.
1514
- *
1515
- * SHA-1 is used purely to name things deterministically — never for security.
1516
- * The output is byte-identical to `createHash("sha1").update(str).digest("hex")`,
1517
- * which `sha1.test.ts` pins against `node:crypto` directly.
1518
- */
1519
- /** Rotate a 32-bit word left by `n` bits. */
1520
- function rotl(value, n) {
1521
- return value << n | value >>> 32 - n;
1522
- }
1523
- /**
1524
- * SHA-1 digest of a string, hex-encoded.
1525
- *
1526
- * The input is encoded as UTF-8, matching Node's default handling of strings
1527
- * passed to `hash.update(str)`.
1528
- */
1529
- function sha1Hex(input) {
1530
- const bytes = Array.from(new TextEncoder().encode(input));
1531
- const bitLength = bytes.length * 8;
1532
- bytes.push(128);
1533
- while (bytes.length % 64 !== 56) bytes.push(0);
1534
- const hi = Math.floor(bitLength / 4294967296);
1535
- const lo = bitLength >>> 0;
1536
- bytes.push(hi >>> 24 & 255, hi >>> 16 & 255, hi >>> 8 & 255, hi & 255);
1537
- bytes.push(lo >>> 24 & 255, lo >>> 16 & 255, lo >>> 8 & 255, lo & 255);
1538
- let h0 = 1732584193;
1539
- let h1 = 4023233417;
1540
- let h2 = 2562383102;
1541
- let h3 = 271733878;
1542
- let h4 = 3285377520;
1543
- const w = new Array(80);
1544
- for (let offset = 0; offset < bytes.length; offset += 64) {
1545
- for (let i = 0; i < 16; i++) {
1546
- const j = offset + i * 4;
1547
- w[i] = bytes[j] << 24 | bytes[j + 1] << 16 | bytes[j + 2] << 8 | bytes[j + 3] | 0;
1548
- }
1549
- for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
1550
- let a = h0;
1551
- let b = h1;
1552
- let c = h2;
1553
- let d = h3;
1554
- let e = h4;
1555
- for (let i = 0; i < 80; i++) {
1556
- let f;
1557
- let k;
1558
- if (i < 20) {
1559
- f = b & c | ~b & d;
1560
- k = 1518500249;
1561
- } else if (i < 40) {
1562
- f = b ^ c ^ d;
1563
- k = 1859775393;
1564
- } else if (i < 60) {
1565
- f = b & c | b & d | c & d;
1566
- k = 2400959708;
1567
- } else {
1568
- f = b ^ c ^ d;
1569
- k = 3395469782;
1570
- }
1571
- const temp = rotl(a, 5) + f + e + k + w[i] | 0;
1572
- e = d;
1573
- d = c;
1574
- c = rotl(b, 30);
1575
- b = a;
1576
- a = temp;
1577
- }
1578
- h0 = h0 + a | 0;
1579
- h1 = h1 + b | 0;
1580
- h2 = h2 + c | 0;
1581
- h3 = h3 + d | 0;
1582
- h4 = h4 + e | 0;
1583
- }
1584
- return [
1585
- h0,
1586
- h1,
1587
- h2,
1588
- h3,
1589
- h4
1590
- ].map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
1591
- }
1592
- //#endregion
1593
- //#region ../utils/src/policy-names.ts
1594
- /**
1595
- * Naming of the Postgres policies generated from a collection's security rules.
1596
- *
1597
- * A rule without an explicit `name` is compiled to `<table>_<op>_<hash>`, where
1598
- * the hash covers the rule's semantics. The Studio needs the same names to tell
1599
- * "this policy came from your code" apart from "someone wrote this in SQL" —
1600
- * without them it treats generated policies as foreign and offers to import
1601
- * them back into the codebase they came from.
1602
- *
1603
- * This is the single definition of that naming. The DDL and Drizzle generators
1604
- * both derive names from here, so a change cannot silently rename every policy
1605
- * in every deployed database while the UI keeps matching the old ones.
1606
- */
1607
- /** Stable digest of the parts of a rule that determine what the policy does. */
1608
- function getPolicyNameHash(rule) {
1609
- return sha1Hex(JSON.stringify({
1610
- a: rule.access,
1611
- m: rule.mode,
1612
- op: rule.operation,
1613
- ops: rule.operations?.slice().sort(),
1614
- own: rule.ownerField,
1615
- rol: rule.roles?.slice().sort(),
1616
- pg: rule.pgRoles?.slice().sort(),
1617
- u: rule.using,
1618
- w: rule.withCheck,
1619
- c: rule.condition,
1620
- ch: rule.check
1621
- })).substring(0, 7);
1622
- }
1623
- /** The operations a rule expands to — `operations` wins over `operation`. */
1624
- function getPolicyOperations(rule) {
1625
- return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
1626
- }
1627
- /**
1628
- * Every Postgres policy name a single rule compiles to — one per operation.
1629
- *
1630
- * @param rule The security rule as written in the collection config.
1631
- * @param tableName The rule's table (see `getTableName` in `@rebasepro/common`).
1632
- */
1633
- function getPolicyNamesForRule(rule, tableName) {
1634
- const ops = getPolicyOperations(rule);
1635
- const ruleHash = getPolicyNameHash(rule);
1636
- return ops.map((op, opIdx) => rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`);
1637
- }
1638
- //#endregion
1639
1505
  //#region ../utils/src/names.ts
1640
1506
  /**
1641
1507
  * Generates a foreign key column name from a given string, typically a collection slug or name.
@@ -1760,109 +1626,6 @@ function createRelationRefWithData(id, path, data) {
1760
1626
  data
1761
1627
  };
1762
1628
  }
1763
- /**
1764
- * Derive a row's address from its key columns.
1765
- *
1766
- * Single key → the value as a string. Composite → each part joined by
1767
- * {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what
1768
- * {@link parseIdValues} expects to invert.
1769
- */
1770
- function buildCompositeId(values, primaryKeys) {
1771
- if (primaryKeys.length === 0) return "";
1772
- if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
1773
- return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
1774
- }
1775
- /**
1776
- * Invert {@link buildCompositeId}: turn an address back into key columns, each
1777
- * coerced to the type its column actually round-trips as.
1778
- *
1779
- * This is the boundary where a URL segment becomes a query parameter, so a
1780
- * malformed address must throw rather than silently produce a query that
1781
- * matches the wrong row (or none).
1782
- */
1783
- function parseIdValues(idValue, primaryKeys) {
1784
- const result = {};
1785
- if (primaryKeys.length === 0) return result;
1786
- if (primaryKeys.length === 1) {
1787
- const pk = primaryKeys[0];
1788
- if (pk.type === "number" && !pk.isUUID) {
1789
- const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
1790
- if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
1791
- result[pk.fieldName] = parsed;
1792
- } else result[pk.fieldName] = String(idValue);
1793
- return result;
1794
- }
1795
- const parts = String(idValue).split(":::");
1796
- if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
1797
- for (let i = 0; i < primaryKeys.length; i++) {
1798
- const pk = primaryKeys[i];
1799
- const val = parts[i];
1800
- if (pk.type === "number" && !pk.isUUID) {
1801
- const parsed = parseInt(val, 10);
1802
- if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
1803
- result[pk.fieldName] = parsed;
1804
- } else result[pk.fieldName] = val;
1805
- }
1806
- return result;
1807
- }
1808
- /**
1809
- * The primary keys of a collection, as declared by its properties.
1810
- *
1811
- * This is the only tier both sides can read, because it is the only one written
1812
- * in the config: the postgres driver can also infer keys from the Drizzle
1813
- * schema, which the browser never sees and is never sent — the admin compiles
1814
- * the collection files into its own bundle rather than being served them. A key
1815
- * that lives only in the Drizzle schema is therefore invisible here, and the
1816
- * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
1817
- * to add.
1818
- *
1819
- * Returns an empty array when a collection declares none, which callers must
1820
- * treat as "not addressable" rather than defaulting to `id`: guessing a key
1821
- * that is not the real one produces confidently wrong addresses.
1822
- */
1823
- function getDeclaredPrimaryKeys(collection) {
1824
- const properties = collection.properties;
1825
- if (!properties) return [];
1826
- const keys = [];
1827
- for (const [fieldName, propRaw] of Object.entries(properties)) {
1828
- const prop = propRaw;
1829
- if (!prop || typeof prop !== "object") continue;
1830
- if (!("isId" in prop) || !prop.isId) continue;
1831
- keys.push({
1832
- fieldName,
1833
- type: prop.type === "number" ? "number" : "string",
1834
- isUUID: prop.isId === "uuid"
1835
- });
1836
- }
1837
- return keys;
1838
- }
1839
- /**
1840
- * The keys to address a collection's rows with, resolved the way the driver
1841
- * resolves them — minus the tier the browser cannot reach.
1842
- *
1843
- * The postgres driver tries, in order: properties marked `isId`; the primary
1844
- * keys of the Drizzle schema; and finally a column literally named `id`. Only
1845
- * the first and last are visible in a `CollectionConfig`, which is what both
1846
- * sides share.
1847
- *
1848
- * So the two agree except on a collection that declares no `isId` and whose key
1849
- * is known only to Drizzle. There, the driver reads the real key, and this
1850
- * either resolves nothing (reported to the console by the caller) or — if the
1851
- * table happens to have an unrelated `id` property — resolves `id`, which is
1852
- * the wrong key and cannot be detected from here: the addresses look right and
1853
- * route wrong. Only the config can settle it, so the server names both cases
1854
- * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
1855
- */
1856
- function resolvePrimaryKeys(collection) {
1857
- const declared = getDeclaredPrimaryKeys(collection);
1858
- if (declared.length > 0) return declared;
1859
- const idProp = collection.properties?.id;
1860
- if (idProp && typeof idProp === "object") return [{
1861
- fieldName: "id",
1862
- type: idProp.type === "number" ? "number" : "string"
1863
- }];
1864
- return [];
1865
- }
1866
1629
  //#endregion
1867
1630
  //#region ../common/src/util/enums.ts
1868
1631
  function enumToObjectEntries(enumValues) {
@@ -2097,109 +1860,15 @@ function getSubcollections(collection) {
2097
1860
  * - `field = 'literal'`
2098
1861
  * - `field != 'literal'`
2099
1862
  * - `field = current_setting('app.user_id')`
2100
- * - `A AND B`, `A OR B` — only where the keyword is at the top level
1863
+ * - `A AND B`
2101
1864
  * - `true`
2102
1865
  * - `IN (...)` (as optimistic true)
2103
1866
  *
2104
1867
  * For anything it doesn't understand, it returns a `raw` expression, which
2105
1868
  * the evaluator treats as "unknown" (and usually optimistic true).
2106
- *
2107
- * **This output also round-trips back into DDL** via `policyToPostgres` (the
2108
- * schema/policy generators), so decomposing a clause the parser only partly
2109
- * understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
2110
- * prefer `raw`: it is reproduced verbatim.
2111
1869
  */
2112
- /** True when `keyword` starts at `i` as a standalone word. */
2113
- function isKeywordAt(upper, i, keyword) {
2114
- if (!upper.startsWith(keyword, i)) return false;
2115
- const before = i === 0 ? " " : upper[i - 1];
2116
- const after = upper[i + keyword.length] ?? " ";
2117
- return /[\s()]/.test(before) && /[\s()]/.test(after);
2118
- }
2119
- /**
2120
- * Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
2121
- * outside a string literal. Returns null when it never does, so the caller
2122
- * leaves the clause alone.
2123
- *
2124
- * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
2125
- * `AND` inside
2126
- * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
2127
- * split the expression, and re-emitting the halves produced
2128
- * `(EXISTS (...) AND m.user_id = auth.uid())`
2129
- * where `m` is no longer in scope — SQL that Postgres rejects outright with
2130
- * "missing FROM-clause entry for table". Returning null instead keeps such a
2131
- * clause as a `raw` expression, which round-trips verbatim.
2132
- */
2133
- function splitTopLevel(sql, keyword) {
2134
- const upper = sql.toUpperCase();
2135
- const parts = [];
2136
- let depth = 0;
2137
- let inString = false;
2138
- let start = 0;
2139
- for (let i = 0; i < sql.length; i++) {
2140
- const ch = sql[i];
2141
- if (inString) {
2142
- if (ch === "'") if (sql[i + 1] === "'") i++;
2143
- else inString = false;
2144
- continue;
2145
- }
2146
- if (ch === "'") {
2147
- inString = true;
2148
- continue;
2149
- }
2150
- if (ch === "(") {
2151
- depth++;
2152
- continue;
2153
- }
2154
- if (ch === ")") {
2155
- depth--;
2156
- continue;
2157
- }
2158
- if (depth === 0 && isKeywordAt(upper, i, keyword)) {
2159
- parts.push(sql.slice(start, i));
2160
- i += keyword.length - 1;
2161
- start = i + 1;
2162
- }
2163
- }
2164
- if (parts.length === 0) return null;
2165
- parts.push(sql.slice(start));
2166
- const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
2167
- return trimmedParts.length > 1 ? trimmedParts : null;
2168
- }
2169
- /** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
2170
- function stripOuterParens(sql) {
2171
- let s = sql.trim();
2172
- for (;;) {
2173
- if (!s.startsWith("(") || !s.endsWith(")")) return s;
2174
- let depth = 0;
2175
- let inString = false;
2176
- let wraps = true;
2177
- for (let i = 0; i < s.length; i++) {
2178
- const ch = s[i];
2179
- if (inString) {
2180
- if (ch === "'") if (s[i + 1] === "'") i++;
2181
- else inString = false;
2182
- continue;
2183
- }
2184
- if (ch === "'") {
2185
- inString = true;
2186
- continue;
2187
- }
2188
- if (ch === "(") depth++;
2189
- else if (ch === ")") {
2190
- depth--;
2191
- if (depth === 0 && i < s.length - 1) {
2192
- wraps = false;
2193
- break;
2194
- }
2195
- }
2196
- }
2197
- if (!wraps) return s;
2198
- s = s.slice(1, -1).trim();
2199
- }
2200
- }
2201
1870
  function sqlToPolicy(sql) {
2202
- const trimmed = stripOuterParens(sql.trim());
1871
+ const trimmed = sql.trim();
2203
1872
  if (trimmed.toLowerCase() === "true") return policy.true();
2204
1873
  if (trimmed.toLowerCase() === "false") return policy.false();
2205
1874
  const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
@@ -2212,10 +1881,14 @@ function sqlToPolicy(sql) {
2212
1881
  const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
2213
1882
  return policy.rolesContain(roles);
2214
1883
  }
2215
- const orParts = splitTopLevel(trimmed, "OR");
2216
- if (orParts) return policy.or(...orParts.map(sqlToPolicy));
2217
- const andParts = splitTopLevel(trimmed, "AND");
2218
- if (andParts) return policy.and(...andParts.map(sqlToPolicy));
1884
+ if (trimmed.toUpperCase().includes(" OR ")) {
1885
+ const parts = trimmed.split(/ OR /i);
1886
+ return policy.or(...parts.map(sqlToPolicy));
1887
+ }
1888
+ if (trimmed.toUpperCase().includes(" AND ")) {
1889
+ const parts = trimmed.split(/ AND /i);
1890
+ return policy.and(...parts.map(sqlToPolicy));
1891
+ }
2219
1892
  const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
2220
1893
  if (match) {
2221
1894
  const [, leftStr, op, rightStr] = match;
@@ -2521,307 +2194,6 @@ var buildPropertyCallbacks = (properties) => {
2521
2194
  };
2522
2195
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
2523
2196
  };
2524
- //#endregion
2525
- //#region ../common/src/util/auth-default-policies.ts
2526
- /**
2527
- * Default RLS policies injected by the schema generator.
2528
- *
2529
- * Rebase's enforcement model is unified: authenticated (user-context) requests
2530
- * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
2531
- * statement — reads and writes. A collection's `securityRules` are the whole
2532
- * authorization model. The server context (auth flows, migrations,
2533
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
2534
- *
2535
- * Because RLS default-denies, every collection is **locked by default**: with
2536
- * no rules, only the server context and admins can touch it. The generator
2537
- * injects that safe baseline:
2538
- *
2539
- * **For every collection**
2540
- * 1. A permissive **server-or-admin SELECT** grant.
2541
- * 2. A permissive **server-or-admin write** grant (insert/update/delete).
2542
- *
2543
- * Author `securityRules` are permissive and OR together, so explicit rules only
2544
- * *broaden* access from this locked baseline (e.g. "users read/write their own
2545
- * rows").
2546
- *
2547
- * **For auth collections additionally**
2548
- * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
2549
- * their own row (profile, session bootstrap) without every app re-declaring
2550
- * it.
2551
- * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
2552
- * every other policy, so a write is rejected unless the caller is an admin
2553
- * (or the server context) — even if the author also wrote a permissive rule
2554
- * such as "a user may edit their own row". Without this, a permissive owner
2555
- * rule would let a user change their own `roles`.
2556
- *
2557
- * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
2558
- * — the built-in flows that run without a user (signup, migrations) set no user
2559
- * GUC — which also lets the owner connection satisfy these policies even under
2560
- * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
2561
- * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
2562
- *
2563
- * Opt out with `disableDefaultPolicies: true` to take full responsibility for
2564
- * the collection's RLS.
2565
- */
2566
- var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2567
- /** Write operations that must be admin-gated by default on auth collections. */
2568
- var DEFAULT_GUARDED_OPS = [
2569
- "insert",
2570
- "update",
2571
- "delete"
2572
- ];
2573
- /** Whether a collection is flagged as an authentication collection. */
2574
- function isAuthCollection(collection) {
2575
- const auth = collection.auth;
2576
- return auth === true || typeof auth === "object" && auth?.enabled === true;
2577
- }
2578
- /** The property marked as the row id (falls back to `id`). */
2579
- function getIdPropertyName$1(collection) {
2580
- for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
2581
- return "id";
2582
- }
2583
- /**
2584
- * Returns the security rules that should be applied to a collection: the
2585
- * author's explicit `securityRules` plus the framework defaults described in
2586
- * the module doc (baseline server/admin read for all collections; self-read
2587
- * and the admin write gate for auth collections).
2588
- *
2589
- * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
2590
- */
2591
- function getEffectiveSecurityRules(collection) {
2592
- const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
2593
- if (collection.disableDefaultPolicies) return explicit;
2594
- const tableName = getTableName$1(collection);
2595
- const injected = [];
2596
- injected.push({
2597
- name: `${tableName}_default_admin_read`,
2598
- operations: ["select"],
2599
- condition: SERVER_OR_ADMIN_EXPR$1
2600
- });
2601
- injected.push({
2602
- name: `${tableName}_default_admin_write`,
2603
- operations: [...DEFAULT_GUARDED_OPS],
2604
- condition: SERVER_OR_ADMIN_EXPR$1,
2605
- check: SERVER_OR_ADMIN_EXPR$1
2606
- });
2607
- if (isAuthCollection(collection)) {
2608
- injected.push({
2609
- name: `${tableName}_default_self_read`,
2610
- operations: ["select"],
2611
- condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
2612
- });
2613
- injected.push({
2614
- name: `${tableName}_require_admin_write`,
2615
- mode: "restrictive",
2616
- operations: [...DEFAULT_GUARDED_OPS],
2617
- condition: SERVER_OR_ADMIN_EXPR$1,
2618
- check: SERVER_OR_ADMIN_EXPR$1
2619
- });
2620
- }
2621
- return [...explicit, ...injected];
2622
- }
2623
- //#endregion
2624
- //#region ../common/src/util/junction-policies.ts
2625
- var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2626
- /**
2627
- * Walk every collection's resolved relations and aggregate the junction tables
2628
- * they declare. Two collections may declare the same junction from opposite
2629
- * sides (posts→tags and tags→posts through `posts_tags`); both become
2630
- * `declaringSides` of one spec, so derived write grants consider both.
2631
- */
2632
- function resolveJunctionSpecs(collections) {
2633
- const specs = /* @__PURE__ */ new Map();
2634
- for (const collection of collections) {
2635
- const resolved = resolveCollectionRelations(collection);
2636
- for (const relation of Object.values(resolved)) {
2637
- if (!relation.through) continue;
2638
- const targetCollection = typeof relation.target === "function" ? relation.target() : void 0;
2639
- if (!targetCollection) continue;
2640
- const rawName = relation.through.table;
2641
- const table = rawName.includes(".") ? rawName.split(".").pop() : rawName;
2642
- const schema = "public";
2643
- const source = {
2644
- collection,
2645
- junctionColumn: relation.through.sourceColumn,
2646
- relation
2647
- };
2648
- const target = {
2649
- collection: targetCollection,
2650
- junctionColumn: relation.through.targetColumn
2651
- };
2652
- const existing = specs.get(table);
2653
- if (!existing) specs.set(table, {
2654
- table,
2655
- schema,
2656
- endpoints: [source, target],
2657
- declaringSides: [source]
2658
- });
2659
- else if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
2660
- }
2661
- }
2662
- return specs;
2663
- }
2664
- /**
2665
- * A synthetic CollectionConfig standing in for the junction during policy
2666
- * compilation and naming. Its two FK columns carry explicit `columnName`s so
2667
- * `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
2668
- * whatever their casing.
2669
- */
2670
- function getJunctionCollectionConfig(spec) {
2671
- const properties = {};
2672
- for (const endpoint of spec.endpoints) properties[endpoint.junctionColumn] = {
2673
- type: "string",
2674
- columnName: endpoint.junctionColumn
2675
- };
2676
- return {
2677
- slug: spec.table,
2678
- name: spec.table,
2679
- table: spec.table,
2680
- schema: spec.schema,
2681
- properties
2682
- };
2683
- }
2684
- /** The property marked as the row id (falls back to `id`). */
2685
- function getIdPropertyName(collection) {
2686
- for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
2687
- return "id";
2688
- }
2689
- /** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */
2690
- function existsEndpoint(endpoint, extra) {
2691
- const correlation = policy.compare(policy.field(getIdPropertyName(endpoint.collection)), "eq", policy.outerField(endpoint.junctionColumn));
2692
- return policy.existsIn({
2693
- collection: endpoint.collection.slug,
2694
- where: extra ? policy.and(correlation, extra) : correlation
2695
- });
2696
- }
2697
- /**
2698
- * Whether a parent-rule expression keeps its meaning when moved inside the
2699
- * junction's `EXISTS` subquery — and the re-scoped copy if it does.
2700
- *
2701
- * Returns `null` when the rule cannot be embedded faithfully: `raw` SQL
2702
- * anywhere (its `{column}` placeholders would bind to the junction), or an
2703
- * `outerField` inside a nested `existsIn` (it would bind to the junction while
2704
- * the author meant the parent, and no operand can express "the middle scope").
2705
- * Top-level `outerField`s are rewritten to `field`, which is what they meant.
2706
- */
2707
- function embedParentExpression(expr, depth = 0) {
2708
- switch (expr.kind) {
2709
- case "raw": return null;
2710
- case "and":
2711
- case "or": {
2712
- const parts = [];
2713
- for (const child of expr.operands) {
2714
- const embedded = embedParentExpression(child, depth);
2715
- if (!embedded) return null;
2716
- parts.push(embedded);
2717
- }
2718
- return expr.kind === "and" ? policy.and(...parts) : policy.or(...parts);
2719
- }
2720
- case "not": {
2721
- const embedded = embedParentExpression(expr.operand, depth);
2722
- return embedded ? policy.not(embedded) : null;
2723
- }
2724
- case "existsIn": {
2725
- const where = embedParentExpression(expr.where, depth + 1);
2726
- return where ? policy.existsIn({
2727
- collection: expr.collection,
2728
- where
2729
- }) : null;
2730
- }
2731
- case "compare": {
2732
- const left = embedOperand(expr.left, depth);
2733
- const right = embedOperand(expr.right, depth);
2734
- if (!left || !right) return null;
2735
- return {
2736
- ...expr,
2737
- left,
2738
- right
2739
- };
2740
- }
2741
- default: return expr;
2742
- }
2743
- }
2744
- /** Re-scope an operand, or return `null` if its binding cannot be preserved. */
2745
- function embedOperand(operand, depth) {
2746
- if (operand.kind === "outerField") {
2747
- if (depth === 0) return policy.field(operand.name);
2748
- return null;
2749
- }
2750
- return operand;
2751
- }
2752
- /** Does the rule cover the `update` operation? */
2753
- function coversUpdate(rule) {
2754
- return getPolicyOperations(rule).some((op) => op === "update" || op === "all");
2755
- }
2756
- /**
2757
- * The full derived policy set for a junction table: the locked server/admin
2758
- * baseline, the endpoint-visibility read grant, inherited write grants, and
2759
- * inherited restrictive gates. Returns `[]` when every declaring collection set
2760
- * `disableDefaultPolicies` — the junction is then the author's to police, and
2761
- * stays locked (RLS is still enabled) until they write policies for it.
2762
- */
2763
- function getJunctionSecurityRules(spec) {
2764
- if (spec.declaringSides.every((side) => side.collection.disableDefaultPolicies)) return [];
2765
- const rules = [];
2766
- rules.push({
2767
- name: `${spec.table}_default_admin_read`,
2768
- operations: ["select"],
2769
- condition: SERVER_OR_ADMIN_EXPR
2770
- });
2771
- rules.push({
2772
- name: `${spec.table}_default_admin_write`,
2773
- operations: [
2774
- "insert",
2775
- "update",
2776
- "delete"
2777
- ],
2778
- condition: SERVER_OR_ADMIN_EXPR,
2779
- check: SERVER_OR_ADMIN_EXPR
2780
- });
2781
- rules.push({
2782
- name: `${spec.table}_default_edge_read`,
2783
- operations: ["select"],
2784
- condition: policy.and(existsEndpoint(spec.endpoints[0]), existsEndpoint(spec.endpoints[1]))
2785
- });
2786
- const writeGrants = [];
2787
- for (const side of spec.declaringSides) {
2788
- const updateRules = ((isPostgresCollectionConfig(side.collection) ? side.collection.securityRules : void 0) ?? []).filter(coversUpdate);
2789
- const permissive = updateRules.filter((r) => r.mode !== "restrictive");
2790
- const restrictive = updateRules.filter((r) => r.mode === "restrictive");
2791
- const embeddedGates = [];
2792
- let gatesEmbeddable = true;
2793
- for (const gate of restrictive) {
2794
- const using = securityRuleToConditions(gate).usingExpr;
2795
- const embedded = using ? embedParentExpression(using) : null;
2796
- if (!embedded) {
2797
- gatesEmbeddable = false;
2798
- break;
2799
- }
2800
- embeddedGates.push(embedded);
2801
- }
2802
- if (!gatesEmbeddable) continue;
2803
- const grants = [];
2804
- for (const rule of permissive) {
2805
- const using = securityRuleToConditions(rule).usingExpr;
2806
- const embedded = using ? embedParentExpression(using) : null;
2807
- if (embedded) grants.push(embedded);
2808
- }
2809
- if (grants.length === 0) continue;
2810
- const condition = embeddedGates.length > 0 ? policy.and(policy.or(...grants), ...embeddedGates) : policy.or(...grants);
2811
- writeGrants.push(existsEndpoint(side, condition));
2812
- }
2813
- if (writeGrants.length > 0) rules.push({
2814
- name: `${spec.table}_default_edge_write`,
2815
- operations: [
2816
- "insert",
2817
- "update",
2818
- "delete"
2819
- ],
2820
- condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),
2821
- check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)
2822
- });
2823
- return rules;
2824
- }
2825
2197
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
2826
2198
  (function(root, factory) {
2827
2199
  if (typeof define === "function" && define.amd) define(factory);
@@ -4091,45 +3463,18 @@ function deserializeFilter(query) {
4091
3463
  }
4092
3464
  //#endregion
4093
3465
  //#region ../common/src/data/buildRebaseData.ts
4094
- function createPrimaryKeyResolver(options) {
4095
- const cache = /* @__PURE__ */ new Map();
4096
- const warned = /* @__PURE__ */ new Set();
4097
- return function primaryKeysFor(slug) {
4098
- const cached = cache.get(slug);
4099
- if (cached) return cached;
4100
- const collection = options?.resolveCollection?.(slug);
4101
- if (!collection) return [];
4102
- const keys = resolvePrimaryKeys(collection);
4103
- if (keys.length > 0) {
4104
- cache.set(slug, keys);
4105
- return keys;
4106
- }
4107
- if (!warned.has(slug)) {
4108
- warned.add(slug);
4109
- 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.`);
4110
- }
4111
- return keys;
4112
- };
4113
- }
4114
3466
  /**
4115
- * Give a flat row the Entity view-model the admin renders.
4116
- *
4117
- * The address is *derived here* — it is not a column, and the row it came from
4118
- * does not contain one. Rows carry exactly what the table has, with the types
4119
- * Postgres returned; the id is this layer's invention, and this is the only
4120
- * place it is minted.
4121
- *
4122
- * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
4123
- * than postgres still serve rows with one, and this keeps them working.
3467
+ * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
3468
+ * Mirrors the client SDK's rowToEntity conversion.
4124
3469
  */
4125
- function rowToEntity(row, slug, primaryKeys = []) {
3470
+ function rowToEntity(row, slug) {
4126
3471
  return {
4127
- id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
3472
+ id: row.id,
4128
3473
  path: slug,
4129
3474
  values: row
4130
3475
  };
4131
3476
  }
4132
- function createDriverAccessor(driver, slug, getPks = () => []) {
3477
+ function createDriverAccessor(driver, slug) {
4133
3478
  const accessor = {
4134
3479
  async find(params) {
4135
3480
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
@@ -4162,7 +3507,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4162
3507
  hasMore = offset + rows.length < total;
4163
3508
  }
4164
3509
  return {
4165
- data: rows.map((row) => rowToEntity(row, slug, getPks())),
3510
+ data: rows.map((row) => rowToEntity(row, slug)),
4166
3511
  meta: {
4167
3512
  total,
4168
3513
  limit,
@@ -4176,7 +3521,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4176
3521
  path: slug,
4177
3522
  id
4178
3523
  });
4179
- return row ? rowToEntity(row, slug, getPks()) : void 0;
3524
+ return row ? rowToEntity(row, slug) : void 0;
4180
3525
  },
4181
3526
  async create(data, id) {
4182
3527
  return rowToEntity(await driver.save({
@@ -4184,22 +3529,15 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4184
3529
  values: data,
4185
3530
  id,
4186
3531
  status: "new"
4187
- }), slug, getPks());
3532
+ }), slug);
4188
3533
  },
4189
- createMany: driver.saveMany ? async (data, options) => {
4190
- return (await driver.saveMany({
4191
- path: slug,
4192
- rows: data,
4193
- upsert: options?.upsert
4194
- })).map((row) => rowToEntity(row, slug, getPks()));
4195
- } : void 0,
4196
3534
  async update(id, data) {
4197
3535
  return rowToEntity(await driver.save({
4198
3536
  path: slug,
4199
3537
  values: data,
4200
3538
  id,
4201
3539
  status: "existing"
4202
- }), slug, getPks());
3540
+ }), slug);
4203
3541
  },
4204
3542
  async delete(id) {
4205
3543
  return driver.delete({ row: {
@@ -4228,7 +3566,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4228
3566
  searchString: params?.searchString,
4229
3567
  onUpdate: (entities) => {
4230
3568
  onUpdate({
4231
- data: entities.map((row) => rowToEntity(row, slug, getPks())),
3569
+ data: entities.map((row) => rowToEntity(row, slug)),
4232
3570
  meta: {
4233
3571
  total: entities.length,
4234
3572
  limit,
@@ -4244,7 +3582,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4244
3582
  return driver.listenOne({
4245
3583
  path: slug,
4246
3584
  id,
4247
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
3585
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
4248
3586
  onError
4249
3587
  });
4250
3588
  } : void 0,
@@ -4283,13 +3621,12 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4283
3621
  * await data.products.create({ name: "Camera", price: 299 });
4284
3622
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
4285
3623
  */
4286
- function buildRebaseData(driver, options) {
3624
+ function buildRebaseData(driver) {
4287
3625
  const cache = /* @__PURE__ */ new Map();
4288
- const primaryKeysFor = createPrimaryKeyResolver(options);
4289
3626
  function getAccessor(slug) {
4290
3627
  let accessor = cache.get(slug);
4291
3628
  if (!accessor) {
4292
- accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
3629
+ accessor = createDriverAccessor(driver, slug);
4293
3630
  cache.set(slug, accessor);
4294
3631
  }
4295
3632
  return accessor;
@@ -4302,9 +3639,8 @@ function buildRebaseData(driver, options) {
4302
3639
  } });
4303
3640
  }
4304
3641
  /**
4305
- * Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
4306
- * the row untouched under `.values` and derives `.id` alongside it, so dropping
4307
- * the wrapper is the whole operation — the address was never part of the row.
3642
+ * Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
3643
+ * (id included) under `.values`, so this is just that payload.
4308
3644
  */
4309
3645
  function entityToRow(entity) {
4310
3646
  return entity.values;
@@ -4391,12 +3727,6 @@ function toSdkCollectionClient(snap) {
4391
3727
  async create(data, id) {
4392
3728
  return entityToRow(await snap.create(data, id));
4393
3729
  },
4394
- async createMany(data, options) {
4395
- if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
4396
- if (data.length === 0) return [];
4397
- if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
4398
- return (await snap.createMany(data, options)).map(entityToRow);
4399
- },
4400
3730
  async update(id, data) {
4401
3731
  return entityToRow(await snap.update(id, data));
4402
3732
  },
@@ -4584,86 +3914,37 @@ function getPrimaryKeys(collection, registry) {
4584
3914
  isUUID
4585
3915
  });
4586
3916
  }
4587
- return keys;
4588
- }
4589
- /**
4590
- * Collections whose key the *browser* cannot resolve, and what it will do
4591
- * instead.
4592
- *
4593
- * The two sides resolve keys from different evidence. This driver reads, in
4594
- * order: properties marked `isId`, the primary keys of the Drizzle schema, then
4595
- * a column literally named `id`. The admin shares the `CollectionConfig` — it
4596
- * compiles the same collection files into its bundle — but never the Drizzle
4597
- * schema, so the middle tier is invisible to it.
4598
- *
4599
- * Nothing can normalize this at runtime: the server does not serve the admin
4600
- * its collections, so a key resolved here cannot be handed over there. The
4601
- * config files are the only thing both sides read, so the fix is an edit to
4602
- * them, and the most this can do is say exactly which edit.
4603
- *
4604
- * Two shapes, and the second is the dangerous one:
4605
- *
4606
- * - No `isId`, no `id` property → the admin resolves no address, warns in the
4607
- * console, and rows cannot be opened or linked.
4608
- * - No `isId`, but an `id` property that is *not* the key → the admin addresses
4609
- * rows by `id` while this driver reads the address as the real key. Nothing
4610
- * errors: the addresses look right and route wrong.
4611
- */
4612
- function findUnresolvableKeyCollections(collections, registry) {
4613
- const findings = [];
4614
- for (const collection of collections) {
4615
- if (getDeclaredPrimaryKeys(collection).length > 0) continue;
4616
- let keys;
4617
- try {
4618
- keys = getPrimaryKeys(collection, registry);
4619
- } catch {
4620
- continue;
4621
- }
4622
- if (keys.length === 0) continue;
4623
- if (keys.length === 1 && keys[0].fieldName === "id") continue;
4624
- findings.push({
4625
- collection,
4626
- keys,
4627
- shadowedByIdProperty: Boolean(collection.properties?.id)
4628
- });
4629
- }
4630
- return findings;
3917
+ return keys;
4631
3918
  }
4632
- /**
4633
- * Report the collections from {@link findUnresolvableKeyCollections} at boot,
4634
- * with the edit that fixes each one.
4635
- *
4636
- * Grouped by failure, not by collection: the shadowed case is a routing bug and
4637
- * the silent case is a missing feature, and they deserve different urgency.
4638
- */
4639
- function warnOnKeysTheAdminCannotResolve(collections, registry) {
4640
- const findings = findUnresolvableKeyCollections(collections, registry);
4641
- if (findings.length === 0) return;
4642
- 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"}\``;
4643
- const shadowed = findings.filter((f) => f.shadowedByIdProperty);
4644
- const silent = findings.filter((f) => !f.shadowedByIdProperty);
4645
- 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");
4646
- 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");
3919
+ function parseIdValues(idValue, primaryKeys) {
3920
+ const result = {};
3921
+ if (primaryKeys.length === 0) return result;
3922
+ if (primaryKeys.length === 1) {
3923
+ const pk = primaryKeys[0];
3924
+ if (pk.type === "number" && !pk.isUUID) {
3925
+ const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
3926
+ if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
3927
+ result[pk.fieldName] = parsed;
3928
+ } else result[pk.fieldName] = String(idValue);
3929
+ return result;
3930
+ }
3931
+ const parts = String(idValue).split(":::");
3932
+ if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
3933
+ for (let i = 0; i < primaryKeys.length; i++) {
3934
+ const pk = primaryKeys[i];
3935
+ const val = parts[i];
3936
+ if (pk.type === "number" && !pk.isUUID) {
3937
+ const parsed = parseInt(val, 10);
3938
+ if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
3939
+ result[pk.fieldName] = parsed;
3940
+ } else result[pk.fieldName] = val;
3941
+ }
3942
+ return result;
4647
3943
  }
4648
- /**
4649
- * The address of a row: derived from the collection's primary keys, because a
4650
- * row does not carry one it is exactly its columns.
4651
- *
4652
- * Falls back to a literal `id` column, which covers the two cases where the
4653
- * keys cannot be resolved: a row that reached us from somewhere other than the
4654
- * postgres driver, and a collection whose table the registry cannot look up
4655
- * (`getPrimaryKeys` throws for an unregistered table rather than returning
4656
- * nothing). Returns `""` when there is no key and no `id` — callers decide what
4657
- * that means, since "unaddressable" is a different answer in a notification
4658
- * (broadcast a wildcard) than in a save (fail).
4659
- */
4660
- function deriveRowAddress(row, collection, registry) {
4661
- try {
4662
- const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
4663
- if (composite && composite.split(":::").some((part) => part !== "")) return composite;
4664
- } catch {}
4665
- if (row.id !== void 0 && row.id !== null) return String(row.id);
4666
- return "";
3944
+ function buildCompositeId(values, primaryKeys) {
3945
+ if (primaryKeys.length === 0) return "";
3946
+ if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
3947
+ return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
4667
3948
  }
4668
3949
  //#endregion
4669
3950
  //#region src/utils/drizzle-conditions.ts
@@ -5286,10 +4567,12 @@ function serializeDataToServer(row, properties, collection, registry) {
5286
4567
  continue;
5287
4568
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
5288
4569
  const serializedValue = serializePropertyToServer(effectiveValue, property);
4570
+ const pks = getPrimaryKeys(collection, registry);
5289
4571
  inverseRelationUpdates.push({
5290
4572
  relationKey: key,
5291
4573
  relation,
5292
- newValue: serializedValue
4574
+ newValue: serializedValue,
4575
+ currentId: row.id || buildCompositeId(row, pks)
5293
4576
  });
5294
4577
  continue;
5295
4578
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
@@ -5299,11 +4582,15 @@ function serializeDataToServer(row, properties, collection, registry) {
5299
4582
  relation,
5300
4583
  newTargetId: serializedValue
5301
4584
  });
5302
- else inverseRelationUpdates.push({
5303
- relationKey: key,
5304
- relation,
5305
- newValue: serializedValue
5306
- });
4585
+ else {
4586
+ const pks = getPrimaryKeys(collection, registry);
4587
+ inverseRelationUpdates.push({
4588
+ relationKey: key,
4589
+ relation,
4590
+ newValue: serializedValue,
4591
+ currentId: row.id || buildCompositeId(row, pks)
4592
+ });
4593
+ }
5307
4594
  continue;
5308
4595
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
5309
4596
  const serializedValue = serializePropertyToServer(effectiveValue, property);
@@ -5755,9 +5042,10 @@ var RelationService = class {
5755
5042
  const rows = [];
5756
5043
  for (const row of results) {
5757
5044
  const targetRow = row[targetTableName] || row;
5045
+ const id = targetRow[idInfo[0].fieldName];
5758
5046
  const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5759
5047
  rows.push({
5760
- id: buildCompositeId(targetRow, idInfo),
5048
+ id: id?.toString() || "",
5761
5049
  path: targetCollection.slug,
5762
5050
  values: parsedValues
5763
5051
  });
@@ -5779,9 +5067,10 @@ var RelationService = class {
5779
5067
  const rows = [];
5780
5068
  for (const row of results) {
5781
5069
  const targetRow = row[getTableName$1(targetCollection)] || row;
5070
+ const id = targetRow[idInfo[0].fieldName];
5782
5071
  const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5783
5072
  rows.push({
5784
- id: buildCompositeId(targetRow, idInfo),
5073
+ id: id?.toString() || "",
5785
5074
  path: targetCollection.slug,
5786
5075
  values: parsedValues
5787
5076
  });
@@ -5821,8 +5110,7 @@ var RelationService = class {
5821
5110
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5822
5111
  const targetCollection = relation.target();
5823
5112
  const targetTable = getTableForCollection(targetCollection, this.registry);
5824
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
5825
- const targetIdInfo = targetPks[0];
5113
+ const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5826
5114
  const targetIdField = targetTable[targetIdInfo.fieldName];
5827
5115
  const parentPks = getPrimaryKeys(parentCollection, this.registry);
5828
5116
  const parentIdInfo = parentPks[0];
@@ -5859,7 +5147,7 @@ var RelationService = class {
5859
5147
  const parentId = parentRow[parentIdInfo.fieldName];
5860
5148
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5861
5149
  resultMap.set(String(parentId), {
5862
- id: buildCompositeId(targetRow, targetPks),
5150
+ id: String(targetRow[targetIdInfo.fieldName]),
5863
5151
  path: targetCollection.slug,
5864
5152
  values: parsedValues
5865
5153
  });
@@ -5898,7 +5186,7 @@ var RelationService = class {
5898
5186
  if (targetRow) {
5899
5187
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5900
5188
  resultMap.set(parentIdStr, {
5901
- id: buildCompositeId(targetRow, targetPks),
5189
+ id: String(targetRow[targetIdInfo.fieldName]),
5902
5190
  path: targetCollection.slug,
5903
5191
  values: parsedValues
5904
5192
  });
@@ -5919,7 +5207,7 @@ var RelationService = class {
5919
5207
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5920
5208
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5921
5209
  resultMap.set(String(parentId), {
5922
- id: buildCompositeId(targetRow, targetPks),
5210
+ id: String(targetRow[targetIdInfo.fieldName]),
5923
5211
  path: targetCollection.slug,
5924
5212
  values: parsedValues
5925
5213
  });
@@ -5937,8 +5225,8 @@ var RelationService = class {
5937
5225
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5938
5226
  const targetCollection = relation.target();
5939
5227
  const targetTable = getTableForCollection(targetCollection, this.registry);
5940
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
5941
- const targetIdField = targetTable[targetPks[0].fieldName];
5228
+ const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5229
+ const targetIdField = targetTable[targetIdInfo.fieldName];
5942
5230
  const parentPks = getPrimaryKeys(parentCollection, this.registry);
5943
5231
  const parentIdInfo = parentPks[0];
5944
5232
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5973,7 +5261,7 @@ var RelationService = class {
5973
5261
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5974
5262
  const arr = resultMap.get(parentId) || [];
5975
5263
  arr.push({
5976
- id: buildCompositeId(targetRow, targetPks),
5264
+ id: String(targetRow[targetIdInfo.fieldName]),
5977
5265
  path: targetCollection.slug,
5978
5266
  values: parsedValues
5979
5267
  });
@@ -6003,7 +5291,7 @@ var RelationService = class {
6003
5291
  const parsedValues = await parseDataFromServer(targetData, targetCollection);
6004
5292
  const arr = resultMap.get(parentId) || [];
6005
5293
  arr.push({
6006
- id: buildCompositeId(targetData, targetPks),
5294
+ id: String(targetData[targetIdInfo.fieldName]),
6007
5295
  path: targetCollection.slug,
6008
5296
  values: parsedValues
6009
5297
  });
@@ -6027,7 +5315,7 @@ var RelationService = class {
6027
5315
  const key = String(parentId);
6028
5316
  const arr = resultMap.get(key) || [];
6029
5317
  arr.push({
6030
- id: buildCompositeId(targetRow, targetPks),
5318
+ id: String(targetRow[targetIdInfo.fieldName]),
6031
5319
  path: targetCollection.slug,
6032
5320
  values: parsedValues
6033
5321
  });
@@ -6475,32 +5763,14 @@ var FetchService = class {
6475
5763
  return null;
6476
5764
  }
6477
5765
  /**
6478
- * The address a relation ref points at.
6479
- *
6480
- * The whole key, not its first column: a composite-keyed target addressed
6481
- * by `tenant_id` alone points at every row that shares it. And a target
6482
- * whose key cannot be resolved at all used to throw here — reading
6483
- * `targetPks[0]` of an empty array — taking down the parent's fetch over a
6484
- * relation it may not even have asked for; the first column is a guess, but
6485
- * a ref that resolves to nothing beats no rows at all.
6486
- */
6487
- relationTargetAddress(targetRow, targetCollection) {
6488
- const address = deriveRowAddress(targetRow, targetCollection, this.registry);
6489
- if (address) return address;
6490
- const firstColumn = targetRow[Object.keys(targetRow)[0]];
6491
- return String(firstColumn ?? "");
6492
- }
6493
- /**
6494
5766
  * Convert a db.query result row (with nested relation objects) to a flat row.
6495
5767
  * Handles:
5768
+ * - Placing `id` at the top level as a string
6496
5769
  * - Type normalization (dates, numbers, NaN) via normalizeDbValues
6497
5770
  * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
6498
5771
  * - Flattening junction-table many-to-many results
6499
- *
6500
- * The row's own address is not among them: it is derived by the consumer
6501
- * from the collection's primary keys.
6502
5772
  */
6503
- drizzleResultToRow(row, collection) {
5773
+ drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
6504
5774
  const resolvedRelations = resolveCollectionRelations(collection);
6505
5775
  const normalizedValues = normalizeDbValues(row, collection);
6506
5776
  for (const [key, relation] of Object.entries(resolvedRelations)) {
@@ -6509,13 +5779,14 @@ var FetchService = class {
6509
5779
  if (relation.cardinality === "many" && Array.isArray(relData)) {
6510
5780
  const targetCollection = relation.target();
6511
5781
  const targetPath = targetCollection.slug;
5782
+ const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
6512
5783
  normalizedValues[key] = relData.map((item) => {
6513
5784
  let targetRow = item;
6514
5785
  if (this.isJunctionRelation(relation, collection)) {
6515
5786
  const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6516
5787
  if (nestedKey) targetRow = item[nestedKey];
6517
5788
  }
6518
- const relId = this.relationTargetAddress(targetRow, targetCollection);
5789
+ const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
6519
5790
  return createRelationRefWithData(relId, targetPath, {
6520
5791
  id: relId,
6521
5792
  path: targetPath,
@@ -6525,8 +5796,9 @@ var FetchService = class {
6525
5796
  } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
6526
5797
  const targetCollection = relation.target();
6527
5798
  const targetPath = targetCollection.slug;
5799
+ const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
6528
5800
  const relObj = relData;
6529
- const relId = this.relationTargetAddress(relObj, targetCollection);
5801
+ const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
6530
5802
  normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
6531
5803
  id: relId,
6532
5804
  path: targetPath,
@@ -6534,7 +5806,10 @@ var FetchService = class {
6534
5806
  });
6535
5807
  }
6536
5808
  }
6537
- return normalizedValues;
5809
+ return {
5810
+ ...normalizedValues,
5811
+ id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
5812
+ };
6538
5813
  }
6539
5814
  /**
6540
5815
  * Post-fetch joinPath relations for a single flat row.
@@ -6557,6 +5832,31 @@ var FetchService = class {
6557
5832
  await Promise.all(promises);
6558
5833
  }
6559
5834
  /**
5835
+ * Post-fetch joinPath relations for a batch of flat rows.
5836
+ * Uses batch fetching to avoid N+1 queries for list views.
5837
+ */
5838
+ async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
5839
+ if (rows.length === 0) return;
5840
+ const resolvedRelations = resolveCollectionRelations(collection);
5841
+ const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
5842
+ if (joinPathRelations.length === 0) return;
5843
+ for (const [key, relation] of joinPathRelations) try {
5844
+ const rowIds = rows.map((r) => {
5845
+ return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
5846
+ });
5847
+ const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
5848
+ for (const row of rows) {
5849
+ const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
5850
+ const relatedRow = resultMap.get(String(id));
5851
+ if (relatedRow) {
5852
+ if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
5853
+ }
5854
+ }
5855
+ } catch (e) {
5856
+ logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
5857
+ }
5858
+ }
5859
+ /**
6560
5860
  * Resolves joinPath relations for raw REST rows and directly injects them.
6561
5861
  * Uses RelationService to query the database and maps results back to the flattened objects.
6562
5862
  */
@@ -6568,48 +5868,67 @@ var FetchService = class {
6568
5868
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
6569
5869
  if (joinPathRelations.length === 0) return;
6570
5870
  const idInfo = idInfoArray[0];
6571
- const parentIdOf = (row) => row[idInfo.fieldName];
6572
5871
  for (const [key, relation] of joinPathRelations) try {
6573
- const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
6574
- if (addressable.length === 0) continue;
6575
- const rowIds = addressable.map((r) => parentIdOf(r));
5872
+ const rowIds = rows.map((r) => {
5873
+ return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
5874
+ });
6576
5875
  if (relation.cardinality === "one") {
6577
5876
  const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
6578
- for (const row of addressable) {
6579
- const relatedRow = resultMap.get(String(parentIdOf(row)));
6580
- row[key] = relatedRow ? { ...relatedRow.values } : null;
5877
+ for (const row of rows) {
5878
+ const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
5879
+ const relatedRow = resultMap.get(String(id));
5880
+ if (relatedRow) row[key] = {
5881
+ ...relatedRow.values,
5882
+ id: relatedRow.id
5883
+ };
5884
+ else row[key] = null;
6581
5885
  }
6582
5886
  } else if (relation.cardinality === "many") {
6583
5887
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
6584
- for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
5888
+ for (const row of rows) {
5889
+ const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
5890
+ row[key] = (resultMap.get(String(id)) || []).map((e) => ({
5891
+ ...e.values,
5892
+ id: e.id
5893
+ }));
5894
+ }
6585
5895
  }
6586
5896
  } catch (e) {
6587
5897
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
6588
5898
  }
6589
5899
  }
6590
5900
  /**
6591
- * Convert a db.query result row to a flat REST-style row with populated relations.
6592
- *
6593
- * Every column is copied through under its own name, with the value Postgres
6594
- * returned. This used to open with a synthesized `id` and then skip the key
6595
- * column, which renamed it (a `sku` primary key was served as `id`, and `sku`
6596
- * did not appear at all) and restringified it (`42` → `"42"`). Consumers that
6597
- * need an address derive it from the collection's primary keys.
5901
+ * Convert a db.query result row to a flat REST-style object with populated relations.
6598
5902
  */
6599
- drizzleResultToRestRow(row, collection) {
6600
- const flat = {};
5903
+ drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
5904
+ const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
6601
5905
  const resolvedRelations = resolveCollectionRelations(collection);
6602
5906
  for (const [k, v] of Object.entries(row)) {
5907
+ if (k === idInfo.fieldName) continue;
6603
5908
  const relation = findRelation(resolvedRelations, k);
6604
5909
  if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
6605
5910
  if (this.isJunctionRelation(relation, collection)) {
6606
5911
  const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6607
- if (nestedKey) return { ...item[nestedKey] };
5912
+ if (nestedKey) {
5913
+ const nested = item[nestedKey];
5914
+ return {
5915
+ ...nested,
5916
+ id: String(nested.id ?? nested[Object.keys(nested)[0]])
5917
+ };
5918
+ }
6608
5919
  }
6609
- return { ...item };
5920
+ return {
5921
+ ...item,
5922
+ id: String(item.id ?? item[Object.keys(item)[0]])
5923
+ };
6610
5924
  });
6611
- else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) flat[k] = { ...v };
6612
- else flat[k] = v;
5925
+ else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
5926
+ const relObj = v;
5927
+ flat[k] = {
5928
+ ...relObj,
5929
+ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
5930
+ };
5931
+ } else flat[k] = v;
6613
5932
  }
6614
5933
  return flat;
6615
5934
  }
@@ -6697,7 +6016,7 @@ var FetchService = class {
6697
6016
  with: withConfig
6698
6017
  });
6699
6018
  if (!row) return void 0;
6700
- const flatRow = this.drizzleResultToRow(row, collection);
6019
+ const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
6701
6020
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6702
6021
  return flatRow;
6703
6022
  } catch (e) {
@@ -6749,7 +6068,7 @@ var FetchService = class {
6749
6068
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6750
6069
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6751
6070
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6752
- return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection));
6071
+ return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray));
6753
6072
  } catch (e) {
6754
6073
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6755
6074
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6820,7 +6139,8 @@ var FetchService = class {
6820
6139
  const parsedRows = await Promise.all(results.map(async (rawRow) => {
6821
6140
  return {
6822
6141
  rawRow,
6823
- values: await parseDataFromServer(rawRow, collection)
6142
+ values: await parseDataFromServer(rawRow, collection),
6143
+ id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
6824
6144
  };
6825
6145
  }));
6826
6146
  if (!skipRelations) {
@@ -6860,7 +6180,10 @@ var FetchService = class {
6860
6180
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
6861
6181
  }
6862
6182
  }
6863
- return parsedRows.map((item) => item.values);
6183
+ return parsedRows.map((item) => ({
6184
+ ...item.values,
6185
+ id: item.id
6186
+ }));
6864
6187
  }
6865
6188
  /**
6866
6189
  * Fetch a collection of rows
@@ -6891,7 +6214,10 @@ var FetchService = class {
6891
6214
  const relationKey = pathSegments[i];
6892
6215
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
6893
6216
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
6894
- if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
6217
+ if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
6218
+ ...row.values,
6219
+ id: row.id
6220
+ }));
6895
6221
  if (i + 1 < pathSegments.length) {
6896
6222
  const nextEntityId = pathSegments[i + 1];
6897
6223
  currentCollection = relation.target();
@@ -6988,7 +6314,7 @@ var FetchService = class {
6988
6314
  if (qb && !options.searchString && !options.vectorSearch) try {
6989
6315
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
6990
6316
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
6991
- const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection));
6317
+ const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray));
6992
6318
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
6993
6319
  return restRows;
6994
6320
  } catch (e) {
@@ -6999,7 +6325,10 @@ var FetchService = class {
6999
6325
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
7000
6326
  }
7001
6327
  const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
7002
- if (!include || include.length === 0) return rows;
6328
+ if (!include || include.length === 0) return rows.map((row) => ({
6329
+ ...row,
6330
+ id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6331
+ }));
7003
6332
  const resolvedRelations = resolveCollectionRelations(collection);
7004
6333
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
7005
6334
  const shouldInclude = (key) => include[0] === "*" || include.includes(key);
@@ -7011,7 +6340,10 @@ var FetchService = class {
7011
6340
  for (const row of rows) {
7012
6341
  const eid = row[idInfo.fieldName];
7013
6342
  const related = batchResults.get(String(eid));
7014
- if (related) row[key] = { ...related.values };
6343
+ if (related) row[key] = {
6344
+ ...related.values,
6345
+ id: related.id
6346
+ };
7015
6347
  }
7016
6348
  } catch (e) {
7017
6349
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
@@ -7029,7 +6361,10 @@ var FetchService = class {
7029
6361
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
7030
6362
  }
7031
6363
  }
7032
- return rows;
6364
+ return rows.map((row) => ({
6365
+ ...row,
6366
+ id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6367
+ }));
7033
6368
  }
7034
6369
  /**
7035
6370
  * Fetch a single row with optional relation includes for REST API.
@@ -7050,7 +6385,7 @@ var FetchService = class {
7050
6385
  ...withConfig ? { with: withConfig } : {}
7051
6386
  });
7052
6387
  if (!row) return null;
7053
- const restRow = this.drizzleResultToRestRow(row, collection);
6388
+ const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
7054
6389
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
7055
6390
  return restRow;
7056
6391
  } catch (e) {
@@ -7062,7 +6397,11 @@ var FetchService = class {
7062
6397
  }
7063
6398
  const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
7064
6399
  if (result.length === 0) return null;
7065
- const flatEntity = { ...result[0] };
6400
+ const raw = result[0];
6401
+ const flatEntity = {
6402
+ ...raw,
6403
+ id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
6404
+ };
7066
6405
  if (!include || include.length === 0) return flatEntity;
7067
6406
  const resolvedRelations = resolveCollectionRelations(collection);
7068
6407
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
@@ -7174,15 +6513,32 @@ var FetchService = class {
7174
6513
  if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
7175
6514
  }
7176
6515
  return (await queryTarget.findMany(queryOpts)).map((row) => {
7177
- const flat = {};
7178
- for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
7179
- const keys = Object.keys(item);
7180
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
7181
- if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
7182
- return { ...item };
7183
- });
7184
- else if (typeof v === "object" && v !== null) flat[k] = { ...v };
7185
- else flat[k] = v;
6516
+ const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
6517
+ for (const [k, v] of Object.entries(row)) {
6518
+ if (k === idInfo.fieldName) continue;
6519
+ if (Array.isArray(v)) flat[k] = v.map((item) => {
6520
+ const keys = Object.keys(item);
6521
+ const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6522
+ if (nestedObj && keys.length <= 3) {
6523
+ const nested = item[nestedObj];
6524
+ return {
6525
+ ...nested,
6526
+ id: String(nested.id ?? nested[Object.keys(nested)[0]])
6527
+ };
6528
+ }
6529
+ return {
6530
+ ...item,
6531
+ id: String(item.id ?? item[Object.keys(item)[0]])
6532
+ };
6533
+ });
6534
+ else if (typeof v === "object" && v !== null) {
6535
+ const relObj = v;
6536
+ flat[k] = {
6537
+ ...relObj,
6538
+ id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
6539
+ };
6540
+ } else flat[k] = v;
6541
+ }
7186
6542
  return flat;
7187
6543
  });
7188
6544
  } catch (e) {
@@ -7398,27 +6754,6 @@ var PersistService = class {
7398
6754
  this.fetchService = new FetchService(db, registry);
7399
6755
  }
7400
6756
  /**
7401
- * Explain a write that matched no rows.
7402
- *
7403
- * Row-level security filters UPDATE and DELETE through the policy's USING
7404
- * clause instead of raising: a denied write is reported by Postgres exactly
7405
- * like a successful one that happened to match nothing. Left unchecked, a
7406
- * caller cannot tell "denied" from "done" — the write returns 200/204 and
7407
- * the row is untouched.
7408
- *
7409
- * Re-reading the target over the *same* RLS-scoped handle separates the two
7410
- * cases. A visible row means the policy rejected the write (403); an
7411
- * invisible one means there is nothing there to write for this caller (404,
7412
- * matching what a GET would say). The re-read is bound by the caller's own
7413
- * policies, so it discloses nothing a plain read wouldn't.
7414
- *
7415
- * Only reached when zero rows matched, so the happy path pays nothing.
7416
- */
7417
- async explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
7418
- if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(`Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`, "WRITE_DENIED");
7419
- return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
7420
- }
7421
- /**
7422
6757
  * Delete an row by ID
7423
6758
  */
7424
6759
  async delete(collectionPath, id, _databaseId) {
@@ -7432,7 +6767,7 @@ var PersistService = class {
7432
6767
  if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
7433
6768
  conditions.push(eq(field, parsedIdObj[info.fieldName]));
7434
6769
  }
7435
- if (((await this.db.delete(table).where(and(...conditions))).rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
6770
+ await this.db.delete(table).where(and(...conditions));
7436
6771
  }
7437
6772
  /**
7438
6773
  * Delete all rows from a collection
@@ -7443,14 +6778,8 @@ var PersistService = class {
7443
6778
  }
7444
6779
  /**
7445
6780
  * Save an row (create or update)
7446
- *
7447
- * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
7448
- * UPDATE against the primary key rather than a plain UPDATE. That is one
7449
- * statement, so it cannot lose a race the way a read-then-write can, and it
7450
- * does not care whether the row already exists — which is what a re-runnable
7451
- * import needs.
7452
6781
  */
7453
- async save(collectionPath, values, id, databaseId, options) {
6782
+ async save(collectionPath, values, id, databaseId) {
7454
6783
  let effectiveCollectionPath = collectionPath;
7455
6784
  const effectiveValues = { ...values };
7456
6785
  let junctionTableInfo;
@@ -7541,7 +6870,7 @@ var PersistService = class {
7541
6870
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
7542
6871
  savedId = await this.db.transaction(async (tx) => {
7543
6872
  let currentId;
7544
- if (id && !options?.upsert) {
6873
+ if (id) {
7545
6874
  currentId = id;
7546
6875
  const idValues = parseIdValues(id, idInfoArray);
7547
6876
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
@@ -7552,28 +6881,13 @@ var PersistService = class {
7552
6881
  const field = table[info.fieldName];
7553
6882
  conditions.push(eq(field, idValues[info.fieldName]));
7554
6883
  }
7555
- if (((await updateQuery.where(and(...conditions))).rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(tx, table, conditions, effectiveCollectionPath, currentId, "update");
6884
+ await updateQuery.where(and(...conditions));
7556
6885
  }
7557
6886
  } else {
7558
6887
  const dataForInsert = { ...entityData };
7559
- if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
7560
6888
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
7561
- const insertQuery = tx.insert(table).values(dataForInsert);
7562
- const hasFullKey = idInfoArray.length > 0 && idInfoArray.every((info) => dataForInsert[info.fieldName] !== void 0);
7563
- let result;
7564
- if (options?.upsert && hasFullKey) {
7565
- const target = idInfoArray.map((info) => table[info.fieldName]);
7566
- const set = { ...dataForInsert };
7567
- for (const info of idInfoArray) delete set[info.fieldName];
7568
- result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
7569
- target,
7570
- set
7571
- }).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
7572
- } else result = await insertQuery.returning(returningKeys);
7573
- const resultRow = result[0];
7574
- if (!resultRow) if (id) currentId = id;
7575
- else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
7576
- else currentId = buildCompositeId(resultRow, idInfoArray);
6889
+ const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
6890
+ currentId = buildCompositeId(resultRow, idInfoArray);
7577
6891
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
7578
6892
  }
7579
6893
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
@@ -7604,7 +6918,6 @@ var PersistService = class {
7604
6918
  * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
7605
6919
  */
7606
6920
  toUserFriendlyError(error, collectionSlug) {
7607
- if (error instanceof ApiError || error?.name === "ApiError") return error;
7608
6921
  const pgError = extractPgError(error);
7609
6922
  if (pgError) {
7610
6923
  const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
@@ -7682,8 +6995,8 @@ var DataService = class {
7682
6995
  /**
7683
6996
  * Save an row (create or update)
7684
6997
  */
7685
- async save(collectionPath, values, id, databaseId, options) {
7686
- return this.persistService.save(collectionPath, values, id, databaseId, options);
6998
+ async save(collectionPath, values, id, databaseId) {
6999
+ return this.persistService.save(collectionPath, values, id, databaseId);
7687
7000
  }
7688
7001
  /**
7689
7002
  * Delete an row by ID
@@ -7751,26 +7064,6 @@ var DataService = class {
7751
7064
  */
7752
7065
  /** Internal prefix applied to branch database names to avoid collisions. */
7753
7066
  var BRANCH_DB_PREFIX = "rb_";
7754
- /** `duplicate_database` — the target database name is already taken. */
7755
- var PG_DUPLICATE_DATABASE = "42P04";
7756
- /** `object_in_use` — the database still has connections attached. */
7757
- var PG_OBJECT_IN_USE = "55006";
7758
- /**
7759
- * Describe a failed branch DDL statement in terms a user can act on.
7760
- *
7761
- * Drizzle reports failures as `Failed query: <sql> params:` and hides the real
7762
- * PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
7763
- * the actual problem. Match on the PG error code instead — it survives wrapping
7764
- * and, unlike the message text, is not locale-dependent.
7765
- */
7766
- function describeBranchDdlError(err, fallbackContext) {
7767
- const pgError = extractPgError(err);
7768
- if (pgError?.code === PG_DUPLICATE_DATABASE) return /* @__PURE__ */ new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
7769
- 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.`);
7770
- const detail = pgError?.message ?? extractCauseMessage(err);
7771
- if (detail) return new Error(detail);
7772
- return err instanceof Error ? err : new Error(String(err));
7773
- }
7774
7067
  /** Fully-qualified metadata table in the rebase schema. */
7775
7068
  var BRANCHES_TABLE = "rebase.branches";
7776
7069
  /**
@@ -7839,8 +7132,10 @@ var BranchService = class {
7839
7132
  try {
7840
7133
  await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
7841
7134
  } catch (err) {
7842
- 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.`);
7843
- throw describeBranchDdlError(err, dbName);
7135
+ const msg = err instanceof Error ? err.message : String(err);
7136
+ if (msg.includes("already exists")) throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
7137
+ if (msg.includes("being accessed by other users")) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
7138
+ throw err;
7844
7139
  }
7845
7140
  const now = /* @__PURE__ */ new Date();
7846
7141
  await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
@@ -7865,8 +7160,8 @@ var BranchService = class {
7865
7160
  try {
7866
7161
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
7867
7162
  } catch (err) {
7868
- 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.`);
7869
- throw describeBranchDdlError(err, dbName);
7163
+ if ((err instanceof Error ? err.message : String(err)).includes("being accessed by other users")) throw new Error(`Cannot delete branch "${sanitizedName}": the database has active connections. Close other clients and try again.`);
7164
+ throw err;
7870
7165
  }
7871
7166
  await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
7872
7167
  }
@@ -8498,7 +7793,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8498
7793
  this.realtimeService.subscriptions.delete(subscriptionId);
8499
7794
  };
8500
7795
  }
8501
- async save({ path, id, values, collection, status, upsert }) {
7796
+ async save({ path, id, values, collection, status }) {
8502
7797
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
8503
7798
  let updatedValues = values;
8504
7799
  const contextForCallback = this.buildCallContext();
@@ -8555,7 +7850,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8555
7850
  timestampNowValue: /* @__PURE__ */ new Date()
8556
7851
  });
8557
7852
  try {
8558
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
7853
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
8559
7854
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
8560
7855
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
8561
7856
  collection: resolvedCollection,
@@ -8576,8 +7871,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8576
7871
  context: contextForCallback
8577
7872
  });
8578
7873
  }
8579
- const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
8580
- const savedValues = savedRow;
7874
+ const savedId = savedRow.id;
7875
+ const { id: _savedId, ...savedValues } = savedRow;
8581
7876
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
8582
7877
  if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
8583
7878
  collection: resolvedCollection,
@@ -8609,7 +7904,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8609
7904
  }
8610
7905
  if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
8611
7906
  tableName: path,
8612
- id: savedId,
7907
+ id: savedId.toString(),
8613
7908
  action: status === "new" ? "create" : "update",
8614
7909
  values: savedValues,
8615
7910
  previousValues: previousValuesForHistory,
@@ -8617,11 +7912,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8617
7912
  });
8618
7913
  if (this._deferNotifications) this._pendingNotifications.push({
8619
7914
  path,
8620
- id: savedId,
7915
+ id: savedId.toString(),
8621
7916
  row: savedRow,
8622
7917
  databaseId: resolvedCollection?.databaseId
8623
7918
  });
8624
- else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
7919
+ else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
8625
7920
  return savedRow;
8626
7921
  } catch (error) {
8627
7922
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
@@ -8656,52 +7951,12 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8656
7951
  throw error;
8657
7952
  }
8658
7953
  }
8659
- /**
8660
- * Write many rows through the same pipeline as {@link save}.
8661
- *
8662
- * The batch runs in one transaction of its own, so a failure part-way leaves
8663
- * nothing behind — the point of a batch is that a re-run starts from a known
8664
- * state. When this driver is already inside a transaction (the authenticated
8665
- * path, via `withTransaction`) the nested call becomes a savepoint, which is
8666
- * still atomic and still commits once.
8667
- *
8668
- * Rows are applied in order, so a batch that touches the same key twice ends
8669
- * with the last write winning, exactly as separate calls would.
8670
- */
8671
- async saveMany({ path, rows, collection, upsert }) {
8672
- return this.db.transaction(async (tx) => {
8673
- const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
8674
- txDriver.dataService = new DataService(tx, this.registry);
8675
- txDriver.client = this.client;
8676
- txDriver._deferNotifications = this._deferNotifications;
8677
- txDriver._pendingNotifications = this._pendingNotifications;
8678
- const saved = [];
8679
- for (let i = 0; i < rows.length; i++) {
8680
- const values = rows[i];
8681
- const id = values?.id;
8682
- try {
8683
- saved.push(await txDriver.save({
8684
- path,
8685
- values,
8686
- collection,
8687
- status: "new",
8688
- upsert
8689
- }));
8690
- } catch (error) {
8691
- const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
8692
- throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
8693
- statusCode: error?.statusCode,
8694
- code: error?.code,
8695
- name: error?.name
8696
- });
8697
- }
8698
- }
8699
- return saved;
8700
- });
8701
- }
8702
7954
  async delete({ row, collection }) {
8703
7955
  const targetPath = row.path;
8704
- const targetRow = { ...row.values ?? {} };
7956
+ const targetRow = {
7957
+ id: row.id,
7958
+ ...row.values ?? {}
7959
+ };
8705
7960
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
8706
7961
  const contextForCallback = this.buildCallContext();
8707
7962
  if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -9060,18 +8315,6 @@ var AuthenticatedPostgresBackendDriver = class {
9060
8315
  async save(props) {
9061
8316
  return this.withTransaction((delegate) => delegate.save(props));
9062
8317
  }
9063
- /**
9064
- * One transaction for the whole batch, rather than one per row.
9065
- *
9066
- * This is the point of the method: `save` opens a transaction per call, so
9067
- * importing 10k rows through it means 10k transactions (and, over HTTP, 10k
9068
- * round trips). Here the RLS context is established once and every row lands
9069
- * or none does. Realtime notifications are already deferred to commit by
9070
- * `withTransaction`, so a batch does not flood subscribers mid-flight.
9071
- */
9072
- async saveMany(props) {
9073
- return this.withTransaction((delegate) => delegate.saveMany(props));
9074
- }
9075
8318
  async delete(props) {
9076
8319
  return this.withTransaction((delegate) => delegate.delete(props));
9077
8320
  }
@@ -9318,6 +8561,105 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
9318
8561
  references: [users.id]
9319
8562
  }) }));
9320
8563
  //#endregion
8564
+ //#region src/schema/auth-default-policies.ts
8565
+ /**
8566
+ * Default RLS policies injected by the schema generator.
8567
+ *
8568
+ * Rebase's enforcement model is unified: authenticated (user-context) requests
8569
+ * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
8570
+ * statement — reads and writes. A collection's `securityRules` are the whole
8571
+ * authorization model. The server context (auth flows, migrations,
8572
+ * `dataAsAdmin`) runs as the owner and bypasses RLS.
8573
+ *
8574
+ * Because RLS default-denies, every collection is **locked by default**: with
8575
+ * no rules, only the server context and admins can touch it. The generator
8576
+ * injects that safe baseline:
8577
+ *
8578
+ * **For every collection**
8579
+ * 1. A permissive **server-or-admin SELECT** grant.
8580
+ * 2. A permissive **server-or-admin write** grant (insert/update/delete).
8581
+ *
8582
+ * Author `securityRules` are permissive and OR together, so explicit rules only
8583
+ * *broaden* access from this locked baseline (e.g. "users read/write their own
8584
+ * rows").
8585
+ *
8586
+ * **For auth collections additionally**
8587
+ * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
8588
+ * their own row (profile, session bootstrap) without every app re-declaring
8589
+ * it.
8590
+ * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
8591
+ * every other policy, so a write is rejected unless the caller is an admin
8592
+ * (or the server context) — even if the author also wrote a permissive rule
8593
+ * such as "a user may edit their own row". Without this, a permissive owner
8594
+ * rule would let a user change their own `roles`.
8595
+ *
8596
+ * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
8597
+ * — the built-in flows that run without a user (signup, migrations) set no user
8598
+ * GUC — which also lets the owner connection satisfy these policies even under
8599
+ * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
8600
+ * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
8601
+ *
8602
+ * Opt out with `disableDefaultPolicies: true` to take full responsibility for
8603
+ * the collection's RLS.
8604
+ */
8605
+ var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
8606
+ /** Write operations that must be admin-gated by default on auth collections. */
8607
+ var DEFAULT_GUARDED_OPS = [
8608
+ "insert",
8609
+ "update",
8610
+ "delete"
8611
+ ];
8612
+ /** Whether a collection is flagged as an authentication collection. */
8613
+ function isAuthCollection(collection) {
8614
+ const auth = collection.auth;
8615
+ return auth === true || typeof auth === "object" && auth?.enabled === true;
8616
+ }
8617
+ /** The property marked as the row id (falls back to `id`). */
8618
+ function getIdPropertyName(collection) {
8619
+ for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
8620
+ return "id";
8621
+ }
8622
+ /**
8623
+ * Returns the security rules that should be applied to a collection: the
8624
+ * author's explicit `securityRules` plus the framework defaults described in
8625
+ * the module doc (baseline server/admin read for all collections; self-read
8626
+ * and the admin write gate for auth collections).
8627
+ *
8628
+ * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
8629
+ */
8630
+ function getEffectiveSecurityRules(collection) {
8631
+ const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
8632
+ if (collection.disableDefaultPolicies) return explicit;
8633
+ const tableName = getTableName$1(collection);
8634
+ const injected = [];
8635
+ injected.push({
8636
+ name: `${tableName}_default_admin_read`,
8637
+ operations: ["select"],
8638
+ condition: SERVER_OR_ADMIN_EXPR
8639
+ });
8640
+ injected.push({
8641
+ name: `${tableName}_default_admin_write`,
8642
+ operations: [...DEFAULT_GUARDED_OPS],
8643
+ condition: SERVER_OR_ADMIN_EXPR,
8644
+ check: SERVER_OR_ADMIN_EXPR
8645
+ });
8646
+ if (isAuthCollection(collection)) {
8647
+ injected.push({
8648
+ name: `${tableName}_default_self_read`,
8649
+ operations: ["select"],
8650
+ condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
8651
+ });
8652
+ injected.push({
8653
+ name: `${tableName}_require_admin_write`,
8654
+ mode: "restrictive",
8655
+ operations: [...DEFAULT_GUARDED_OPS],
8656
+ condition: SERVER_OR_ADMIN_EXPR,
8657
+ check: SERVER_OR_ADMIN_EXPR
8658
+ });
8659
+ }
8660
+ return [...explicit, ...injected];
8661
+ }
8662
+ //#endregion
9321
8663
  //#region src/schema/generate-drizzle-schema-logic.ts
9322
8664
  /**
9323
8665
  * Resolve the SQL column name for a property.
@@ -9515,12 +8857,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
9515
8857
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
9516
8858
  */
9517
8859
  var wrapSql = (clause) => `sql\`${clause}\``;
8860
+ /**
8861
+ * Generates a deterministic hash based on the rule configuration.
8862
+ */
8863
+ var getPolicyNameHash = (rule) => {
8864
+ const data = JSON.stringify({
8865
+ a: rule.access,
8866
+ m: rule.mode,
8867
+ op: rule.operation,
8868
+ ops: rule.operations?.slice().sort(),
8869
+ own: rule.ownerField,
8870
+ rol: rule.roles?.slice().sort(),
8871
+ pg: rule.pgRoles?.slice().sort(),
8872
+ u: rule.using,
8873
+ w: rule.withCheck,
8874
+ c: rule.condition,
8875
+ ch: rule.check
8876
+ });
8877
+ return createHash("sha1").update(data).digest("hex").substring(0, 7);
8878
+ };
9518
8879
  var generatePolicyCode = (collection, rule, index, resolveCollection) => {
9519
8880
  const tableName = getTableName$1(collection);
9520
8881
  const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
9521
- const policyNames = getPolicyNamesForRule(rule, tableName);
8882
+ const ruleHash = getPolicyNameHash(rule);
9522
8883
  return ops.map((op, opIdx) => {
9523
- return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
8884
+ return generateSinglePolicyCode(collection, rule, op, rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`, resolveCollection);
9524
8885
  }).join("");
9525
8886
  };
9526
8887
  /**
@@ -9649,7 +9010,6 @@ var generateSchema = async (collections, stripPolicies = false) => {
9649
9010
  });
9650
9011
  });
9651
9012
  schemaContent += "\n";
9652
- const junctionSpecs = resolveJunctionSpecs(collections);
9653
9013
  for (const collection of collections) {
9654
9014
  const tableName = getTableName$1(collection);
9655
9015
  if (tableName) allTablesToGenerate.set(tableName, { collection });
@@ -9683,17 +9043,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
9683
9043
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
9684
9044
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
9685
9045
  schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
9686
- schemaContent += "}, (table) => ([\n";
9687
- schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
9688
- const junctionSpec = junctionSpecs.get(baseTableName);
9689
- if (!stripPolicies && junctionSpec) {
9690
- const junctionCollection = getJunctionCollectionConfig(junctionSpec);
9691
- const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
9692
- getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
9693
- schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
9694
- });
9695
- }
9696
- schemaContent += "])).enableRLS();\n\n";
9046
+ schemaContent += "}, (table) => ({\n";
9047
+ schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
9048
+ schemaContent += "}));\n\n";
9697
9049
  } else if (!isJunction) {
9698
9050
  const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
9699
9051
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -10384,7 +9736,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10384
9736
  startAfter: request.startAfter,
10385
9737
  searchString: request.searchString
10386
9738
  }, authContext);
10387
- this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
9739
+ this.sendCollectionUpdate(clientId, subscriptionId, rows);
10388
9740
  } catch (error) {
10389
9741
  const sanitized = sanitizeErrorForClient(error, request.path);
10390
9742
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10485,7 +9837,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10485
9837
  if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
10486
9838
  else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
10487
9839
  else if (subscription.type === "collection" && subscription.collectionRequest) {
10488
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
9840
+ if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
10489
9841
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
10490
9842
  }
10491
9843
  } catch (error) {
@@ -10515,7 +9867,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10515
9867
  if (!this._subscriptions.has(subscriptionId)) return;
10516
9868
  try {
10517
9869
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
10518
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
9870
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
10519
9871
  } catch (error) {
10520
9872
  const sanitized = sanitizeErrorForClient(error, notifyPath);
10521
9873
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10729,12 +10081,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10729
10081
  }
10730
10082
  return await this.dataService.fetchOne(notifyPath, id);
10731
10083
  }
10732
- sendCollectionUpdate(clientId, subscriptionId, rows, path) {
10084
+ sendCollectionUpdate(clientId, subscriptionId, rows) {
10733
10085
  const message = {
10734
10086
  type: "collection_update",
10735
10087
  subscriptionId,
10736
- rows,
10737
- pks: this.primaryKeysForPath(path)
10088
+ rows
10738
10089
  };
10739
10090
  this.sendMessage(clientId, message);
10740
10091
  }
@@ -10749,33 +10100,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10749
10100
  /**
10750
10101
  * Send a lightweight row-level patch to a collection subscriber.
10751
10102
  * The client can merge this into its cached data for instant feedback.
10752
- *
10753
- * The key columns ride along: the patch names a row by address, and the
10754
- * client has to find that row among the ones it cached — which carry
10755
- * columns and no address. The SDK holds no collection config to derive one
10756
- * from, so this is the only place the mapping can come from.
10757
10103
  */
10758
- sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
10104
+ sendCollectionPatch(clientId, subscriptionId, id, row) {
10759
10105
  const message = {
10760
10106
  type: "collection_patch",
10761
10107
  subscriptionId,
10762
10108
  id,
10763
- row,
10764
- pks: this.primaryKeysForPath(notifyPath)
10109
+ row
10765
10110
  };
10766
10111
  this.sendMessage(clientId, message);
10767
10112
  }
10768
- /** The key columns of the collection at `path`, if they can be resolved. */
10769
- primaryKeysForPath(path) {
10770
- try {
10771
- const collection = this.registry.getCollectionByPath(path);
10772
- if (!collection) return void 0;
10773
- const keys = getPrimaryKeys(collection, this.registry);
10774
- return keys.length > 0 ? keys : void 0;
10775
- } catch {
10776
- return;
10777
- }
10778
- }
10779
10113
  sendError(clientId, error, subscriptionId, code) {
10780
10114
  const message = {
10781
10115
  type: "error",
@@ -11023,7 +10357,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11023
10357
  }
11024
10358
  /** Compute the canonical (possibly composite) id string from a captured row. */
11025
10359
  extractIdFromCdcRow(collection, row) {
11026
- return deriveRowAddress(row, collection, this.registry) || "*";
10360
+ try {
10361
+ const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
10362
+ if (composite && composite !== ":::") return composite;
10363
+ } catch {}
10364
+ if (row.id !== void 0 && row.id !== null) return String(row.id);
10365
+ return "*";
11027
10366
  }
11028
10367
  dedupKey(path, id, databaseId) {
11029
10368
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -11639,7 +10978,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11639
10978
  } catch (error) {
11640
10979
  logger.error("💥 [WebSocket Server] Error handling message", { error });
11641
10980
  if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
11642
- const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
10981
+ const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error instanceof Error ? error.message : "An unexpected error occurred";
11643
10982
  const errorResponse = {
11644
10983
  type: "ERROR",
11645
10984
  requestId,
@@ -20483,33 +19822,6 @@ function formatBytes(bytes) {
20483
19822
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
20484
19823
  }
20485
19824
  //#endregion
20486
- //#region src/collections/buildRegistry.ts
20487
- /**
20488
- * Build the collection registry for a driver.
20489
- *
20490
- * The order matters and is the reason this is one function rather than a run of
20491
- * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
20492
- * anything that inspects them has to run *after* the tables are registered —
20493
- * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
20494
- * collection whose table it cannot look up is one it has nothing to say about.
20495
- * Warned too early, it would skip every collection and report nothing, which
20496
- * reads exactly like having nothing to report.
20497
- */
20498
- function buildCollectionRegistry(schema) {
20499
- const registry = new PostgresCollectionRegistry();
20500
- if (schema.collections) {
20501
- registry.registerMultiple(schema.collections);
20502
- logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
20503
- }
20504
- if (schema.tables) Object.values(schema.tables).forEach((table) => {
20505
- if (isTable(table)) registry.registerTable(table, getTableName(table));
20506
- });
20507
- if (schema.enums) registry.registerEnums(schema.enums);
20508
- if (schema.relations) registry.registerRelations(schema.relations);
20509
- warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
20510
- return registry;
20511
- }
20512
- //#endregion
20513
19825
  //#region src/auth/ensure-tables.ts
20514
19826
  /**
20515
19827
  * Auto-create auth tables if they don't exist.
@@ -22492,14 +21804,21 @@ function createPostgresBootstrapper(pgConfig) {
22492
21804
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
22493
21805
  }
22494
21806
  const activeCollections = introspectedCollections ?? collections;
21807
+ const registry = new PostgresCollectionRegistry();
21808
+ if (activeCollections) {
21809
+ registry.registerMultiple(activeCollections);
21810
+ logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
21811
+ }
22495
21812
  const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
22496
- const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
22497
- const registry = buildCollectionRegistry({
22498
- collections: activeCollections,
22499
- tables: schemaTables,
22500
- enums: pgConfig.schema?.enums,
22501
- relations: schemaRelations
21813
+ if (schemaTables) Object.values(schemaTables).forEach((table) => {
21814
+ if (isTable(table)) {
21815
+ const tableName = getTableName(table);
21816
+ registry.registerTable(table, tableName);
21817
+ }
22502
21818
  });
21819
+ if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
21820
+ const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
21821
+ if (schemaRelations) registry.registerRelations(schemaRelations);
22503
21822
  if (schemaTables) patchPgArrayNullSafety(schemaTables);
22504
21823
  const mergedSchema = {
22505
21824
  ...schemaTables,