@rebasepro/server-postgres 0.9.1-canary.ad25bc0 → 0.9.1-canary.ad870eb

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,98 +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
- * The driver can also infer keys from the Drizzle schema, which the browser
1812
- * cannot see — so the server normalizes what it resolved onto the config it
1813
- * serves (see `stampPrimaryKeys`), and this reads that back. Returns an empty
1814
- * array when a collection declares none, which callers must treat as "not
1815
- * addressable" rather than defaulting to `id`: guessing a key that is not the
1816
- * real one produces confidently wrong addresses.
1817
- */
1818
- function getDeclaredPrimaryKeys(collection) {
1819
- const properties = collection.properties;
1820
- if (!properties) return [];
1821
- const keys = [];
1822
- for (const [fieldName, propRaw] of Object.entries(properties)) {
1823
- const prop = propRaw;
1824
- if (!prop || typeof prop !== "object") continue;
1825
- if (!("isId" in prop) || !prop.isId) continue;
1826
- keys.push({
1827
- fieldName,
1828
- type: prop.type === "number" ? "number" : "string",
1829
- isUUID: prop.isId === "uuid"
1830
- });
1831
- }
1832
- return keys;
1833
- }
1834
- /**
1835
- * The keys to address a collection's rows with, resolved the way the driver
1836
- * resolves them — minus the tier the browser cannot reach.
1837
- *
1838
- * The postgres driver tries, in order: properties marked `isId`; the primary
1839
- * keys of the Drizzle schema; and finally a column literally named `id`. Only
1840
- * the first and last are visible in a `CollectionConfig`, which is what both
1841
- * sides share — so a collection whose key is *only* known to Drizzle, and is
1842
- * not named `id`, resolves to nothing here. That is reported rather than
1843
- * guessed: inventing a key produces addresses that look right and route wrong.
1844
- */
1845
- function resolvePrimaryKeys(collection) {
1846
- const declared = getDeclaredPrimaryKeys(collection);
1847
- if (declared.length > 0) return declared;
1848
- const idProp = collection.properties?.id;
1849
- if (idProp && typeof idProp === "object") return [{
1850
- fieldName: "id",
1851
- type: idProp.type === "number" ? "number" : "string"
1852
- }];
1853
- return [];
1854
- }
1855
1629
  //#endregion
1856
1630
  //#region ../common/src/util/enums.ts
1857
1631
  function enumToObjectEntries(enumValues) {
@@ -2086,109 +1860,15 @@ function getSubcollections(collection) {
2086
1860
  * - `field = 'literal'`
2087
1861
  * - `field != 'literal'`
2088
1862
  * - `field = current_setting('app.user_id')`
2089
- * - `A AND B`, `A OR B` — only where the keyword is at the top level
1863
+ * - `A AND B`
2090
1864
  * - `true`
2091
1865
  * - `IN (...)` (as optimistic true)
2092
1866
  *
2093
1867
  * For anything it doesn't understand, it returns a `raw` expression, which
2094
1868
  * the evaluator treats as "unknown" (and usually optimistic true).
2095
- *
2096
- * **This output also round-trips back into DDL** via `policyToPostgres` (the
2097
- * schema/policy generators), so decomposing a clause the parser only partly
2098
- * understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
2099
- * prefer `raw`: it is reproduced verbatim.
2100
- */
2101
- /** True when `keyword` starts at `i` as a standalone word. */
2102
- function isKeywordAt(upper, i, keyword) {
2103
- if (!upper.startsWith(keyword, i)) return false;
2104
- const before = i === 0 ? " " : upper[i - 1];
2105
- const after = upper[i + keyword.length] ?? " ";
2106
- return /[\s()]/.test(before) && /[\s()]/.test(after);
2107
- }
2108
- /**
2109
- * Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
2110
- * outside a string literal. Returns null when it never does, so the caller
2111
- * leaves the clause alone.
2112
- *
2113
- * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
2114
- * `AND` inside
2115
- * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
2116
- * split the expression, and re-emitting the halves produced
2117
- * `(EXISTS (...) AND m.user_id = auth.uid())`
2118
- * where `m` is no longer in scope — SQL that Postgres rejects outright with
2119
- * "missing FROM-clause entry for table". Returning null instead keeps such a
2120
- * clause as a `raw` expression, which round-trips verbatim.
2121
1869
  */
2122
- function splitTopLevel(sql, keyword) {
2123
- const upper = sql.toUpperCase();
2124
- const parts = [];
2125
- let depth = 0;
2126
- let inString = false;
2127
- let start = 0;
2128
- for (let i = 0; i < sql.length; i++) {
2129
- const ch = sql[i];
2130
- if (inString) {
2131
- if (ch === "'") if (sql[i + 1] === "'") i++;
2132
- else inString = false;
2133
- continue;
2134
- }
2135
- if (ch === "'") {
2136
- inString = true;
2137
- continue;
2138
- }
2139
- if (ch === "(") {
2140
- depth++;
2141
- continue;
2142
- }
2143
- if (ch === ")") {
2144
- depth--;
2145
- continue;
2146
- }
2147
- if (depth === 0 && isKeywordAt(upper, i, keyword)) {
2148
- parts.push(sql.slice(start, i));
2149
- i += keyword.length - 1;
2150
- start = i + 1;
2151
- }
2152
- }
2153
- if (parts.length === 0) return null;
2154
- parts.push(sql.slice(start));
2155
- const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
2156
- return trimmedParts.length > 1 ? trimmedParts : null;
2157
- }
2158
- /** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
2159
- function stripOuterParens(sql) {
2160
- let s = sql.trim();
2161
- for (;;) {
2162
- if (!s.startsWith("(") || !s.endsWith(")")) return s;
2163
- let depth = 0;
2164
- let inString = false;
2165
- let wraps = true;
2166
- for (let i = 0; i < s.length; i++) {
2167
- const ch = s[i];
2168
- if (inString) {
2169
- if (ch === "'") if (s[i + 1] === "'") i++;
2170
- else inString = false;
2171
- continue;
2172
- }
2173
- if (ch === "'") {
2174
- inString = true;
2175
- continue;
2176
- }
2177
- if (ch === "(") depth++;
2178
- else if (ch === ")") {
2179
- depth--;
2180
- if (depth === 0 && i < s.length - 1) {
2181
- wraps = false;
2182
- break;
2183
- }
2184
- }
2185
- }
2186
- if (!wraps) return s;
2187
- s = s.slice(1, -1).trim();
2188
- }
2189
- }
2190
1870
  function sqlToPolicy(sql) {
2191
- const trimmed = stripOuterParens(sql.trim());
1871
+ const trimmed = sql.trim();
2192
1872
  if (trimmed.toLowerCase() === "true") return policy.true();
2193
1873
  if (trimmed.toLowerCase() === "false") return policy.false();
2194
1874
  const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
@@ -2201,10 +1881,14 @@ function sqlToPolicy(sql) {
2201
1881
  const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
2202
1882
  return policy.rolesContain(roles);
2203
1883
  }
2204
- const orParts = splitTopLevel(trimmed, "OR");
2205
- if (orParts) return policy.or(...orParts.map(sqlToPolicy));
2206
- const andParts = splitTopLevel(trimmed, "AND");
2207
- 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
+ }
2208
1892
  const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
2209
1893
  if (match) {
2210
1894
  const [, leftStr, op, rightStr] = match;
@@ -2510,105 +2194,6 @@ var buildPropertyCallbacks = (properties) => {
2510
2194
  };
2511
2195
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
2512
2196
  };
2513
- //#endregion
2514
- //#region ../common/src/util/auth-default-policies.ts
2515
- /**
2516
- * Default RLS policies injected by the schema generator.
2517
- *
2518
- * Rebase's enforcement model is unified: authenticated (user-context) requests
2519
- * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
2520
- * statement — reads and writes. A collection's `securityRules` are the whole
2521
- * authorization model. The server context (auth flows, migrations,
2522
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
2523
- *
2524
- * Because RLS default-denies, every collection is **locked by default**: with
2525
- * no rules, only the server context and admins can touch it. The generator
2526
- * injects that safe baseline:
2527
- *
2528
- * **For every collection**
2529
- * 1. A permissive **server-or-admin SELECT** grant.
2530
- * 2. A permissive **server-or-admin write** grant (insert/update/delete).
2531
- *
2532
- * Author `securityRules` are permissive and OR together, so explicit rules only
2533
- * *broaden* access from this locked baseline (e.g. "users read/write their own
2534
- * rows").
2535
- *
2536
- * **For auth collections additionally**
2537
- * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
2538
- * their own row (profile, session bootstrap) without every app re-declaring
2539
- * it.
2540
- * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
2541
- * every other policy, so a write is rejected unless the caller is an admin
2542
- * (or the server context) — even if the author also wrote a permissive rule
2543
- * such as "a user may edit their own row". Without this, a permissive owner
2544
- * rule would let a user change their own `roles`.
2545
- *
2546
- * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
2547
- * — the built-in flows that run without a user (signup, migrations) set no user
2548
- * GUC — which also lets the owner connection satisfy these policies even under
2549
- * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
2550
- * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
2551
- *
2552
- * Opt out with `disableDefaultPolicies: true` to take full responsibility for
2553
- * the collection's RLS.
2554
- */
2555
- var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2556
- /** Write operations that must be admin-gated by default on auth collections. */
2557
- var DEFAULT_GUARDED_OPS = [
2558
- "insert",
2559
- "update",
2560
- "delete"
2561
- ];
2562
- /** Whether a collection is flagged as an authentication collection. */
2563
- function isAuthCollection(collection) {
2564
- const auth = collection.auth;
2565
- return auth === true || typeof auth === "object" && auth?.enabled === true;
2566
- }
2567
- /** The property marked as the row id (falls back to `id`). */
2568
- function getIdPropertyName(collection) {
2569
- for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
2570
- return "id";
2571
- }
2572
- /**
2573
- * Returns the security rules that should be applied to a collection: the
2574
- * author's explicit `securityRules` plus the framework defaults described in
2575
- * the module doc (baseline server/admin read for all collections; self-read
2576
- * and the admin write gate for auth collections).
2577
- *
2578
- * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
2579
- */
2580
- function getEffectiveSecurityRules(collection) {
2581
- const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
2582
- if (collection.disableDefaultPolicies) return explicit;
2583
- const tableName = getTableName$1(collection);
2584
- const injected = [];
2585
- injected.push({
2586
- name: `${tableName}_default_admin_read`,
2587
- operations: ["select"],
2588
- condition: SERVER_OR_ADMIN_EXPR
2589
- });
2590
- injected.push({
2591
- name: `${tableName}_default_admin_write`,
2592
- operations: [...DEFAULT_GUARDED_OPS],
2593
- condition: SERVER_OR_ADMIN_EXPR,
2594
- check: SERVER_OR_ADMIN_EXPR
2595
- });
2596
- if (isAuthCollection(collection)) {
2597
- injected.push({
2598
- name: `${tableName}_default_self_read`,
2599
- operations: ["select"],
2600
- condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
2601
- });
2602
- injected.push({
2603
- name: `${tableName}_require_admin_write`,
2604
- mode: "restrictive",
2605
- operations: [...DEFAULT_GUARDED_OPS],
2606
- condition: SERVER_OR_ADMIN_EXPR,
2607
- check: SERVER_OR_ADMIN_EXPR
2608
- });
2609
- }
2610
- return [...explicit, ...injected];
2611
- }
2612
2197
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
2613
2198
  (function(root, factory) {
2614
2199
  if (typeof define === "function" && define.amd) define(factory);
@@ -3878,45 +3463,18 @@ function deserializeFilter(query) {
3878
3463
  }
3879
3464
  //#endregion
3880
3465
  //#region ../common/src/data/buildRebaseData.ts
3881
- function createPrimaryKeyResolver(options) {
3882
- const cache = /* @__PURE__ */ new Map();
3883
- const warned = /* @__PURE__ */ new Set();
3884
- return function primaryKeysFor(slug) {
3885
- const cached = cache.get(slug);
3886
- if (cached) return cached;
3887
- const collection = options?.resolveCollection?.(slug);
3888
- if (!collection) return [];
3889
- const keys = resolvePrimaryKeys(collection);
3890
- if (keys.length > 0) {
3891
- cache.set(slug, keys);
3892
- return keys;
3893
- }
3894
- if (!warned.has(slug)) {
3895
- warned.add(slug);
3896
- 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.`);
3897
- }
3898
- return keys;
3899
- };
3900
- }
3901
3466
  /**
3902
- * Give a flat row the Entity view-model the admin renders.
3903
- *
3904
- * The address is *derived here* — it is not a column, and the row it came from
3905
- * does not contain one. Rows carry exactly what the table has, with the types
3906
- * Postgres returned; the id is this layer's invention, and this is the only
3907
- * place it is minted.
3908
- *
3909
- * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
3910
- * 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.
3911
3469
  */
3912
- function rowToEntity(row, slug, primaryKeys = []) {
3470
+ function rowToEntity(row, slug) {
3913
3471
  return {
3914
- id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
3472
+ id: row.id,
3915
3473
  path: slug,
3916
3474
  values: row
3917
3475
  };
3918
3476
  }
3919
- function createDriverAccessor(driver, slug, getPks = () => []) {
3477
+ function createDriverAccessor(driver, slug) {
3920
3478
  const accessor = {
3921
3479
  async find(params) {
3922
3480
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
@@ -3949,7 +3507,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3949
3507
  hasMore = offset + rows.length < total;
3950
3508
  }
3951
3509
  return {
3952
- data: rows.map((row) => rowToEntity(row, slug, getPks())),
3510
+ data: rows.map((row) => rowToEntity(row, slug)),
3953
3511
  meta: {
3954
3512
  total,
3955
3513
  limit,
@@ -3963,7 +3521,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3963
3521
  path: slug,
3964
3522
  id
3965
3523
  });
3966
- return row ? rowToEntity(row, slug, getPks()) : void 0;
3524
+ return row ? rowToEntity(row, slug) : void 0;
3967
3525
  },
3968
3526
  async create(data, id) {
3969
3527
  return rowToEntity(await driver.save({
@@ -3971,22 +3529,15 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
3971
3529
  values: data,
3972
3530
  id,
3973
3531
  status: "new"
3974
- }), slug, getPks());
3532
+ }), slug);
3975
3533
  },
3976
- createMany: driver.saveMany ? async (data, options) => {
3977
- return (await driver.saveMany({
3978
- path: slug,
3979
- rows: data,
3980
- upsert: options?.upsert
3981
- })).map((row) => rowToEntity(row, slug, getPks()));
3982
- } : void 0,
3983
3534
  async update(id, data) {
3984
3535
  return rowToEntity(await driver.save({
3985
3536
  path: slug,
3986
3537
  values: data,
3987
3538
  id,
3988
3539
  status: "existing"
3989
- }), slug, getPks());
3540
+ }), slug);
3990
3541
  },
3991
3542
  async delete(id) {
3992
3543
  return driver.delete({ row: {
@@ -4015,7 +3566,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4015
3566
  searchString: params?.searchString,
4016
3567
  onUpdate: (entities) => {
4017
3568
  onUpdate({
4018
- data: entities.map((row) => rowToEntity(row, slug, getPks())),
3569
+ data: entities.map((row) => rowToEntity(row, slug)),
4019
3570
  meta: {
4020
3571
  total: entities.length,
4021
3572
  limit,
@@ -4031,7 +3582,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4031
3582
  return driver.listenOne({
4032
3583
  path: slug,
4033
3584
  id,
4034
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
3585
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
4035
3586
  onError
4036
3587
  });
4037
3588
  } : void 0,
@@ -4070,13 +3621,12 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
4070
3621
  * await data.products.create({ name: "Camera", price: 299 });
4071
3622
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
4072
3623
  */
4073
- function buildRebaseData(driver, options) {
3624
+ function buildRebaseData(driver) {
4074
3625
  const cache = /* @__PURE__ */ new Map();
4075
- const primaryKeysFor = createPrimaryKeyResolver(options);
4076
3626
  function getAccessor(slug) {
4077
3627
  let accessor = cache.get(slug);
4078
3628
  if (!accessor) {
4079
- accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
3629
+ accessor = createDriverAccessor(driver, slug);
4080
3630
  cache.set(slug, accessor);
4081
3631
  }
4082
3632
  return accessor;
@@ -4089,9 +3639,8 @@ function buildRebaseData(driver, options) {
4089
3639
  } });
4090
3640
  }
4091
3641
  /**
4092
- * Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
4093
- * the row untouched under `.values` and derives `.id` alongside it, so dropping
4094
- * 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.
4095
3644
  */
4096
3645
  function entityToRow(entity) {
4097
3646
  return entity.values;
@@ -4178,12 +3727,6 @@ function toSdkCollectionClient(snap) {
4178
3727
  async create(data, id) {
4179
3728
  return entityToRow(await snap.create(data, id));
4180
3729
  },
4181
- async createMany(data, options) {
4182
- if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
4183
- if (data.length === 0) return [];
4184
- if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
4185
- return (await snap.createMany(data, options)).map(entityToRow);
4186
- },
4187
3730
  async update(id, data) {
4188
3731
  return entityToRow(await snap.update(id, data));
4189
3732
  },
@@ -4373,6 +3916,36 @@ function getPrimaryKeys(collection, registry) {
4373
3916
  }
4374
3917
  return keys;
4375
3918
  }
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;
3943
+ }
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(":::");
3948
+ }
4376
3949
  //#endregion
4377
3950
  //#region src/utils/drizzle-conditions.ts
4378
3951
  /**
@@ -6192,14 +5765,12 @@ var FetchService = class {
6192
5765
  /**
6193
5766
  * Convert a db.query result row (with nested relation objects) to a flat row.
6194
5767
  * Handles:
5768
+ * - Placing `id` at the top level as a string
6195
5769
  * - Type normalization (dates, numbers, NaN) via normalizeDbValues
6196
5770
  * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
6197
5771
  * - Flattening junction-table many-to-many results
6198
- *
6199
- * The row's own address is not among them: it is derived by the consumer
6200
- * from the collection's primary keys.
6201
5772
  */
6202
- drizzleResultToRow(row, collection) {
5773
+ drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
6203
5774
  const resolvedRelations = resolveCollectionRelations(collection);
6204
5775
  const normalizedValues = normalizeDbValues(row, collection);
6205
5776
  for (const [key, relation] of Object.entries(resolvedRelations)) {
@@ -6235,7 +5806,10 @@ var FetchService = class {
6235
5806
  });
6236
5807
  }
6237
5808
  }
6238
- return normalizedValues;
5809
+ return {
5810
+ ...normalizedValues,
5811
+ id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
5812
+ };
6239
5813
  }
6240
5814
  /**
6241
5815
  * Post-fetch joinPath relations for a single flat row.
@@ -6324,28 +5898,37 @@ var FetchService = class {
6324
5898
  }
6325
5899
  }
6326
5900
  /**
6327
- * Convert a db.query result row to a flat REST-style row with populated relations.
6328
- *
6329
- * Every column is copied through under its own name, with the value Postgres
6330
- * returned. This used to open with a synthesized `id` and then skip the key
6331
- * column, which renamed it (a `sku` primary key was served as `id`, and `sku`
6332
- * did not appear at all) and restringified it (`42` → `"42"`). Consumers that
6333
- * 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.
6334
5902
  */
6335
- drizzleResultToRestRow(row, collection) {
6336
- const flat = {};
5903
+ drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
5904
+ const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
6337
5905
  const resolvedRelations = resolveCollectionRelations(collection);
6338
5906
  for (const [k, v] of Object.entries(row)) {
5907
+ if (k === idInfo.fieldName) continue;
6339
5908
  const relation = findRelation(resolvedRelations, k);
6340
5909
  if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
6341
5910
  if (this.isJunctionRelation(relation, collection)) {
6342
5911
  const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6343
- 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
+ }
6344
5919
  }
6345
- return { ...item };
5920
+ return {
5921
+ ...item,
5922
+ id: String(item.id ?? item[Object.keys(item)[0]])
5923
+ };
6346
5924
  });
6347
- else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) flat[k] = { ...v };
6348
- 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;
6349
5932
  }
6350
5933
  return flat;
6351
5934
  }
@@ -6433,7 +6016,7 @@ var FetchService = class {
6433
6016
  with: withConfig
6434
6017
  });
6435
6018
  if (!row) return void 0;
6436
- const flatRow = this.drizzleResultToRow(row, collection);
6019
+ const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
6437
6020
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6438
6021
  return flatRow;
6439
6022
  } catch (e) {
@@ -6485,7 +6068,7 @@ var FetchService = class {
6485
6068
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6486
6069
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6487
6070
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6488
- 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));
6489
6072
  } catch (e) {
6490
6073
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6491
6074
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6556,7 +6139,8 @@ var FetchService = class {
6556
6139
  const parsedRows = await Promise.all(results.map(async (rawRow) => {
6557
6140
  return {
6558
6141
  rawRow,
6559
- values: await parseDataFromServer(rawRow, collection)
6142
+ values: await parseDataFromServer(rawRow, collection),
6143
+ id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
6560
6144
  };
6561
6145
  }));
6562
6146
  if (!skipRelations) {
@@ -6596,7 +6180,10 @@ var FetchService = class {
6596
6180
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
6597
6181
  }
6598
6182
  }
6599
- return parsedRows.map((item) => item.values);
6183
+ return parsedRows.map((item) => ({
6184
+ ...item.values,
6185
+ id: item.id
6186
+ }));
6600
6187
  }
6601
6188
  /**
6602
6189
  * Fetch a collection of rows
@@ -6727,7 +6314,7 @@ var FetchService = class {
6727
6314
  if (qb && !options.searchString && !options.vectorSearch) try {
6728
6315
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
6729
6316
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
6730
- 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));
6731
6318
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
6732
6319
  return restRows;
6733
6320
  } catch (e) {
@@ -6738,7 +6325,10 @@ var FetchService = class {
6738
6325
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
6739
6326
  }
6740
6327
  const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
6741
- 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
+ }));
6742
6332
  const resolvedRelations = resolveCollectionRelations(collection);
6743
6333
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
6744
6334
  const shouldInclude = (key) => include[0] === "*" || include.includes(key);
@@ -6750,7 +6340,10 @@ var FetchService = class {
6750
6340
  for (const row of rows) {
6751
6341
  const eid = row[idInfo.fieldName];
6752
6342
  const related = batchResults.get(String(eid));
6753
- if (related) row[key] = { ...related.values };
6343
+ if (related) row[key] = {
6344
+ ...related.values,
6345
+ id: related.id
6346
+ };
6754
6347
  }
6755
6348
  } catch (e) {
6756
6349
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
@@ -6768,7 +6361,10 @@ var FetchService = class {
6768
6361
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
6769
6362
  }
6770
6363
  }
6771
- return rows;
6364
+ return rows.map((row) => ({
6365
+ ...row,
6366
+ id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6367
+ }));
6772
6368
  }
6773
6369
  /**
6774
6370
  * Fetch a single row with optional relation includes for REST API.
@@ -6789,7 +6385,7 @@ var FetchService = class {
6789
6385
  ...withConfig ? { with: withConfig } : {}
6790
6386
  });
6791
6387
  if (!row) return null;
6792
- const restRow = this.drizzleResultToRestRow(row, collection);
6388
+ const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
6793
6389
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
6794
6390
  return restRow;
6795
6391
  } catch (e) {
@@ -6801,7 +6397,11 @@ var FetchService = class {
6801
6397
  }
6802
6398
  const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
6803
6399
  if (result.length === 0) return null;
6804
- 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
+ };
6805
6405
  if (!include || include.length === 0) return flatEntity;
6806
6406
  const resolvedRelations = resolveCollectionRelations(collection);
6807
6407
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
@@ -6913,15 +6513,32 @@ var FetchService = class {
6913
6513
  if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
6914
6514
  }
6915
6515
  return (await queryTarget.findMany(queryOpts)).map((row) => {
6916
- const flat = {};
6917
- for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
6918
- const keys = Object.keys(item);
6919
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6920
- if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
6921
- return { ...item };
6922
- });
6923
- else if (typeof v === "object" && v !== null) flat[k] = { ...v };
6924
- 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
+ }
6925
6542
  return flat;
6926
6543
  });
6927
6544
  } catch (e) {
@@ -7137,27 +6754,6 @@ var PersistService = class {
7137
6754
  this.fetchService = new FetchService(db, registry);
7138
6755
  }
7139
6756
  /**
7140
- * Explain a write that matched no rows.
7141
- *
7142
- * Row-level security filters UPDATE and DELETE through the policy's USING
7143
- * clause instead of raising: a denied write is reported by Postgres exactly
7144
- * like a successful one that happened to match nothing. Left unchecked, a
7145
- * caller cannot tell "denied" from "done" — the write returns 200/204 and
7146
- * the row is untouched.
7147
- *
7148
- * Re-reading the target over the *same* RLS-scoped handle separates the two
7149
- * cases. A visible row means the policy rejected the write (403); an
7150
- * invisible one means there is nothing there to write for this caller (404,
7151
- * matching what a GET would say). The re-read is bound by the caller's own
7152
- * policies, so it discloses nothing a plain read wouldn't.
7153
- *
7154
- * Only reached when zero rows matched, so the happy path pays nothing.
7155
- */
7156
- async explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
7157
- 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");
7158
- return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
7159
- }
7160
- /**
7161
6757
  * Delete an row by ID
7162
6758
  */
7163
6759
  async delete(collectionPath, id, _databaseId) {
@@ -7171,7 +6767,7 @@ var PersistService = class {
7171
6767
  if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
7172
6768
  conditions.push(eq(field, parsedIdObj[info.fieldName]));
7173
6769
  }
7174
- 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));
7175
6771
  }
7176
6772
  /**
7177
6773
  * Delete all rows from a collection
@@ -7182,14 +6778,8 @@ var PersistService = class {
7182
6778
  }
7183
6779
  /**
7184
6780
  * Save an row (create or update)
7185
- *
7186
- * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
7187
- * UPDATE against the primary key rather than a plain UPDATE. That is one
7188
- * statement, so it cannot lose a race the way a read-then-write can, and it
7189
- * does not care whether the row already exists — which is what a re-runnable
7190
- * import needs.
7191
6781
  */
7192
- async save(collectionPath, values, id, databaseId, options) {
6782
+ async save(collectionPath, values, id, databaseId) {
7193
6783
  let effectiveCollectionPath = collectionPath;
7194
6784
  const effectiveValues = { ...values };
7195
6785
  let junctionTableInfo;
@@ -7280,7 +6870,7 @@ var PersistService = class {
7280
6870
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
7281
6871
  savedId = await this.db.transaction(async (tx) => {
7282
6872
  let currentId;
7283
- if (id && !options?.upsert) {
6873
+ if (id) {
7284
6874
  currentId = id;
7285
6875
  const idValues = parseIdValues(id, idInfoArray);
7286
6876
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
@@ -7291,28 +6881,13 @@ var PersistService = class {
7291
6881
  const field = table[info.fieldName];
7292
6882
  conditions.push(eq(field, idValues[info.fieldName]));
7293
6883
  }
7294
- 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));
7295
6885
  }
7296
6886
  } else {
7297
6887
  const dataForInsert = { ...entityData };
7298
- if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
7299
6888
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
7300
- const insertQuery = tx.insert(table).values(dataForInsert);
7301
- const hasFullKey = idInfoArray.length > 0 && idInfoArray.every((info) => dataForInsert[info.fieldName] !== void 0);
7302
- let result;
7303
- if (options?.upsert && hasFullKey) {
7304
- const target = idInfoArray.map((info) => table[info.fieldName]);
7305
- const set = { ...dataForInsert };
7306
- for (const info of idInfoArray) delete set[info.fieldName];
7307
- result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
7308
- target,
7309
- set
7310
- }).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
7311
- } else result = await insertQuery.returning(returningKeys);
7312
- const resultRow = result[0];
7313
- if (!resultRow) if (id) currentId = id;
7314
- else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
7315
- else currentId = buildCompositeId(resultRow, idInfoArray);
6889
+ const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
6890
+ currentId = buildCompositeId(resultRow, idInfoArray);
7316
6891
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
7317
6892
  }
7318
6893
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
@@ -7343,7 +6918,6 @@ var PersistService = class {
7343
6918
  * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
7344
6919
  */
7345
6920
  toUserFriendlyError(error, collectionSlug) {
7346
- if (error instanceof ApiError || error?.name === "ApiError") return error;
7347
6921
  const pgError = extractPgError(error);
7348
6922
  if (pgError) {
7349
6923
  const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
@@ -7421,8 +6995,8 @@ var DataService = class {
7421
6995
  /**
7422
6996
  * Save an row (create or update)
7423
6997
  */
7424
- async save(collectionPath, values, id, databaseId, options) {
7425
- 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);
7426
7000
  }
7427
7001
  /**
7428
7002
  * Delete an row by ID
@@ -7490,26 +7064,6 @@ var DataService = class {
7490
7064
  */
7491
7065
  /** Internal prefix applied to branch database names to avoid collisions. */
7492
7066
  var BRANCH_DB_PREFIX = "rb_";
7493
- /** `duplicate_database` — the target database name is already taken. */
7494
- var PG_DUPLICATE_DATABASE = "42P04";
7495
- /** `object_in_use` — the database still has connections attached. */
7496
- var PG_OBJECT_IN_USE = "55006";
7497
- /**
7498
- * Describe a failed branch DDL statement in terms a user can act on.
7499
- *
7500
- * Drizzle reports failures as `Failed query: <sql> params:` and hides the real
7501
- * PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
7502
- * the actual problem. Match on the PG error code instead — it survives wrapping
7503
- * and, unlike the message text, is not locale-dependent.
7504
- */
7505
- function describeBranchDdlError(err, fallbackContext) {
7506
- const pgError = extractPgError(err);
7507
- if (pgError?.code === PG_DUPLICATE_DATABASE) return /* @__PURE__ */ new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
7508
- 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.`);
7509
- const detail = pgError?.message ?? extractCauseMessage(err);
7510
- if (detail) return new Error(detail);
7511
- return err instanceof Error ? err : new Error(String(err));
7512
- }
7513
7067
  /** Fully-qualified metadata table in the rebase schema. */
7514
7068
  var BRANCHES_TABLE = "rebase.branches";
7515
7069
  /**
@@ -7578,8 +7132,10 @@ var BranchService = class {
7578
7132
  try {
7579
7133
  await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
7580
7134
  } catch (err) {
7581
- 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.`);
7582
- 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;
7583
7139
  }
7584
7140
  const now = /* @__PURE__ */ new Date();
7585
7141
  await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
@@ -7604,8 +7160,8 @@ var BranchService = class {
7604
7160
  try {
7605
7161
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
7606
7162
  } catch (err) {
7607
- 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.`);
7608
- 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;
7609
7165
  }
7610
7166
  await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
7611
7167
  }
@@ -8237,7 +7793,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8237
7793
  this.realtimeService.subscriptions.delete(subscriptionId);
8238
7794
  };
8239
7795
  }
8240
- async save({ path, id, values, collection, status, upsert }) {
7796
+ async save({ path, id, values, collection, status }) {
8241
7797
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
8242
7798
  let updatedValues = values;
8243
7799
  const contextForCallback = this.buildCallContext();
@@ -8294,7 +7850,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8294
7850
  timestampNowValue: /* @__PURE__ */ new Date()
8295
7851
  });
8296
7852
  try {
8297
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
7853
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
8298
7854
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
8299
7855
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
8300
7856
  collection: resolvedCollection,
@@ -8395,49 +7951,6 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8395
7951
  throw error;
8396
7952
  }
8397
7953
  }
8398
- /**
8399
- * Write many rows through the same pipeline as {@link save}.
8400
- *
8401
- * The batch runs in one transaction of its own, so a failure part-way leaves
8402
- * nothing behind — the point of a batch is that a re-run starts from a known
8403
- * state. When this driver is already inside a transaction (the authenticated
8404
- * path, via `withTransaction`) the nested call becomes a savepoint, which is
8405
- * still atomic and still commits once.
8406
- *
8407
- * Rows are applied in order, so a batch that touches the same key twice ends
8408
- * with the last write winning, exactly as separate calls would.
8409
- */
8410
- async saveMany({ path, rows, collection, upsert }) {
8411
- return this.db.transaction(async (tx) => {
8412
- const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
8413
- txDriver.dataService = new DataService(tx, this.registry);
8414
- txDriver.client = this.client;
8415
- txDriver._deferNotifications = this._deferNotifications;
8416
- txDriver._pendingNotifications = this._pendingNotifications;
8417
- const saved = [];
8418
- for (let i = 0; i < rows.length; i++) {
8419
- const values = rows[i];
8420
- const id = values?.id;
8421
- try {
8422
- saved.push(await txDriver.save({
8423
- path,
8424
- values,
8425
- collection,
8426
- status: "new",
8427
- upsert
8428
- }));
8429
- } catch (error) {
8430
- const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
8431
- throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
8432
- statusCode: error?.statusCode,
8433
- code: error?.code,
8434
- name: error?.name
8435
- });
8436
- }
8437
- }
8438
- return saved;
8439
- });
8440
- }
8441
7954
  async delete({ row, collection }) {
8442
7955
  const targetPath = row.path;
8443
7956
  const targetRow = {
@@ -8802,18 +8315,6 @@ var AuthenticatedPostgresBackendDriver = class {
8802
8315
  async save(props) {
8803
8316
  return this.withTransaction((delegate) => delegate.save(props));
8804
8317
  }
8805
- /**
8806
- * One transaction for the whole batch, rather than one per row.
8807
- *
8808
- * This is the point of the method: `save` opens a transaction per call, so
8809
- * importing 10k rows through it means 10k transactions (and, over HTTP, 10k
8810
- * round trips). Here the RLS context is established once and every row lands
8811
- * or none does. Realtime notifications are already deferred to commit by
8812
- * `withTransaction`, so a batch does not flood subscribers mid-flight.
8813
- */
8814
- async saveMany(props) {
8815
- return this.withTransaction((delegate) => delegate.saveMany(props));
8816
- }
8817
8318
  async delete(props) {
8818
8319
  return this.withTransaction((delegate) => delegate.delete(props));
8819
8320
  }
@@ -9060,6 +8561,105 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
9060
8561
  references: [users.id]
9061
8562
  }) }));
9062
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
9063
8663
  //#region src/schema/generate-drizzle-schema-logic.ts
9064
8664
  /**
9065
8665
  * Resolve the SQL column name for a property.
@@ -9257,12 +8857,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
9257
8857
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
9258
8858
  */
9259
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
+ };
9260
8879
  var generatePolicyCode = (collection, rule, index, resolveCollection) => {
9261
8880
  const tableName = getTableName$1(collection);
9262
8881
  const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
9263
- const policyNames = getPolicyNamesForRule(rule, tableName);
8882
+ const ruleHash = getPolicyNameHash(rule);
9264
8883
  return ops.map((op, opIdx) => {
9265
- 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);
9266
8885
  }).join("");
9267
8886
  };
9268
8887
  /**
@@ -11359,7 +10978,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11359
10978
  } catch (error) {
11360
10979
  logger.error("💥 [WebSocket Server] Error handling message", { error });
11361
10980
  if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
11362
- 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";
11363
10982
  const errorResponse = {
11364
10983
  type: "ERROR",
11365
10984
  requestId,