@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/PostgresBackendDriver.d.ts +25 -2
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +942 -373
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +21 -7
- package/dist/services/PersistService.d.ts +9 -1
- package/dist/services/RelationService.d.ts +0 -1
- package/dist/services/collection-helpers.d.ts +52 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +7 -0
- package/package.json +6 -7
- package/src/PostgresBackendDriver.ts +109 -8
- package/src/PostgresBootstrapper.ts +11 -23
- package/src/collections/buildRegistry.ts +59 -0
- package/src/data-transformer.ts +11 -9
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +84 -155
- package/src/services/PersistService.ts +53 -7
- package/src/services/RelationService.ts +10 -11
- package/src/services/collection-helpers.ts +129 -44
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +38 -19
- package/src/websocket.ts +4 -1
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/src/schema/auth-default-policies.ts +0 -132
package/dist/index.es.js
CHANGED
|
@@ -6,12 +6,12 @@ import { drizzle } from "drizzle-orm/node-postgres";
|
|
|
6
6
|
import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
|
|
7
7
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
8
8
|
import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
9
|
-
import { createHash, randomUUID } from "crypto";
|
|
10
9
|
import fs, { promises } from "fs";
|
|
11
10
|
import path from "path";
|
|
12
11
|
import chokidar from "chokidar";
|
|
13
12
|
import { WebSocket, WebSocketServer } from "ws";
|
|
14
13
|
import { EventEmitter } from "events";
|
|
14
|
+
import { randomUUID } from "crypto";
|
|
15
15
|
import { inspect } from "util";
|
|
16
16
|
import os from "os";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -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
|
-
*
|
|
3558
|
-
*
|
|
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
|
|
3733
|
-
*
|
|
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
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
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
|
|
4630
|
+
return findings;
|
|
4033
4631
|
}
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
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
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
5319
|
-
const targetIdField = targetTable[
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
5963
|
-
|
|
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
|
|
5968
|
-
const
|
|
5969
|
-
|
|
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
|
|
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
|
|
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
|
|
5994
|
-
const flat = {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = {
|
|
6607
|
-
for (const [k, v] of Object.entries(row)) {
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
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
|
|
7001
|
-
|
|
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
|
-
|
|
7248
|
-
|
|
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
|
|
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.
|
|
7987
|
-
const
|
|
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
|
|
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
|
|
8620
|
+
id: savedId,
|
|
8028
8621
|
row: savedRow,
|
|
8029
8622
|
databaseId: resolvedCollection?.databaseId
|
|
8030
8623
|
});
|
|
8031
|
-
else await this.realtimeService.notifyUpdate(path, savedId
|
|
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
|
|
9521
|
+
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
8995
9522
|
return ops.map((op, opIdx) => {
|
|
8996
|
-
return generateSinglePolicyCode(collection, rule, op,
|
|
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) => (
|
|
9159
|
-
schemaContent += `
|
|
9160
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|