@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.58368ce

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
@@ -6,12 +6,12 @@ import { drizzle } from "drizzle-orm/node-postgres";
6
6
  import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
7
7
  import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
8
8
  import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
9
- import { createHash, randomUUID } from "crypto";
10
9
  import fs, { promises } from "fs";
11
10
  import path from "path";
12
11
  import chokidar from "chokidar";
13
12
  import { WebSocket, WebSocketServer } from "ws";
14
13
  import { EventEmitter } from "events";
14
+ import { randomUUID } from "crypto";
15
15
  import { inspect } from "util";
16
16
  import os from "os";
17
17
  import { fileURLToPath } from "node:url";
@@ -1502,6 +1502,140 @@ 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
1505
1639
  //#region ../utils/src/names.ts
1506
1640
  /**
1507
1641
  * Generates a foreign key column name from a given string, typically a collection slug or name.
@@ -1626,6 +1760,109 @@ function createRelationRefWithData(id, path, data) {
1626
1760
  data
1627
1761
  };
1628
1762
  }
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
+ }
1629
1866
  //#endregion
1630
1867
  //#region ../common/src/util/enums.ts
1631
1868
  function enumToObjectEntries(enumValues) {
@@ -2284,6 +2521,307 @@ var buildPropertyCallbacks = (properties) => {
2284
2521
  };
2285
2522
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
2286
2523
  };
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
+ }
2287
2825
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
2288
2826
  (function(root, factory) {
2289
2827
  if (typeof define === "function" && define.amd) define(factory);
@@ -3553,18 +4091,45 @@ function deserializeFilter(query) {
3553
4091
  }
3554
4092
  //#endregion
3555
4093
  //#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
+ }
3556
4114
  /**
3557
- * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
3558
- * Mirrors the client SDK's rowToEntity conversion.
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.
3559
4124
  */
3560
- function rowToEntity(row, slug) {
4125
+ function rowToEntity(row, slug, primaryKeys = []) {
3561
4126
  return {
3562
- id: row.id,
4127
+ id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
3563
4128
  path: slug,
3564
4129
  values: row
3565
4130
  };
3566
4131
  }
3567
- function createDriverAccessor(driver, slug) {
4132
+ function createDriverAccessor(driver, slug, getPks = () => []) {
3568
4133
  const accessor = {
3569
4134
  async find(params) {
3570
4135
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
@@ -3597,7 +4162,7 @@ function createDriverAccessor(driver, slug) {
3597
4162
  hasMore = offset + rows.length < total;
3598
4163
  }
3599
4164
  return {
3600
- data: rows.map((row) => rowToEntity(row, slug)),
4165
+ data: rows.map((row) => rowToEntity(row, slug, getPks())),
3601
4166
  meta: {
3602
4167
  total,
3603
4168
  limit,
@@ -3611,7 +4176,7 @@ function createDriverAccessor(driver, slug) {
3611
4176
  path: slug,
3612
4177
  id
3613
4178
  });
3614
- return row ? rowToEntity(row, slug) : void 0;
4179
+ return row ? rowToEntity(row, slug, getPks()) : void 0;
3615
4180
  },
3616
4181
  async create(data, id) {
3617
4182
  return rowToEntity(await driver.save({
@@ -3619,15 +4184,22 @@ function createDriverAccessor(driver, slug) {
3619
4184
  values: data,
3620
4185
  id,
3621
4186
  status: "new"
3622
- }), slug);
4187
+ }), slug, getPks());
3623
4188
  },
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,
3624
4196
  async update(id, data) {
3625
4197
  return rowToEntity(await driver.save({
3626
4198
  path: slug,
3627
4199
  values: data,
3628
4200
  id,
3629
4201
  status: "existing"
3630
- }), slug);
4202
+ }), slug, getPks());
3631
4203
  },
3632
4204
  async delete(id) {
3633
4205
  return driver.delete({ row: {
@@ -3656,7 +4228,7 @@ function createDriverAccessor(driver, slug) {
3656
4228
  searchString: params?.searchString,
3657
4229
  onUpdate: (entities) => {
3658
4230
  onUpdate({
3659
- data: entities.map((row) => rowToEntity(row, slug)),
4231
+ data: entities.map((row) => rowToEntity(row, slug, getPks())),
3660
4232
  meta: {
3661
4233
  total: entities.length,
3662
4234
  limit,
@@ -3672,7 +4244,7 @@ function createDriverAccessor(driver, slug) {
3672
4244
  return driver.listenOne({
3673
4245
  path: slug,
3674
4246
  id,
3675
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
4247
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
3676
4248
  onError
3677
4249
  });
3678
4250
  } : void 0,
@@ -3711,12 +4283,13 @@ function createDriverAccessor(driver, slug) {
3711
4283
  * await data.products.create({ name: "Camera", price: 299 });
3712
4284
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
3713
4285
  */
3714
- function buildRebaseData(driver) {
4286
+ function buildRebaseData(driver, options) {
3715
4287
  const cache = /* @__PURE__ */ new Map();
4288
+ const primaryKeysFor = createPrimaryKeyResolver(options);
3716
4289
  function getAccessor(slug) {
3717
4290
  let accessor = cache.get(slug);
3718
4291
  if (!accessor) {
3719
- accessor = createDriverAccessor(driver, slug);
4292
+ accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
3720
4293
  cache.set(slug, accessor);
3721
4294
  }
3722
4295
  return accessor;
@@ -3729,8 +4302,9 @@ function buildRebaseData(driver) {
3729
4302
  } });
3730
4303
  }
3731
4304
  /**
3732
- * Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
3733
- * (id included) under `.values`, so this is just that payload.
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.
3734
4308
  */
3735
4309
  function entityToRow(entity) {
3736
4310
  return entity.values;
@@ -3817,6 +4391,12 @@ function toSdkCollectionClient(snap) {
3817
4391
  async create(data, id) {
3818
4392
  return entityToRow(await snap.create(data, id));
3819
4393
  },
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
+ },
3820
4400
  async update(id, data) {
3821
4401
  return entityToRow(await snap.update(id, data));
3822
4402
  },
@@ -4006,35 +4586,84 @@ function getPrimaryKeys(collection, registry) {
4006
4586
  }
4007
4587
  return keys;
4008
4588
  }
4009
- function parseIdValues(idValue, primaryKeys) {
4010
- const result = {};
4011
- if (primaryKeys.length === 0) return result;
4012
- if (primaryKeys.length === 1) {
4013
- const pk = primaryKeys[0];
4014
- if (pk.type === "number" && !pk.isUUID) {
4015
- const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
4016
- if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
4017
- result[pk.fieldName] = parsed;
4018
- } else result[pk.fieldName] = String(idValue);
4019
- return result;
4020
- }
4021
- const parts = String(idValue).split(":::");
4022
- if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
4023
- for (let i = 0; i < primaryKeys.length; i++) {
4024
- const pk = primaryKeys[i];
4025
- const val = parts[i];
4026
- if (pk.type === "number" && !pk.isUUID) {
4027
- const parsed = parseInt(val, 10);
4028
- if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
4029
- result[pk.fieldName] = parsed;
4030
- } else result[pk.fieldName] = val;
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
+ });
4031
4629
  }
4032
- return result;
4630
+ return findings;
4033
4631
  }
4034
- function buildCompositeId(values, primaryKeys) {
4035
- if (primaryKeys.length === 0) return "";
4036
- if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
4037
- return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
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");
4647
+ }
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 "";
4038
4667
  }
4039
4668
  //#endregion
4040
4669
  //#region src/utils/drizzle-conditions.ts
@@ -4657,12 +5286,10 @@ function serializeDataToServer(row, properties, collection, registry) {
4657
5286
  continue;
4658
5287
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
4659
5288
  const serializedValue = serializePropertyToServer(effectiveValue, property);
4660
- const pks = getPrimaryKeys(collection, registry);
4661
5289
  inverseRelationUpdates.push({
4662
5290
  relationKey: key,
4663
5291
  relation,
4664
- newValue: serializedValue,
4665
- currentId: row.id || buildCompositeId(row, pks)
5292
+ newValue: serializedValue
4666
5293
  });
4667
5294
  continue;
4668
5295
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
@@ -4672,15 +5299,11 @@ function serializeDataToServer(row, properties, collection, registry) {
4672
5299
  relation,
4673
5300
  newTargetId: serializedValue
4674
5301
  });
4675
- else {
4676
- const pks = getPrimaryKeys(collection, registry);
4677
- inverseRelationUpdates.push({
4678
- relationKey: key,
4679
- relation,
4680
- newValue: serializedValue,
4681
- currentId: row.id || buildCompositeId(row, pks)
4682
- });
4683
- }
5302
+ else inverseRelationUpdates.push({
5303
+ relationKey: key,
5304
+ relation,
5305
+ newValue: serializedValue
5306
+ });
4684
5307
  continue;
4685
5308
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
4686
5309
  const serializedValue = serializePropertyToServer(effectiveValue, property);
@@ -5132,10 +5755,9 @@ var RelationService = class {
5132
5755
  const rows = [];
5133
5756
  for (const row of results) {
5134
5757
  const targetRow = row[targetTableName] || row;
5135
- const id = targetRow[idInfo[0].fieldName];
5136
5758
  const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5137
5759
  rows.push({
5138
- id: id?.toString() || "",
5760
+ id: buildCompositeId(targetRow, idInfo),
5139
5761
  path: targetCollection.slug,
5140
5762
  values: parsedValues
5141
5763
  });
@@ -5157,10 +5779,9 @@ var RelationService = class {
5157
5779
  const rows = [];
5158
5780
  for (const row of results) {
5159
5781
  const targetRow = row[getTableName$1(targetCollection)] || row;
5160
- const id = targetRow[idInfo[0].fieldName];
5161
5782
  const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5162
5783
  rows.push({
5163
- id: id?.toString() || "",
5784
+ id: buildCompositeId(targetRow, idInfo),
5164
5785
  path: targetCollection.slug,
5165
5786
  values: parsedValues
5166
5787
  });
@@ -5200,7 +5821,8 @@ var RelationService = class {
5200
5821
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5201
5822
  const targetCollection = relation.target();
5202
5823
  const targetTable = getTableForCollection(targetCollection, this.registry);
5203
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5824
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
5825
+ const targetIdInfo = targetPks[0];
5204
5826
  const targetIdField = targetTable[targetIdInfo.fieldName];
5205
5827
  const parentPks = getPrimaryKeys(parentCollection, this.registry);
5206
5828
  const parentIdInfo = parentPks[0];
@@ -5237,7 +5859,7 @@ var RelationService = class {
5237
5859
  const parentId = parentRow[parentIdInfo.fieldName];
5238
5860
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5239
5861
  resultMap.set(String(parentId), {
5240
- id: String(targetRow[targetIdInfo.fieldName]),
5862
+ id: buildCompositeId(targetRow, targetPks),
5241
5863
  path: targetCollection.slug,
5242
5864
  values: parsedValues
5243
5865
  });
@@ -5276,7 +5898,7 @@ var RelationService = class {
5276
5898
  if (targetRow) {
5277
5899
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5278
5900
  resultMap.set(parentIdStr, {
5279
- id: String(targetRow[targetIdInfo.fieldName]),
5901
+ id: buildCompositeId(targetRow, targetPks),
5280
5902
  path: targetCollection.slug,
5281
5903
  values: parsedValues
5282
5904
  });
@@ -5297,7 +5919,7 @@ var RelationService = class {
5297
5919
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5298
5920
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5299
5921
  resultMap.set(String(parentId), {
5300
- id: String(targetRow[targetIdInfo.fieldName]),
5922
+ id: buildCompositeId(targetRow, targetPks),
5301
5923
  path: targetCollection.slug,
5302
5924
  values: parsedValues
5303
5925
  });
@@ -5315,8 +5937,8 @@ var RelationService = class {
5315
5937
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5316
5938
  const targetCollection = relation.target();
5317
5939
  const targetTable = getTableForCollection(targetCollection, this.registry);
5318
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5319
- const targetIdField = targetTable[targetIdInfo.fieldName];
5940
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
5941
+ const targetIdField = targetTable[targetPks[0].fieldName];
5320
5942
  const parentPks = getPrimaryKeys(parentCollection, this.registry);
5321
5943
  const parentIdInfo = parentPks[0];
5322
5944
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5351,7 +5973,7 @@ var RelationService = class {
5351
5973
  const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5352
5974
  const arr = resultMap.get(parentId) || [];
5353
5975
  arr.push({
5354
- id: String(targetRow[targetIdInfo.fieldName]),
5976
+ id: buildCompositeId(targetRow, targetPks),
5355
5977
  path: targetCollection.slug,
5356
5978
  values: parsedValues
5357
5979
  });
@@ -5381,7 +6003,7 @@ var RelationService = class {
5381
6003
  const parsedValues = await parseDataFromServer(targetData, targetCollection);
5382
6004
  const arr = resultMap.get(parentId) || [];
5383
6005
  arr.push({
5384
- id: String(targetData[targetIdInfo.fieldName]),
6006
+ id: buildCompositeId(targetData, targetPks),
5385
6007
  path: targetCollection.slug,
5386
6008
  values: parsedValues
5387
6009
  });
@@ -5405,7 +6027,7 @@ var RelationService = class {
5405
6027
  const key = String(parentId);
5406
6028
  const arr = resultMap.get(key) || [];
5407
6029
  arr.push({
5408
- id: String(targetRow[targetIdInfo.fieldName]),
6030
+ id: buildCompositeId(targetRow, targetPks),
5409
6031
  path: targetCollection.slug,
5410
6032
  values: parsedValues
5411
6033
  });
@@ -5853,14 +6475,32 @@ var FetchService = class {
5853
6475
  return null;
5854
6476
  }
5855
6477
  /**
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
+ /**
5856
6494
  * Convert a db.query result row (with nested relation objects) to a flat row.
5857
6495
  * Handles:
5858
- * - Placing `id` at the top level as a string
5859
6496
  * - Type normalization (dates, numbers, NaN) via normalizeDbValues
5860
6497
  * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
5861
6498
  * - 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.
5862
6502
  */
5863
- drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
6503
+ drizzleResultToRow(row, collection) {
5864
6504
  const resolvedRelations = resolveCollectionRelations(collection);
5865
6505
  const normalizedValues = normalizeDbValues(row, collection);
5866
6506
  for (const [key, relation] of Object.entries(resolvedRelations)) {
@@ -5869,14 +6509,13 @@ var FetchService = class {
5869
6509
  if (relation.cardinality === "many" && Array.isArray(relData)) {
5870
6510
  const targetCollection = relation.target();
5871
6511
  const targetPath = targetCollection.slug;
5872
- const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
5873
6512
  normalizedValues[key] = relData.map((item) => {
5874
6513
  let targetRow = item;
5875
6514
  if (this.isJunctionRelation(relation, collection)) {
5876
6515
  const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
5877
6516
  if (nestedKey) targetRow = item[nestedKey];
5878
6517
  }
5879
- const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
6518
+ const relId = this.relationTargetAddress(targetRow, targetCollection);
5880
6519
  return createRelationRefWithData(relId, targetPath, {
5881
6520
  id: relId,
5882
6521
  path: targetPath,
@@ -5886,9 +6525,8 @@ var FetchService = class {
5886
6525
  } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
5887
6526
  const targetCollection = relation.target();
5888
6527
  const targetPath = targetCollection.slug;
5889
- const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
5890
6528
  const relObj = relData;
5891
- const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
6529
+ const relId = this.relationTargetAddress(relObj, targetCollection);
5892
6530
  normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
5893
6531
  id: relId,
5894
6532
  path: targetPath,
@@ -5896,10 +6534,7 @@ var FetchService = class {
5896
6534
  });
5897
6535
  }
5898
6536
  }
5899
- return {
5900
- ...normalizedValues,
5901
- id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
5902
- };
6537
+ return normalizedValues;
5903
6538
  }
5904
6539
  /**
5905
6540
  * Post-fetch joinPath relations for a single flat row.
@@ -5922,31 +6557,6 @@ var FetchService = class {
5922
6557
  await Promise.all(promises);
5923
6558
  }
5924
6559
  /**
5925
- * Post-fetch joinPath relations for a batch of flat rows.
5926
- * Uses batch fetching to avoid N+1 queries for list views.
5927
- */
5928
- async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
5929
- if (rows.length === 0) return;
5930
- const resolvedRelations = resolveCollectionRelations(collection);
5931
- const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
5932
- if (joinPathRelations.length === 0) return;
5933
- for (const [key, relation] of joinPathRelations) try {
5934
- const rowIds = rows.map((r) => {
5935
- return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
5936
- });
5937
- const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
5938
- for (const row of rows) {
5939
- const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
5940
- const relatedRow = resultMap.get(String(id));
5941
- if (relatedRow) {
5942
- if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
5943
- }
5944
- }
5945
- } catch (e) {
5946
- logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
5947
- }
5948
- }
5949
- /**
5950
6560
  * Resolves joinPath relations for raw REST rows and directly injects them.
5951
6561
  * Uses RelationService to query the database and maps results back to the flattened objects.
5952
6562
  */
@@ -5958,67 +6568,48 @@ var FetchService = class {
5958
6568
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
5959
6569
  if (joinPathRelations.length === 0) return;
5960
6570
  const idInfo = idInfoArray[0];
6571
+ const parentIdOf = (row) => row[idInfo.fieldName];
5961
6572
  for (const [key, relation] of joinPathRelations) try {
5962
- const rowIds = rows.map((r) => {
5963
- return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
5964
- });
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));
5965
6576
  if (relation.cardinality === "one") {
5966
6577
  const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
5967
- for (const row of rows) {
5968
- const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
5969
- const relatedRow = resultMap.get(String(id));
5970
- if (relatedRow) row[key] = {
5971
- ...relatedRow.values,
5972
- id: relatedRow.id
5973
- };
5974
- else row[key] = null;
6578
+ for (const row of addressable) {
6579
+ const relatedRow = resultMap.get(String(parentIdOf(row)));
6580
+ row[key] = relatedRow ? { ...relatedRow.values } : null;
5975
6581
  }
5976
6582
  } else if (relation.cardinality === "many") {
5977
6583
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
5978
- for (const row of rows) {
5979
- const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
5980
- row[key] = (resultMap.get(String(id)) || []).map((e) => ({
5981
- ...e.values,
5982
- id: e.id
5983
- }));
5984
- }
6584
+ for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
5985
6585
  }
5986
6586
  } catch (e) {
5987
6587
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
5988
6588
  }
5989
6589
  }
5990
6590
  /**
5991
- * Convert a db.query result row to a flat REST-style object with populated relations.
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.
5992
6598
  */
5993
- drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
5994
- const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
6599
+ drizzleResultToRestRow(row, collection) {
6600
+ const flat = {};
5995
6601
  const resolvedRelations = resolveCollectionRelations(collection);
5996
6602
  for (const [k, v] of Object.entries(row)) {
5997
- if (k === idInfo.fieldName) continue;
5998
6603
  const relation = findRelation(resolvedRelations, k);
5999
6604
  if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
6000
6605
  if (this.isJunctionRelation(relation, collection)) {
6001
6606
  const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6002
- if (nestedKey) {
6003
- const nested = item[nestedKey];
6004
- return {
6005
- ...nested,
6006
- id: String(nested.id ?? nested[Object.keys(nested)[0]])
6007
- };
6008
- }
6607
+ if (nestedKey) return { ...item[nestedKey] };
6009
6608
  }
6010
- return {
6011
- ...item,
6012
- id: String(item.id ?? item[Object.keys(item)[0]])
6013
- };
6609
+ return { ...item };
6014
6610
  });
6015
- else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
6016
- const relObj = v;
6017
- flat[k] = {
6018
- ...relObj,
6019
- id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
6020
- };
6021
- } else flat[k] = v;
6611
+ else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) flat[k] = { ...v };
6612
+ else flat[k] = v;
6022
6613
  }
6023
6614
  return flat;
6024
6615
  }
@@ -6106,7 +6697,7 @@ var FetchService = class {
6106
6697
  with: withConfig
6107
6698
  });
6108
6699
  if (!row) return void 0;
6109
- const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
6700
+ const flatRow = this.drizzleResultToRow(row, collection);
6110
6701
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6111
6702
  return flatRow;
6112
6703
  } catch (e) {
@@ -6158,7 +6749,7 @@ var FetchService = class {
6158
6749
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6159
6750
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6160
6751
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6161
- return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray));
6752
+ return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection));
6162
6753
  } catch (e) {
6163
6754
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6164
6755
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6229,8 +6820,7 @@ var FetchService = class {
6229
6820
  const parsedRows = await Promise.all(results.map(async (rawRow) => {
6230
6821
  return {
6231
6822
  rawRow,
6232
- values: await parseDataFromServer(rawRow, collection),
6233
- id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
6823
+ values: await parseDataFromServer(rawRow, collection)
6234
6824
  };
6235
6825
  }));
6236
6826
  if (!skipRelations) {
@@ -6270,10 +6860,7 @@ var FetchService = class {
6270
6860
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
6271
6861
  }
6272
6862
  }
6273
- return parsedRows.map((item) => ({
6274
- ...item.values,
6275
- id: item.id
6276
- }));
6863
+ return parsedRows.map((item) => item.values);
6277
6864
  }
6278
6865
  /**
6279
6866
  * Fetch a collection of rows
@@ -6304,10 +6891,7 @@ var FetchService = class {
6304
6891
  const relationKey = pathSegments[i];
6305
6892
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
6306
6893
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
6307
- if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
6308
- ...row.values,
6309
- id: row.id
6310
- }));
6894
+ if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
6311
6895
  if (i + 1 < pathSegments.length) {
6312
6896
  const nextEntityId = pathSegments[i + 1];
6313
6897
  currentCollection = relation.target();
@@ -6404,7 +6988,7 @@ var FetchService = class {
6404
6988
  if (qb && !options.searchString && !options.vectorSearch) try {
6405
6989
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
6406
6990
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
6407
- const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray));
6991
+ const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection));
6408
6992
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
6409
6993
  return restRows;
6410
6994
  } catch (e) {
@@ -6415,10 +6999,7 @@ var FetchService = class {
6415
6999
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
6416
7000
  }
6417
7001
  const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
6418
- if (!include || include.length === 0) return rows.map((row) => ({
6419
- ...row,
6420
- id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6421
- }));
7002
+ if (!include || include.length === 0) return rows;
6422
7003
  const resolvedRelations = resolveCollectionRelations(collection);
6423
7004
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
6424
7005
  const shouldInclude = (key) => include[0] === "*" || include.includes(key);
@@ -6430,10 +7011,7 @@ var FetchService = class {
6430
7011
  for (const row of rows) {
6431
7012
  const eid = row[idInfo.fieldName];
6432
7013
  const related = batchResults.get(String(eid));
6433
- if (related) row[key] = {
6434
- ...related.values,
6435
- id: related.id
6436
- };
7014
+ if (related) row[key] = { ...related.values };
6437
7015
  }
6438
7016
  } catch (e) {
6439
7017
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
@@ -6451,10 +7029,7 @@ var FetchService = class {
6451
7029
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
6452
7030
  }
6453
7031
  }
6454
- return rows.map((row) => ({
6455
- ...row,
6456
- id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
6457
- }));
7032
+ return rows;
6458
7033
  }
6459
7034
  /**
6460
7035
  * Fetch a single row with optional relation includes for REST API.
@@ -6475,7 +7050,7 @@ var FetchService = class {
6475
7050
  ...withConfig ? { with: withConfig } : {}
6476
7051
  });
6477
7052
  if (!row) return null;
6478
- const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
7053
+ const restRow = this.drizzleResultToRestRow(row, collection);
6479
7054
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
6480
7055
  return restRow;
6481
7056
  } catch (e) {
@@ -6487,11 +7062,7 @@ var FetchService = class {
6487
7062
  }
6488
7063
  const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
6489
7064
  if (result.length === 0) return null;
6490
- const raw = result[0];
6491
- const flatEntity = {
6492
- ...raw,
6493
- id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
6494
- };
7065
+ const flatEntity = { ...result[0] };
6495
7066
  if (!include || include.length === 0) return flatEntity;
6496
7067
  const resolvedRelations = resolveCollectionRelations(collection);
6497
7068
  const propertyKeys = new Set(Object.keys(collection.properties || {}));
@@ -6603,32 +7174,15 @@ var FetchService = class {
6603
7174
  if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
6604
7175
  }
6605
7176
  return (await queryTarget.findMany(queryOpts)).map((row) => {
6606
- const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
6607
- for (const [k, v] of Object.entries(row)) {
6608
- if (k === idInfo.fieldName) continue;
6609
- if (Array.isArray(v)) flat[k] = v.map((item) => {
6610
- const keys = Object.keys(item);
6611
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6612
- if (nestedObj && keys.length <= 3) {
6613
- const nested = item[nestedObj];
6614
- return {
6615
- ...nested,
6616
- id: String(nested.id ?? nested[Object.keys(nested)[0]])
6617
- };
6618
- }
6619
- return {
6620
- ...item,
6621
- id: String(item.id ?? item[Object.keys(item)[0]])
6622
- };
6623
- });
6624
- else if (typeof v === "object" && v !== null) {
6625
- const relObj = v;
6626
- flat[k] = {
6627
- ...relObj,
6628
- id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
6629
- };
6630
- } else flat[k] = v;
6631
- }
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;
6632
7186
  return flat;
6633
7187
  });
6634
7188
  } catch (e) {
@@ -6889,8 +7443,14 @@ var PersistService = class {
6889
7443
  }
6890
7444
  /**
6891
7445
  * 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.
6892
7452
  */
6893
- async save(collectionPath, values, id, databaseId) {
7453
+ async save(collectionPath, values, id, databaseId, options) {
6894
7454
  let effectiveCollectionPath = collectionPath;
6895
7455
  const effectiveValues = { ...values };
6896
7456
  let junctionTableInfo;
@@ -6981,7 +7541,7 @@ var PersistService = class {
6981
7541
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
6982
7542
  savedId = await this.db.transaction(async (tx) => {
6983
7543
  let currentId;
6984
- if (id) {
7544
+ if (id && !options?.upsert) {
6985
7545
  currentId = id;
6986
7546
  const idValues = parseIdValues(id, idInfoArray);
6987
7547
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
@@ -6996,9 +7556,24 @@ var PersistService = class {
6996
7556
  }
6997
7557
  } else {
6998
7558
  const dataForInsert = { ...entityData };
7559
+ if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
6999
7560
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
7000
- const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
7001
- currentId = buildCompositeId(resultRow, idInfoArray);
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);
7002
7577
  if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
7003
7578
  }
7004
7579
  if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
@@ -7107,8 +7682,8 @@ var DataService = class {
7107
7682
  /**
7108
7683
  * Save an row (create or update)
7109
7684
  */
7110
- async save(collectionPath, values, id, databaseId) {
7111
- return this.persistService.save(collectionPath, values, id, databaseId);
7685
+ async save(collectionPath, values, id, databaseId, options) {
7686
+ return this.persistService.save(collectionPath, values, id, databaseId, options);
7112
7687
  }
7113
7688
  /**
7114
7689
  * Delete an row by ID
@@ -7176,6 +7751,26 @@ var DataService = class {
7176
7751
  */
7177
7752
  /** Internal prefix applied to branch database names to avoid collisions. */
7178
7753
  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
+ }
7179
7774
  /** Fully-qualified metadata table in the rebase schema. */
7180
7775
  var BRANCHES_TABLE = "rebase.branches";
7181
7776
  /**
@@ -7244,10 +7839,8 @@ var BranchService = class {
7244
7839
  try {
7245
7840
  await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
7246
7841
  } catch (err) {
7247
- const msg = err instanceof Error ? err.message : String(err);
7248
- if (msg.includes("already exists")) throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
7249
- if (msg.includes("being accessed by other users")) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
7250
- throw err;
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);
7251
7844
  }
7252
7845
  const now = /* @__PURE__ */ new Date();
7253
7846
  await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
@@ -7272,8 +7865,8 @@ var BranchService = class {
7272
7865
  try {
7273
7866
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
7274
7867
  } catch (err) {
7275
- if ((err instanceof Error ? err.message : String(err)).includes("being accessed by other users")) throw new Error(`Cannot delete branch "${sanitizedName}": the database has active connections. Close other clients and try again.`);
7276
- throw err;
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);
7277
7870
  }
7278
7871
  await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
7279
7872
  }
@@ -7905,7 +8498,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7905
8498
  this.realtimeService.subscriptions.delete(subscriptionId);
7906
8499
  };
7907
8500
  }
7908
- async save({ path, id, values, collection, status }) {
8501
+ async save({ path, id, values, collection, status, upsert }) {
7909
8502
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
7910
8503
  let updatedValues = values;
7911
8504
  const contextForCallback = this.buildCallContext();
@@ -7962,7 +8555,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7962
8555
  timestampNowValue: /* @__PURE__ */ new Date()
7963
8556
  });
7964
8557
  try {
7965
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
8558
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
7966
8559
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
7967
8560
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
7968
8561
  collection: resolvedCollection,
@@ -7983,8 +8576,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7983
8576
  context: contextForCallback
7984
8577
  });
7985
8578
  }
7986
- const savedId = savedRow.id;
7987
- const { id: _savedId, ...savedValues } = savedRow;
8579
+ const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
8580
+ const savedValues = savedRow;
7988
8581
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
7989
8582
  if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
7990
8583
  collection: resolvedCollection,
@@ -8016,7 +8609,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8016
8609
  }
8017
8610
  if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
8018
8611
  tableName: path,
8019
- id: savedId.toString(),
8612
+ id: savedId,
8020
8613
  action: status === "new" ? "create" : "update",
8021
8614
  values: savedValues,
8022
8615
  previousValues: previousValuesForHistory,
@@ -8024,11 +8617,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8024
8617
  });
8025
8618
  if (this._deferNotifications) this._pendingNotifications.push({
8026
8619
  path,
8027
- id: savedId.toString(),
8620
+ id: savedId,
8028
8621
  row: savedRow,
8029
8622
  databaseId: resolvedCollection?.databaseId
8030
8623
  });
8031
- else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
8624
+ else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
8032
8625
  return savedRow;
8033
8626
  } catch (error) {
8034
8627
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
@@ -8063,12 +8656,52 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8063
8656
  throw error;
8064
8657
  }
8065
8658
  }
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
+ }
8066
8702
  async delete({ row, collection }) {
8067
8703
  const targetPath = row.path;
8068
- const targetRow = {
8069
- id: row.id,
8070
- ...row.values ?? {}
8071
- };
8704
+ const targetRow = { ...row.values ?? {} };
8072
8705
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
8073
8706
  const contextForCallback = this.buildCallContext();
8074
8707
  if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -8427,6 +9060,18 @@ var AuthenticatedPostgresBackendDriver = class {
8427
9060
  async save(props) {
8428
9061
  return this.withTransaction((delegate) => delegate.save(props));
8429
9062
  }
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
+ }
8430
9075
  async delete(props) {
8431
9076
  return this.withTransaction((delegate) => delegate.delete(props));
8432
9077
  }
@@ -8673,105 +9318,6 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
8673
9318
  references: [users.id]
8674
9319
  }) }));
8675
9320
  //#endregion
8676
- //#region src/schema/auth-default-policies.ts
8677
- /**
8678
- * Default RLS policies injected by the schema generator.
8679
- *
8680
- * Rebase's enforcement model is unified: authenticated (user-context) requests
8681
- * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
8682
- * statement — reads and writes. A collection's `securityRules` are the whole
8683
- * authorization model. The server context (auth flows, migrations,
8684
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
8685
- *
8686
- * Because RLS default-denies, every collection is **locked by default**: with
8687
- * no rules, only the server context and admins can touch it. The generator
8688
- * injects that safe baseline:
8689
- *
8690
- * **For every collection**
8691
- * 1. A permissive **server-or-admin SELECT** grant.
8692
- * 2. A permissive **server-or-admin write** grant (insert/update/delete).
8693
- *
8694
- * Author `securityRules` are permissive and OR together, so explicit rules only
8695
- * *broaden* access from this locked baseline (e.g. "users read/write their own
8696
- * rows").
8697
- *
8698
- * **For auth collections additionally**
8699
- * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
8700
- * their own row (profile, session bootstrap) without every app re-declaring
8701
- * it.
8702
- * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
8703
- * every other policy, so a write is rejected unless the caller is an admin
8704
- * (or the server context) — even if the author also wrote a permissive rule
8705
- * such as "a user may edit their own row". Without this, a permissive owner
8706
- * rule would let a user change their own `roles`.
8707
- *
8708
- * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
8709
- * — the built-in flows that run without a user (signup, migrations) set no user
8710
- * GUC — which also lets the owner connection satisfy these policies even under
8711
- * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
8712
- * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
8713
- *
8714
- * Opt out with `disableDefaultPolicies: true` to take full responsibility for
8715
- * the collection's RLS.
8716
- */
8717
- var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
8718
- /** Write operations that must be admin-gated by default on auth collections. */
8719
- var DEFAULT_GUARDED_OPS = [
8720
- "insert",
8721
- "update",
8722
- "delete"
8723
- ];
8724
- /** Whether a collection is flagged as an authentication collection. */
8725
- function isAuthCollection(collection) {
8726
- const auth = collection.auth;
8727
- return auth === true || typeof auth === "object" && auth?.enabled === true;
8728
- }
8729
- /** The property marked as the row id (falls back to `id`). */
8730
- function getIdPropertyName(collection) {
8731
- for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
8732
- return "id";
8733
- }
8734
- /**
8735
- * Returns the security rules that should be applied to a collection: the
8736
- * author's explicit `securityRules` plus the framework defaults described in
8737
- * the module doc (baseline server/admin read for all collections; self-read
8738
- * and the admin write gate for auth collections).
8739
- *
8740
- * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
8741
- */
8742
- function getEffectiveSecurityRules(collection) {
8743
- const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
8744
- if (collection.disableDefaultPolicies) return explicit;
8745
- const tableName = getTableName$1(collection);
8746
- const injected = [];
8747
- injected.push({
8748
- name: `${tableName}_default_admin_read`,
8749
- operations: ["select"],
8750
- condition: SERVER_OR_ADMIN_EXPR
8751
- });
8752
- injected.push({
8753
- name: `${tableName}_default_admin_write`,
8754
- operations: [...DEFAULT_GUARDED_OPS],
8755
- condition: SERVER_OR_ADMIN_EXPR,
8756
- check: SERVER_OR_ADMIN_EXPR
8757
- });
8758
- if (isAuthCollection(collection)) {
8759
- injected.push({
8760
- name: `${tableName}_default_self_read`,
8761
- operations: ["select"],
8762
- condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
8763
- });
8764
- injected.push({
8765
- name: `${tableName}_require_admin_write`,
8766
- mode: "restrictive",
8767
- operations: [...DEFAULT_GUARDED_OPS],
8768
- condition: SERVER_OR_ADMIN_EXPR,
8769
- check: SERVER_OR_ADMIN_EXPR
8770
- });
8771
- }
8772
- return [...explicit, ...injected];
8773
- }
8774
- //#endregion
8775
9321
  //#region src/schema/generate-drizzle-schema-logic.ts
8776
9322
  /**
8777
9323
  * Resolve the SQL column name for a property.
@@ -8969,31 +9515,12 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
8969
9515
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
8970
9516
  */
8971
9517
  var wrapSql = (clause) => `sql\`${clause}\``;
8972
- /**
8973
- * Generates a deterministic hash based on the rule configuration.
8974
- */
8975
- var getPolicyNameHash = (rule) => {
8976
- const data = JSON.stringify({
8977
- a: rule.access,
8978
- m: rule.mode,
8979
- op: rule.operation,
8980
- ops: rule.operations?.slice().sort(),
8981
- own: rule.ownerField,
8982
- rol: rule.roles?.slice().sort(),
8983
- pg: rule.pgRoles?.slice().sort(),
8984
- u: rule.using,
8985
- w: rule.withCheck,
8986
- c: rule.condition,
8987
- ch: rule.check
8988
- });
8989
- return createHash("sha1").update(data).digest("hex").substring(0, 7);
8990
- };
8991
9518
  var generatePolicyCode = (collection, rule, index, resolveCollection) => {
8992
9519
  const tableName = getTableName$1(collection);
8993
9520
  const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
8994
- const ruleHash = getPolicyNameHash(rule);
9521
+ const policyNames = getPolicyNamesForRule(rule, tableName);
8995
9522
  return ops.map((op, opIdx) => {
8996
- return generateSinglePolicyCode(collection, rule, op, rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`, resolveCollection);
9523
+ return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
8997
9524
  }).join("");
8998
9525
  };
8999
9526
  /**
@@ -9122,6 +9649,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
9122
9649
  });
9123
9650
  });
9124
9651
  schemaContent += "\n";
9652
+ const junctionSpecs = resolveJunctionSpecs(collections);
9125
9653
  for (const collection of collections) {
9126
9654
  const tableName = getTableName$1(collection);
9127
9655
  if (tableName) allTablesToGenerate.set(tableName, { collection });
@@ -9155,9 +9683,17 @@ var generateSchema = async (collections, stripPolicies = false) => {
9155
9683
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
9156
9684
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
9157
9685
  schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
9158
- schemaContent += "}, (table) => ({\n";
9159
- schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
9160
- schemaContent += "}));\n\n";
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";
9161
9697
  } else if (!isJunction) {
9162
9698
  const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
9163
9699
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -9848,7 +10384,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9848
10384
  startAfter: request.startAfter,
9849
10385
  searchString: request.searchString
9850
10386
  }, authContext);
9851
- this.sendCollectionUpdate(clientId, subscriptionId, rows);
10387
+ this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
9852
10388
  } catch (error) {
9853
10389
  const sanitized = sanitizeErrorForClient(error, request.path);
9854
10390
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -9949,7 +10485,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9949
10485
  if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
9950
10486
  else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
9951
10487
  else if (subscription.type === "collection" && subscription.collectionRequest) {
9952
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
10488
+ if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
9953
10489
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
9954
10490
  }
9955
10491
  } catch (error) {
@@ -9979,7 +10515,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9979
10515
  if (!this._subscriptions.has(subscriptionId)) return;
9980
10516
  try {
9981
10517
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
9982
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
10518
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
9983
10519
  } catch (error) {
9984
10520
  const sanitized = sanitizeErrorForClient(error, notifyPath);
9985
10521
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10193,11 +10729,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10193
10729
  }
10194
10730
  return await this.dataService.fetchOne(notifyPath, id);
10195
10731
  }
10196
- sendCollectionUpdate(clientId, subscriptionId, rows) {
10732
+ sendCollectionUpdate(clientId, subscriptionId, rows, path) {
10197
10733
  const message = {
10198
10734
  type: "collection_update",
10199
10735
  subscriptionId,
10200
- rows
10736
+ rows,
10737
+ pks: this.primaryKeysForPath(path)
10201
10738
  };
10202
10739
  this.sendMessage(clientId, message);
10203
10740
  }
@@ -10212,16 +10749,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10212
10749
  /**
10213
10750
  * Send a lightweight row-level patch to a collection subscriber.
10214
10751
  * 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.
10215
10757
  */
10216
- sendCollectionPatch(clientId, subscriptionId, id, row) {
10758
+ sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
10217
10759
  const message = {
10218
10760
  type: "collection_patch",
10219
10761
  subscriptionId,
10220
10762
  id,
10221
- row
10763
+ row,
10764
+ pks: this.primaryKeysForPath(notifyPath)
10222
10765
  };
10223
10766
  this.sendMessage(clientId, message);
10224
10767
  }
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
+ }
10225
10779
  sendError(clientId, error, subscriptionId, code) {
10226
10780
  const message = {
10227
10781
  type: "error",
@@ -10469,12 +11023,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10469
11023
  }
10470
11024
  /** Compute the canonical (possibly composite) id string from a captured row. */
10471
11025
  extractIdFromCdcRow(collection, row) {
10472
- try {
10473
- const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
10474
- if (composite && composite !== ":::") return composite;
10475
- } catch {}
10476
- if (row.id !== void 0 && row.id !== null) return String(row.id);
10477
- return "*";
11026
+ return deriveRowAddress(row, collection, this.registry) || "*";
10478
11027
  }
10479
11028
  dedupKey(path, id, databaseId) {
10480
11029
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -11090,7 +11639,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11090
11639
  } catch (error) {
11091
11640
  logger.error("💥 [WebSocket Server] Error handling message", { error });
11092
11641
  if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
11093
- const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error instanceof Error ? error.message : "An unexpected error occurred";
11642
+ const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
11094
11643
  const errorResponse = {
11095
11644
  type: "ERROR",
11096
11645
  requestId,
@@ -19934,6 +20483,33 @@ function formatBytes(bytes) {
19934
20483
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
19935
20484
  }
19936
20485
  //#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
19937
20513
  //#region src/auth/ensure-tables.ts
19938
20514
  /**
19939
20515
  * Auto-create auth tables if they don't exist.
@@ -21916,21 +22492,14 @@ function createPostgresBootstrapper(pgConfig) {
21916
22492
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
21917
22493
  }
21918
22494
  const activeCollections = introspectedCollections ?? collections;
21919
- const registry = new PostgresCollectionRegistry();
21920
- if (activeCollections) {
21921
- registry.registerMultiple(activeCollections);
21922
- logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
21923
- }
21924
22495
  const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
21925
- if (schemaTables) Object.values(schemaTables).forEach((table) => {
21926
- if (isTable(table)) {
21927
- const tableName = getTableName(table);
21928
- registry.registerTable(table, tableName);
21929
- }
21930
- });
21931
- if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
21932
22496
  const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
21933
- if (schemaRelations) registry.registerRelations(schemaRelations);
22497
+ const registry = buildCollectionRegistry({
22498
+ collections: activeCollections,
22499
+ tables: schemaTables,
22500
+ enums: pgConfig.schema?.enums,
22501
+ relations: schemaRelations
22502
+ });
21934
22503
  if (schemaTables) patchPgArrayNullSafety(schemaTables);
21935
22504
  const mergedSchema = {
21936
22505
  ...schemaTables,