@rebasepro/server-postgres 0.9.1-canary.ad870eb → 0.9.1-canary.b10dcdf
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/PostgresBootstrapper.d.ts +10 -0
- package/dist/auth/services.d.ts +16 -0
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +1445 -571
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +27 -1
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/collection-helpers.d.ts +79 -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/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +11 -9
- package/src/PostgresBackendDriver.ts +127 -13
- package/src/PostgresBootstrapper.ts +62 -25
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/schema/introspect-db.ts +19 -2
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +130 -14
- package/src/services/RelationService.ts +153 -94
- package/src/services/collection-helpers.ts +164 -47
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +40 -19
- package/src/services/row-pipeline.ts +215 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- 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
|
@@ -3,15 +3,15 @@ import process from "process";
|
|
|
3
3
|
const require = __createRequire(import.meta.url);
|
|
4
4
|
import { Client, Pool } from "pg";
|
|
5
5
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
6
|
-
import { createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
|
|
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";
|
|
@@ -71,16 +71,51 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
71
71
|
var connection_exports = /* @__PURE__ */ __exportAll({
|
|
72
72
|
createDirectDatabaseConnection: () => createDirectDatabaseConnection,
|
|
73
73
|
createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
|
|
74
|
-
createReadReplicaConnection: () => createReadReplicaConnection
|
|
74
|
+
createReadReplicaConnection: () => createReadReplicaConnection,
|
|
75
|
+
guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
|
|
75
76
|
});
|
|
76
77
|
var DEFAULT_POOL = {
|
|
77
78
|
max: 20,
|
|
78
79
|
idleTimeoutMillis: 3e4,
|
|
79
80
|
connectionTimeoutMillis: 1e4,
|
|
80
|
-
queryTimeout:
|
|
81
|
+
queryTimeout: 6e4,
|
|
81
82
|
statementTimeout: 3e4,
|
|
82
83
|
keepAlive: true
|
|
83
84
|
};
|
|
85
|
+
/** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
|
|
86
|
+
var TX_IDLE = "I";
|
|
87
|
+
/**
|
|
88
|
+
* Destroy pool clients that are released while still inside a transaction.
|
|
89
|
+
*
|
|
90
|
+
* pg-pool returns a client to the idle list whenever `release()` is called
|
|
91
|
+
* without an error — even if the connection is still mid-transaction (status
|
|
92
|
+
* `T`/`E`). That happens in practice: drizzle's pool transaction releases in
|
|
93
|
+
* a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
|
|
94
|
+
* (e.g. it was queued behind a statement that hit the client-side
|
|
95
|
+
* query_timeout), the client goes back dirty. The next checkout then runs
|
|
96
|
+
* its statements inside the zombie transaction — with the previous request's
|
|
97
|
+
* `app.*` RLS GUCs still applied, which turns unrelated queries into
|
|
98
|
+
* RLS-scoped ones (observed in production as registration failing with
|
|
99
|
+
* SQLSTATE 42501 under a leaked anonymous context).
|
|
100
|
+
*
|
|
101
|
+
* pg-pool emits `release` before it consults its private `_expired` set, so
|
|
102
|
+
* marking the client expired here makes `_release()` destroy it instead of
|
|
103
|
+
* pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
|
|
104
|
+
* private APIs — feature-detect and fall back to loud logging so an upstream
|
|
105
|
+
* change degrades to observability, never to silent corruption.
|
|
106
|
+
*/
|
|
107
|
+
function guardPoolAgainstDirtyRelease(pool, label) {
|
|
108
|
+
pool.on("release", (err, client) => {
|
|
109
|
+
if (err) return;
|
|
110
|
+
const txStatus = client?._txStatus;
|
|
111
|
+
if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
|
|
112
|
+
const expired = pool._expired;
|
|
113
|
+
if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
|
|
114
|
+
expired.add(client);
|
|
115
|
+
logger.error(`[${label}] Client released back to the pool while still in a transaction (status '${txStatus}') — destroying it so the open transaction and its session state (RLS GUCs) cannot leak into the next request.`);
|
|
116
|
+
} else logger.error(`[${label}] Client released mid-transaction (status '${txStatus}') but the pool's internal expiry set is unavailable (pg-pool internals changed?). The connection may leak its open transaction into subsequent requests.`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
84
119
|
/**
|
|
85
120
|
* Create a Drizzle-backed Postgres connection with a production-grade
|
|
86
121
|
* connection pool.
|
|
@@ -112,6 +147,7 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
|
|
|
112
147
|
logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
|
|
113
148
|
if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
|
|
114
149
|
});
|
|
150
|
+
guardPoolAgainstDirtyRelease(pool, "pg-pool");
|
|
115
151
|
return {
|
|
116
152
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
117
153
|
pool,
|
|
@@ -144,6 +180,7 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
|
|
|
144
180
|
pool.on("error", (err) => {
|
|
145
181
|
logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
|
|
146
182
|
});
|
|
183
|
+
guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
|
|
147
184
|
return {
|
|
148
185
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
149
186
|
pool,
|
|
@@ -173,6 +210,7 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
|
|
|
173
210
|
pool.on("error", (err) => {
|
|
174
211
|
logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
|
|
175
212
|
});
|
|
213
|
+
guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
|
|
176
214
|
return {
|
|
177
215
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
178
216
|
pool,
|
|
@@ -1502,6 +1540,140 @@ function removeFunctions(o) {
|
|
|
1502
1540
|
return o;
|
|
1503
1541
|
}
|
|
1504
1542
|
//#endregion
|
|
1543
|
+
//#region ../utils/src/sha1.ts
|
|
1544
|
+
/**
|
|
1545
|
+
* Minimal SHA-1 implementation that runs in both Node and the browser.
|
|
1546
|
+
*
|
|
1547
|
+
* This exists because generated Postgres policy names embed a SHA-1 digest of
|
|
1548
|
+
* the security rule. The DDL generator runs on the server (where `node:crypto`
|
|
1549
|
+
* is available) but the Studio has to derive the same names in the browser to
|
|
1550
|
+
* tell a policy it generated apart from one it did not. `node:crypto` cannot be
|
|
1551
|
+
* bundled for the browser, so the shared derivation needs a portable digest.
|
|
1552
|
+
*
|
|
1553
|
+
* SHA-1 is used purely to name things deterministically — never for security.
|
|
1554
|
+
* The output is byte-identical to `createHash("sha1").update(str).digest("hex")`,
|
|
1555
|
+
* which `sha1.test.ts` pins against `node:crypto` directly.
|
|
1556
|
+
*/
|
|
1557
|
+
/** Rotate a 32-bit word left by `n` bits. */
|
|
1558
|
+
function rotl(value, n) {
|
|
1559
|
+
return value << n | value >>> 32 - n;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* SHA-1 digest of a string, hex-encoded.
|
|
1563
|
+
*
|
|
1564
|
+
* The input is encoded as UTF-8, matching Node's default handling of strings
|
|
1565
|
+
* passed to `hash.update(str)`.
|
|
1566
|
+
*/
|
|
1567
|
+
function sha1Hex(input) {
|
|
1568
|
+
const bytes = Array.from(new TextEncoder().encode(input));
|
|
1569
|
+
const bitLength = bytes.length * 8;
|
|
1570
|
+
bytes.push(128);
|
|
1571
|
+
while (bytes.length % 64 !== 56) bytes.push(0);
|
|
1572
|
+
const hi = Math.floor(bitLength / 4294967296);
|
|
1573
|
+
const lo = bitLength >>> 0;
|
|
1574
|
+
bytes.push(hi >>> 24 & 255, hi >>> 16 & 255, hi >>> 8 & 255, hi & 255);
|
|
1575
|
+
bytes.push(lo >>> 24 & 255, lo >>> 16 & 255, lo >>> 8 & 255, lo & 255);
|
|
1576
|
+
let h0 = 1732584193;
|
|
1577
|
+
let h1 = 4023233417;
|
|
1578
|
+
let h2 = 2562383102;
|
|
1579
|
+
let h3 = 271733878;
|
|
1580
|
+
let h4 = 3285377520;
|
|
1581
|
+
const w = new Array(80);
|
|
1582
|
+
for (let offset = 0; offset < bytes.length; offset += 64) {
|
|
1583
|
+
for (let i = 0; i < 16; i++) {
|
|
1584
|
+
const j = offset + i * 4;
|
|
1585
|
+
w[i] = bytes[j] << 24 | bytes[j + 1] << 16 | bytes[j + 2] << 8 | bytes[j + 3] | 0;
|
|
1586
|
+
}
|
|
1587
|
+
for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
|
|
1588
|
+
let a = h0;
|
|
1589
|
+
let b = h1;
|
|
1590
|
+
let c = h2;
|
|
1591
|
+
let d = h3;
|
|
1592
|
+
let e = h4;
|
|
1593
|
+
for (let i = 0; i < 80; i++) {
|
|
1594
|
+
let f;
|
|
1595
|
+
let k;
|
|
1596
|
+
if (i < 20) {
|
|
1597
|
+
f = b & c | ~b & d;
|
|
1598
|
+
k = 1518500249;
|
|
1599
|
+
} else if (i < 40) {
|
|
1600
|
+
f = b ^ c ^ d;
|
|
1601
|
+
k = 1859775393;
|
|
1602
|
+
} else if (i < 60) {
|
|
1603
|
+
f = b & c | b & d | c & d;
|
|
1604
|
+
k = 2400959708;
|
|
1605
|
+
} else {
|
|
1606
|
+
f = b ^ c ^ d;
|
|
1607
|
+
k = 3395469782;
|
|
1608
|
+
}
|
|
1609
|
+
const temp = rotl(a, 5) + f + e + k + w[i] | 0;
|
|
1610
|
+
e = d;
|
|
1611
|
+
d = c;
|
|
1612
|
+
c = rotl(b, 30);
|
|
1613
|
+
b = a;
|
|
1614
|
+
a = temp;
|
|
1615
|
+
}
|
|
1616
|
+
h0 = h0 + a | 0;
|
|
1617
|
+
h1 = h1 + b | 0;
|
|
1618
|
+
h2 = h2 + c | 0;
|
|
1619
|
+
h3 = h3 + d | 0;
|
|
1620
|
+
h4 = h4 + e | 0;
|
|
1621
|
+
}
|
|
1622
|
+
return [
|
|
1623
|
+
h0,
|
|
1624
|
+
h1,
|
|
1625
|
+
h2,
|
|
1626
|
+
h3,
|
|
1627
|
+
h4
|
|
1628
|
+
].map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
//#region ../utils/src/policy-names.ts
|
|
1632
|
+
/**
|
|
1633
|
+
* Naming of the Postgres policies generated from a collection's security rules.
|
|
1634
|
+
*
|
|
1635
|
+
* A rule without an explicit `name` is compiled to `<table>_<op>_<hash>`, where
|
|
1636
|
+
* the hash covers the rule's semantics. The Studio needs the same names to tell
|
|
1637
|
+
* "this policy came from your code" apart from "someone wrote this in SQL" —
|
|
1638
|
+
* without them it treats generated policies as foreign and offers to import
|
|
1639
|
+
* them back into the codebase they came from.
|
|
1640
|
+
*
|
|
1641
|
+
* This is the single definition of that naming. The DDL and Drizzle generators
|
|
1642
|
+
* both derive names from here, so a change cannot silently rename every policy
|
|
1643
|
+
* in every deployed database while the UI keeps matching the old ones.
|
|
1644
|
+
*/
|
|
1645
|
+
/** Stable digest of the parts of a rule that determine what the policy does. */
|
|
1646
|
+
function getPolicyNameHash(rule) {
|
|
1647
|
+
return sha1Hex(JSON.stringify({
|
|
1648
|
+
a: rule.access,
|
|
1649
|
+
m: rule.mode,
|
|
1650
|
+
op: rule.operation,
|
|
1651
|
+
ops: rule.operations?.slice().sort(),
|
|
1652
|
+
own: rule.ownerField,
|
|
1653
|
+
rol: rule.roles?.slice().sort(),
|
|
1654
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
1655
|
+
u: rule.using,
|
|
1656
|
+
w: rule.withCheck,
|
|
1657
|
+
c: rule.condition,
|
|
1658
|
+
ch: rule.check
|
|
1659
|
+
})).substring(0, 7);
|
|
1660
|
+
}
|
|
1661
|
+
/** The operations a rule expands to — `operations` wins over `operation`. */
|
|
1662
|
+
function getPolicyOperations(rule) {
|
|
1663
|
+
return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Every Postgres policy name a single rule compiles to — one per operation.
|
|
1667
|
+
*
|
|
1668
|
+
* @param rule The security rule as written in the collection config.
|
|
1669
|
+
* @param tableName The rule's table (see `getTableName` in `@rebasepro/common`).
|
|
1670
|
+
*/
|
|
1671
|
+
function getPolicyNamesForRule(rule, tableName) {
|
|
1672
|
+
const ops = getPolicyOperations(rule);
|
|
1673
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
1674
|
+
return ops.map((op, opIdx) => rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`);
|
|
1675
|
+
}
|
|
1676
|
+
//#endregion
|
|
1505
1677
|
//#region ../utils/src/names.ts
|
|
1506
1678
|
/**
|
|
1507
1679
|
* Generates a foreign key column name from a given string, typically a collection slug or name.
|
|
@@ -1626,6 +1798,109 @@ function createRelationRefWithData(id, path, data) {
|
|
|
1626
1798
|
data
|
|
1627
1799
|
};
|
|
1628
1800
|
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Derive a row's address from its key columns.
|
|
1803
|
+
*
|
|
1804
|
+
* Single key → the value as a string. Composite → each part joined by
|
|
1805
|
+
* {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what
|
|
1806
|
+
* {@link parseIdValues} expects to invert.
|
|
1807
|
+
*/
|
|
1808
|
+
function buildCompositeId(values, primaryKeys) {
|
|
1809
|
+
if (primaryKeys.length === 0) return "";
|
|
1810
|
+
if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
|
|
1811
|
+
return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Invert {@link buildCompositeId}: turn an address back into key columns, each
|
|
1815
|
+
* coerced to the type its column actually round-trips as.
|
|
1816
|
+
*
|
|
1817
|
+
* This is the boundary where a URL segment becomes a query parameter, so a
|
|
1818
|
+
* malformed address must throw rather than silently produce a query that
|
|
1819
|
+
* matches the wrong row (or none).
|
|
1820
|
+
*/
|
|
1821
|
+
function parseIdValues(idValue, primaryKeys) {
|
|
1822
|
+
const result = {};
|
|
1823
|
+
if (primaryKeys.length === 0) return result;
|
|
1824
|
+
if (primaryKeys.length === 1) {
|
|
1825
|
+
const pk = primaryKeys[0];
|
|
1826
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
1827
|
+
const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
|
|
1828
|
+
if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
1829
|
+
result[pk.fieldName] = parsed;
|
|
1830
|
+
} else result[pk.fieldName] = String(idValue);
|
|
1831
|
+
return result;
|
|
1832
|
+
}
|
|
1833
|
+
const parts = String(idValue).split(":::");
|
|
1834
|
+
if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
|
|
1835
|
+
for (let i = 0; i < primaryKeys.length; i++) {
|
|
1836
|
+
const pk = primaryKeys[i];
|
|
1837
|
+
const val = parts[i];
|
|
1838
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
1839
|
+
const parsed = parseInt(val, 10);
|
|
1840
|
+
if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
|
|
1841
|
+
result[pk.fieldName] = parsed;
|
|
1842
|
+
} else result[pk.fieldName] = val;
|
|
1843
|
+
}
|
|
1844
|
+
return result;
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* The primary keys of a collection, as declared by its properties.
|
|
1848
|
+
*
|
|
1849
|
+
* This is the only tier both sides can read, because it is the only one written
|
|
1850
|
+
* in the config: the postgres driver can also infer keys from the Drizzle
|
|
1851
|
+
* schema, which the browser never sees and is never sent — the admin compiles
|
|
1852
|
+
* the collection files into its own bundle rather than being served them. A key
|
|
1853
|
+
* that lives only in the Drizzle schema is therefore invisible here, and the
|
|
1854
|
+
* server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
|
|
1855
|
+
* to add.
|
|
1856
|
+
*
|
|
1857
|
+
* Returns an empty array when a collection declares none, which callers must
|
|
1858
|
+
* treat as "not addressable" rather than defaulting to `id`: guessing a key
|
|
1859
|
+
* that is not the real one produces confidently wrong addresses.
|
|
1860
|
+
*/
|
|
1861
|
+
function getDeclaredPrimaryKeys(collection) {
|
|
1862
|
+
const properties = collection.properties;
|
|
1863
|
+
if (!properties) return [];
|
|
1864
|
+
const keys = [];
|
|
1865
|
+
for (const [fieldName, propRaw] of Object.entries(properties)) {
|
|
1866
|
+
const prop = propRaw;
|
|
1867
|
+
if (!prop || typeof prop !== "object") continue;
|
|
1868
|
+
if (!("isId" in prop) || !prop.isId) continue;
|
|
1869
|
+
keys.push({
|
|
1870
|
+
fieldName,
|
|
1871
|
+
type: prop.type === "number" ? "number" : "string",
|
|
1872
|
+
isUUID: prop.isId === "uuid"
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
return keys;
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* The keys to address a collection's rows with, resolved the way the driver
|
|
1879
|
+
* resolves them — minus the tier the browser cannot reach.
|
|
1880
|
+
*
|
|
1881
|
+
* The postgres driver tries, in order: properties marked `isId`; the primary
|
|
1882
|
+
* keys of the Drizzle schema; and finally a column literally named `id`. Only
|
|
1883
|
+
* the first and last are visible in a `CollectionConfig`, which is what both
|
|
1884
|
+
* sides share.
|
|
1885
|
+
*
|
|
1886
|
+
* So the two agree except on a collection that declares no `isId` and whose key
|
|
1887
|
+
* is known only to Drizzle. There, the driver reads the real key, and this
|
|
1888
|
+
* either resolves nothing (reported to the console by the caller) or — if the
|
|
1889
|
+
* table happens to have an unrelated `id` property — resolves `id`, which is
|
|
1890
|
+
* the wrong key and cannot be detected from here: the addresses look right and
|
|
1891
|
+
* route wrong. Only the config can settle it, so the server names both cases
|
|
1892
|
+
* at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
|
|
1893
|
+
*/
|
|
1894
|
+
function resolvePrimaryKeys(collection) {
|
|
1895
|
+
const declared = getDeclaredPrimaryKeys(collection);
|
|
1896
|
+
if (declared.length > 0) return declared;
|
|
1897
|
+
const idProp = collection.properties?.id;
|
|
1898
|
+
if (idProp && typeof idProp === "object") return [{
|
|
1899
|
+
fieldName: "id",
|
|
1900
|
+
type: idProp.type === "number" ? "number" : "string"
|
|
1901
|
+
}];
|
|
1902
|
+
return [];
|
|
1903
|
+
}
|
|
1629
1904
|
//#endregion
|
|
1630
1905
|
//#region ../common/src/util/enums.ts
|
|
1631
1906
|
function enumToObjectEntries(enumValues) {
|
|
@@ -1860,15 +2135,109 @@ function getSubcollections(collection) {
|
|
|
1860
2135
|
* - `field = 'literal'`
|
|
1861
2136
|
* - `field != 'literal'`
|
|
1862
2137
|
* - `field = current_setting('app.user_id')`
|
|
1863
|
-
* - `A AND B`
|
|
2138
|
+
* - `A AND B`, `A OR B` — only where the keyword is at the top level
|
|
1864
2139
|
* - `true`
|
|
1865
2140
|
* - `IN (...)` (as optimistic true)
|
|
1866
2141
|
*
|
|
1867
2142
|
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
1868
2143
|
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
2144
|
+
*
|
|
2145
|
+
* **This output also round-trips back into DDL** via `policyToPostgres` (the
|
|
2146
|
+
* schema/policy generators), so decomposing a clause the parser only partly
|
|
2147
|
+
* understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
|
|
2148
|
+
* prefer `raw`: it is reproduced verbatim.
|
|
1869
2149
|
*/
|
|
2150
|
+
/** True when `keyword` starts at `i` as a standalone word. */
|
|
2151
|
+
function isKeywordAt(upper, i, keyword) {
|
|
2152
|
+
if (!upper.startsWith(keyword, i)) return false;
|
|
2153
|
+
const before = i === 0 ? " " : upper[i - 1];
|
|
2154
|
+
const after = upper[i + keyword.length] ?? " ";
|
|
2155
|
+
return /[\s()]/.test(before) && /[\s()]/.test(after);
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
|
|
2159
|
+
* outside a string literal. Returns null when it never does, so the caller
|
|
2160
|
+
* leaves the clause alone.
|
|
2161
|
+
*
|
|
2162
|
+
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
2163
|
+
* `AND` inside
|
|
2164
|
+
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
|
|
2165
|
+
* split the expression, and re-emitting the halves produced
|
|
2166
|
+
* `(EXISTS (...) AND m.user_id = auth.uid())`
|
|
2167
|
+
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
2168
|
+
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
2169
|
+
* clause as a `raw` expression, which round-trips verbatim.
|
|
2170
|
+
*/
|
|
2171
|
+
function splitTopLevel(sql, keyword) {
|
|
2172
|
+
const upper = sql.toUpperCase();
|
|
2173
|
+
const parts = [];
|
|
2174
|
+
let depth = 0;
|
|
2175
|
+
let inString = false;
|
|
2176
|
+
let start = 0;
|
|
2177
|
+
for (let i = 0; i < sql.length; i++) {
|
|
2178
|
+
const ch = sql[i];
|
|
2179
|
+
if (inString) {
|
|
2180
|
+
if (ch === "'") if (sql[i + 1] === "'") i++;
|
|
2181
|
+
else inString = false;
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
if (ch === "'") {
|
|
2185
|
+
inString = true;
|
|
2186
|
+
continue;
|
|
2187
|
+
}
|
|
2188
|
+
if (ch === "(") {
|
|
2189
|
+
depth++;
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
if (ch === ")") {
|
|
2193
|
+
depth--;
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
if (depth === 0 && isKeywordAt(upper, i, keyword)) {
|
|
2197
|
+
parts.push(sql.slice(start, i));
|
|
2198
|
+
i += keyword.length - 1;
|
|
2199
|
+
start = i + 1;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
if (parts.length === 0) return null;
|
|
2203
|
+
parts.push(sql.slice(start));
|
|
2204
|
+
const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
2205
|
+
return trimmedParts.length > 1 ? trimmedParts : null;
|
|
2206
|
+
}
|
|
2207
|
+
/** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
|
|
2208
|
+
function stripOuterParens(sql) {
|
|
2209
|
+
let s = sql.trim();
|
|
2210
|
+
for (;;) {
|
|
2211
|
+
if (!s.startsWith("(") || !s.endsWith(")")) return s;
|
|
2212
|
+
let depth = 0;
|
|
2213
|
+
let inString = false;
|
|
2214
|
+
let wraps = true;
|
|
2215
|
+
for (let i = 0; i < s.length; i++) {
|
|
2216
|
+
const ch = s[i];
|
|
2217
|
+
if (inString) {
|
|
2218
|
+
if (ch === "'") if (s[i + 1] === "'") i++;
|
|
2219
|
+
else inString = false;
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
if (ch === "'") {
|
|
2223
|
+
inString = true;
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
if (ch === "(") depth++;
|
|
2227
|
+
else if (ch === ")") {
|
|
2228
|
+
depth--;
|
|
2229
|
+
if (depth === 0 && i < s.length - 1) {
|
|
2230
|
+
wraps = false;
|
|
2231
|
+
break;
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
if (!wraps) return s;
|
|
2236
|
+
s = s.slice(1, -1).trim();
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
1870
2239
|
function sqlToPolicy(sql) {
|
|
1871
|
-
const trimmed = sql.trim();
|
|
2240
|
+
const trimmed = stripOuterParens(sql.trim());
|
|
1872
2241
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
1873
2242
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
1874
2243
|
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
@@ -1881,14 +2250,10 @@ function sqlToPolicy(sql) {
|
|
|
1881
2250
|
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
1882
2251
|
return policy.rolesContain(roles);
|
|
1883
2252
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
if (trimmed.toUpperCase().includes(" AND ")) {
|
|
1889
|
-
const parts = trimmed.split(/ AND /i);
|
|
1890
|
-
return policy.and(...parts.map(sqlToPolicy));
|
|
1891
|
-
}
|
|
2253
|
+
const orParts = splitTopLevel(trimmed, "OR");
|
|
2254
|
+
if (orParts) return policy.or(...orParts.map(sqlToPolicy));
|
|
2255
|
+
const andParts = splitTopLevel(trimmed, "AND");
|
|
2256
|
+
if (andParts) return policy.and(...andParts.map(sqlToPolicy));
|
|
1892
2257
|
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
1893
2258
|
if (match) {
|
|
1894
2259
|
const [, leftStr, op, rightStr] = match;
|
|
@@ -2194,6 +2559,307 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
2194
2559
|
};
|
|
2195
2560
|
return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
|
|
2196
2561
|
};
|
|
2562
|
+
//#endregion
|
|
2563
|
+
//#region ../common/src/util/auth-default-policies.ts
|
|
2564
|
+
/**
|
|
2565
|
+
* Default RLS policies injected by the schema generator.
|
|
2566
|
+
*
|
|
2567
|
+
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
2568
|
+
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
2569
|
+
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
2570
|
+
* authorization model. The server context (auth flows, migrations,
|
|
2571
|
+
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
2572
|
+
*
|
|
2573
|
+
* Because RLS default-denies, every collection is **locked by default**: with
|
|
2574
|
+
* no rules, only the server context and admins can touch it. The generator
|
|
2575
|
+
* injects that safe baseline:
|
|
2576
|
+
*
|
|
2577
|
+
* **For every collection**
|
|
2578
|
+
* 1. A permissive **server-or-admin SELECT** grant.
|
|
2579
|
+
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
2580
|
+
*
|
|
2581
|
+
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
2582
|
+
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
2583
|
+
* rows").
|
|
2584
|
+
*
|
|
2585
|
+
* **For auth collections additionally**
|
|
2586
|
+
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
2587
|
+
* their own row (profile, session bootstrap) without every app re-declaring
|
|
2588
|
+
* it.
|
|
2589
|
+
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
2590
|
+
* every other policy, so a write is rejected unless the caller is an admin
|
|
2591
|
+
* (or the server context) — even if the author also wrote a permissive rule
|
|
2592
|
+
* such as "a user may edit their own row". Without this, a permissive owner
|
|
2593
|
+
* rule would let a user change their own `roles`.
|
|
2594
|
+
*
|
|
2595
|
+
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
2596
|
+
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
2597
|
+
* GUC — which also lets the owner connection satisfy these policies even under
|
|
2598
|
+
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
2599
|
+
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
2600
|
+
*
|
|
2601
|
+
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
2602
|
+
* the collection's RLS.
|
|
2603
|
+
*/
|
|
2604
|
+
var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2605
|
+
/** Write operations that must be admin-gated by default on auth collections. */
|
|
2606
|
+
var DEFAULT_GUARDED_OPS = [
|
|
2607
|
+
"insert",
|
|
2608
|
+
"update",
|
|
2609
|
+
"delete"
|
|
2610
|
+
];
|
|
2611
|
+
/** Whether a collection is flagged as an authentication collection. */
|
|
2612
|
+
function isAuthCollection(collection) {
|
|
2613
|
+
const auth = collection.auth;
|
|
2614
|
+
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
2615
|
+
}
|
|
2616
|
+
/** The property marked as the row id (falls back to `id`). */
|
|
2617
|
+
function getIdPropertyName$1(collection) {
|
|
2618
|
+
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2619
|
+
return "id";
|
|
2620
|
+
}
|
|
2621
|
+
/**
|
|
2622
|
+
* Returns the security rules that should be applied to a collection: the
|
|
2623
|
+
* author's explicit `securityRules` plus the framework defaults described in
|
|
2624
|
+
* the module doc (baseline server/admin read for all collections; self-read
|
|
2625
|
+
* and the admin write gate for auth collections).
|
|
2626
|
+
*
|
|
2627
|
+
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
2628
|
+
*/
|
|
2629
|
+
function getEffectiveSecurityRules(collection) {
|
|
2630
|
+
const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
|
|
2631
|
+
if (collection.disableDefaultPolicies) return explicit;
|
|
2632
|
+
const tableName = getTableName$1(collection);
|
|
2633
|
+
const injected = [];
|
|
2634
|
+
injected.push({
|
|
2635
|
+
name: `${tableName}_default_admin_read`,
|
|
2636
|
+
operations: ["select"],
|
|
2637
|
+
condition: SERVER_OR_ADMIN_EXPR$1
|
|
2638
|
+
});
|
|
2639
|
+
injected.push({
|
|
2640
|
+
name: `${tableName}_default_admin_write`,
|
|
2641
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
2642
|
+
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2643
|
+
check: SERVER_OR_ADMIN_EXPR$1
|
|
2644
|
+
});
|
|
2645
|
+
if (isAuthCollection(collection)) {
|
|
2646
|
+
injected.push({
|
|
2647
|
+
name: `${tableName}_default_self_read`,
|
|
2648
|
+
operations: ["select"],
|
|
2649
|
+
condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
|
|
2650
|
+
});
|
|
2651
|
+
injected.push({
|
|
2652
|
+
name: `${tableName}_require_admin_write`,
|
|
2653
|
+
mode: "restrictive",
|
|
2654
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
2655
|
+
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2656
|
+
check: SERVER_OR_ADMIN_EXPR$1
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
return [...explicit, ...injected];
|
|
2660
|
+
}
|
|
2661
|
+
//#endregion
|
|
2662
|
+
//#region ../common/src/util/junction-policies.ts
|
|
2663
|
+
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2664
|
+
/**
|
|
2665
|
+
* Walk every collection's resolved relations and aggregate the junction tables
|
|
2666
|
+
* they declare. Two collections may declare the same junction from opposite
|
|
2667
|
+
* sides (posts→tags and tags→posts through `posts_tags`); both become
|
|
2668
|
+
* `declaringSides` of one spec, so derived write grants consider both.
|
|
2669
|
+
*/
|
|
2670
|
+
function resolveJunctionSpecs(collections) {
|
|
2671
|
+
const specs = /* @__PURE__ */ new Map();
|
|
2672
|
+
for (const collection of collections) {
|
|
2673
|
+
const resolved = resolveCollectionRelations(collection);
|
|
2674
|
+
for (const relation of Object.values(resolved)) {
|
|
2675
|
+
if (!relation.through) continue;
|
|
2676
|
+
const targetCollection = typeof relation.target === "function" ? relation.target() : void 0;
|
|
2677
|
+
if (!targetCollection) continue;
|
|
2678
|
+
const rawName = relation.through.table;
|
|
2679
|
+
const table = rawName.includes(".") ? rawName.split(".").pop() : rawName;
|
|
2680
|
+
const schema = "public";
|
|
2681
|
+
const source = {
|
|
2682
|
+
collection,
|
|
2683
|
+
junctionColumn: relation.through.sourceColumn,
|
|
2684
|
+
relation
|
|
2685
|
+
};
|
|
2686
|
+
const target = {
|
|
2687
|
+
collection: targetCollection,
|
|
2688
|
+
junctionColumn: relation.through.targetColumn
|
|
2689
|
+
};
|
|
2690
|
+
const existing = specs.get(table);
|
|
2691
|
+
if (!existing) specs.set(table, {
|
|
2692
|
+
table,
|
|
2693
|
+
schema,
|
|
2694
|
+
endpoints: [source, target],
|
|
2695
|
+
declaringSides: [source]
|
|
2696
|
+
});
|
|
2697
|
+
else if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
return specs;
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* A synthetic CollectionConfig standing in for the junction during policy
|
|
2704
|
+
* compilation and naming. Its two FK columns carry explicit `columnName`s so
|
|
2705
|
+
* `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
|
|
2706
|
+
* whatever their casing.
|
|
2707
|
+
*/
|
|
2708
|
+
function getJunctionCollectionConfig(spec) {
|
|
2709
|
+
const properties = {};
|
|
2710
|
+
for (const endpoint of spec.endpoints) properties[endpoint.junctionColumn] = {
|
|
2711
|
+
type: "string",
|
|
2712
|
+
columnName: endpoint.junctionColumn
|
|
2713
|
+
};
|
|
2714
|
+
return {
|
|
2715
|
+
slug: spec.table,
|
|
2716
|
+
name: spec.table,
|
|
2717
|
+
table: spec.table,
|
|
2718
|
+
schema: spec.schema,
|
|
2719
|
+
properties
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
/** The property marked as the row id (falls back to `id`). */
|
|
2723
|
+
function getIdPropertyName(collection) {
|
|
2724
|
+
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2725
|
+
return "id";
|
|
2726
|
+
}
|
|
2727
|
+
/** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */
|
|
2728
|
+
function existsEndpoint(endpoint, extra) {
|
|
2729
|
+
const correlation = policy.compare(policy.field(getIdPropertyName(endpoint.collection)), "eq", policy.outerField(endpoint.junctionColumn));
|
|
2730
|
+
return policy.existsIn({
|
|
2731
|
+
collection: endpoint.collection.slug,
|
|
2732
|
+
where: extra ? policy.and(correlation, extra) : correlation
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Whether a parent-rule expression keeps its meaning when moved inside the
|
|
2737
|
+
* junction's `EXISTS` subquery — and the re-scoped copy if it does.
|
|
2738
|
+
*
|
|
2739
|
+
* Returns `null` when the rule cannot be embedded faithfully: `raw` SQL
|
|
2740
|
+
* anywhere (its `{column}` placeholders would bind to the junction), or an
|
|
2741
|
+
* `outerField` inside a nested `existsIn` (it would bind to the junction while
|
|
2742
|
+
* the author meant the parent, and no operand can express "the middle scope").
|
|
2743
|
+
* Top-level `outerField`s are rewritten to `field`, which is what they meant.
|
|
2744
|
+
*/
|
|
2745
|
+
function embedParentExpression(expr, depth = 0) {
|
|
2746
|
+
switch (expr.kind) {
|
|
2747
|
+
case "raw": return null;
|
|
2748
|
+
case "and":
|
|
2749
|
+
case "or": {
|
|
2750
|
+
const parts = [];
|
|
2751
|
+
for (const child of expr.operands) {
|
|
2752
|
+
const embedded = embedParentExpression(child, depth);
|
|
2753
|
+
if (!embedded) return null;
|
|
2754
|
+
parts.push(embedded);
|
|
2755
|
+
}
|
|
2756
|
+
return expr.kind === "and" ? policy.and(...parts) : policy.or(...parts);
|
|
2757
|
+
}
|
|
2758
|
+
case "not": {
|
|
2759
|
+
const embedded = embedParentExpression(expr.operand, depth);
|
|
2760
|
+
return embedded ? policy.not(embedded) : null;
|
|
2761
|
+
}
|
|
2762
|
+
case "existsIn": {
|
|
2763
|
+
const where = embedParentExpression(expr.where, depth + 1);
|
|
2764
|
+
return where ? policy.existsIn({
|
|
2765
|
+
collection: expr.collection,
|
|
2766
|
+
where
|
|
2767
|
+
}) : null;
|
|
2768
|
+
}
|
|
2769
|
+
case "compare": {
|
|
2770
|
+
const left = embedOperand(expr.left, depth);
|
|
2771
|
+
const right = embedOperand(expr.right, depth);
|
|
2772
|
+
if (!left || !right) return null;
|
|
2773
|
+
return {
|
|
2774
|
+
...expr,
|
|
2775
|
+
left,
|
|
2776
|
+
right
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
default: return expr;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
/** Re-scope an operand, or return `null` if its binding cannot be preserved. */
|
|
2783
|
+
function embedOperand(operand, depth) {
|
|
2784
|
+
if (operand.kind === "outerField") {
|
|
2785
|
+
if (depth === 0) return policy.field(operand.name);
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
return operand;
|
|
2789
|
+
}
|
|
2790
|
+
/** Does the rule cover the `update` operation? */
|
|
2791
|
+
function coversUpdate(rule) {
|
|
2792
|
+
return getPolicyOperations(rule).some((op) => op === "update" || op === "all");
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* The full derived policy set for a junction table: the locked server/admin
|
|
2796
|
+
* baseline, the endpoint-visibility read grant, inherited write grants, and
|
|
2797
|
+
* inherited restrictive gates. Returns `[]` when every declaring collection set
|
|
2798
|
+
* `disableDefaultPolicies` — the junction is then the author's to police, and
|
|
2799
|
+
* stays locked (RLS is still enabled) until they write policies for it.
|
|
2800
|
+
*/
|
|
2801
|
+
function getJunctionSecurityRules(spec) {
|
|
2802
|
+
if (spec.declaringSides.every((side) => side.collection.disableDefaultPolicies)) return [];
|
|
2803
|
+
const rules = [];
|
|
2804
|
+
rules.push({
|
|
2805
|
+
name: `${spec.table}_default_admin_read`,
|
|
2806
|
+
operations: ["select"],
|
|
2807
|
+
condition: SERVER_OR_ADMIN_EXPR
|
|
2808
|
+
});
|
|
2809
|
+
rules.push({
|
|
2810
|
+
name: `${spec.table}_default_admin_write`,
|
|
2811
|
+
operations: [
|
|
2812
|
+
"insert",
|
|
2813
|
+
"update",
|
|
2814
|
+
"delete"
|
|
2815
|
+
],
|
|
2816
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
2817
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
2818
|
+
});
|
|
2819
|
+
rules.push({
|
|
2820
|
+
name: `${spec.table}_default_edge_read`,
|
|
2821
|
+
operations: ["select"],
|
|
2822
|
+
condition: policy.and(existsEndpoint(spec.endpoints[0]), existsEndpoint(spec.endpoints[1]))
|
|
2823
|
+
});
|
|
2824
|
+
const writeGrants = [];
|
|
2825
|
+
for (const side of spec.declaringSides) {
|
|
2826
|
+
const updateRules = ((isPostgresCollectionConfig(side.collection) ? side.collection.securityRules : void 0) ?? []).filter(coversUpdate);
|
|
2827
|
+
const permissive = updateRules.filter((r) => r.mode !== "restrictive");
|
|
2828
|
+
const restrictive = updateRules.filter((r) => r.mode === "restrictive");
|
|
2829
|
+
const embeddedGates = [];
|
|
2830
|
+
let gatesEmbeddable = true;
|
|
2831
|
+
for (const gate of restrictive) {
|
|
2832
|
+
const using = securityRuleToConditions(gate).usingExpr;
|
|
2833
|
+
const embedded = using ? embedParentExpression(using) : null;
|
|
2834
|
+
if (!embedded) {
|
|
2835
|
+
gatesEmbeddable = false;
|
|
2836
|
+
break;
|
|
2837
|
+
}
|
|
2838
|
+
embeddedGates.push(embedded);
|
|
2839
|
+
}
|
|
2840
|
+
if (!gatesEmbeddable) continue;
|
|
2841
|
+
const grants = [];
|
|
2842
|
+
for (const rule of permissive) {
|
|
2843
|
+
const using = securityRuleToConditions(rule).usingExpr;
|
|
2844
|
+
const embedded = using ? embedParentExpression(using) : null;
|
|
2845
|
+
if (embedded) grants.push(embedded);
|
|
2846
|
+
}
|
|
2847
|
+
if (grants.length === 0) continue;
|
|
2848
|
+
const condition = embeddedGates.length > 0 ? policy.and(policy.or(...grants), ...embeddedGates) : policy.or(...grants);
|
|
2849
|
+
writeGrants.push(existsEndpoint(side, condition));
|
|
2850
|
+
}
|
|
2851
|
+
if (writeGrants.length > 0) rules.push({
|
|
2852
|
+
name: `${spec.table}_default_edge_write`,
|
|
2853
|
+
operations: [
|
|
2854
|
+
"insert",
|
|
2855
|
+
"update",
|
|
2856
|
+
"delete"
|
|
2857
|
+
],
|
|
2858
|
+
condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),
|
|
2859
|
+
check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)
|
|
2860
|
+
});
|
|
2861
|
+
return rules;
|
|
2862
|
+
}
|
|
2197
2863
|
(/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
2198
2864
|
(function(root, factory) {
|
|
2199
2865
|
if (typeof define === "function" && define.amd) define(factory);
|
|
@@ -3463,18 +4129,45 @@ function deserializeFilter(query) {
|
|
|
3463
4129
|
}
|
|
3464
4130
|
//#endregion
|
|
3465
4131
|
//#region ../common/src/data/buildRebaseData.ts
|
|
4132
|
+
function createPrimaryKeyResolver(options) {
|
|
4133
|
+
const cache = /* @__PURE__ */ new Map();
|
|
4134
|
+
const warned = /* @__PURE__ */ new Set();
|
|
4135
|
+
return function primaryKeysFor(slug) {
|
|
4136
|
+
const cached = cache.get(slug);
|
|
4137
|
+
if (cached) return cached;
|
|
4138
|
+
const collection = options?.resolveCollection?.(slug);
|
|
4139
|
+
if (!collection) return [];
|
|
4140
|
+
const keys = resolvePrimaryKeys(collection);
|
|
4141
|
+
if (keys.length > 0) {
|
|
4142
|
+
cache.set(slug, keys);
|
|
4143
|
+
return keys;
|
|
4144
|
+
}
|
|
4145
|
+
if (!warned.has(slug)) {
|
|
4146
|
+
warned.add(slug);
|
|
4147
|
+
console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
|
|
4148
|
+
}
|
|
4149
|
+
return keys;
|
|
4150
|
+
};
|
|
4151
|
+
}
|
|
3466
4152
|
/**
|
|
3467
|
-
*
|
|
3468
|
-
*
|
|
4153
|
+
* Give a flat row the Entity view-model the admin renders.
|
|
4154
|
+
*
|
|
4155
|
+
* The address is *derived here* — it is not a column, and the row it came from
|
|
4156
|
+
* does not contain one. Rows carry exactly what the table has, with the types
|
|
4157
|
+
* Postgres returned; the id is this layer's invention, and this is the only
|
|
4158
|
+
* place it is minted.
|
|
4159
|
+
*
|
|
4160
|
+
* `primaryKeys` empty falls back to a literal `id` on the row: drivers other
|
|
4161
|
+
* than postgres still serve rows with one, and this keeps them working.
|
|
3469
4162
|
*/
|
|
3470
|
-
function rowToEntity(row, slug) {
|
|
4163
|
+
function rowToEntity(row, slug, primaryKeys = []) {
|
|
3471
4164
|
return {
|
|
3472
|
-
id: row.id,
|
|
4165
|
+
id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
|
|
3473
4166
|
path: slug,
|
|
3474
4167
|
values: row
|
|
3475
4168
|
};
|
|
3476
4169
|
}
|
|
3477
|
-
function createDriverAccessor(driver, slug) {
|
|
4170
|
+
function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
3478
4171
|
const accessor = {
|
|
3479
4172
|
async find(params) {
|
|
3480
4173
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
@@ -3507,7 +4200,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
3507
4200
|
hasMore = offset + rows.length < total;
|
|
3508
4201
|
}
|
|
3509
4202
|
return {
|
|
3510
|
-
data: rows.map((row) => rowToEntity(row, slug)),
|
|
4203
|
+
data: rows.map((row) => rowToEntity(row, slug, getPks())),
|
|
3511
4204
|
meta: {
|
|
3512
4205
|
total,
|
|
3513
4206
|
limit,
|
|
@@ -3521,7 +4214,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
3521
4214
|
path: slug,
|
|
3522
4215
|
id
|
|
3523
4216
|
});
|
|
3524
|
-
return row ? rowToEntity(row, slug) : void 0;
|
|
4217
|
+
return row ? rowToEntity(row, slug, getPks()) : void 0;
|
|
3525
4218
|
},
|
|
3526
4219
|
async create(data, id) {
|
|
3527
4220
|
return rowToEntity(await driver.save({
|
|
@@ -3529,15 +4222,22 @@ function createDriverAccessor(driver, slug) {
|
|
|
3529
4222
|
values: data,
|
|
3530
4223
|
id,
|
|
3531
4224
|
status: "new"
|
|
3532
|
-
}), slug);
|
|
4225
|
+
}), slug, getPks());
|
|
3533
4226
|
},
|
|
4227
|
+
createMany: driver.saveMany ? async (data, options) => {
|
|
4228
|
+
return (await driver.saveMany({
|
|
4229
|
+
path: slug,
|
|
4230
|
+
rows: data,
|
|
4231
|
+
upsert: options?.upsert
|
|
4232
|
+
})).map((row) => rowToEntity(row, slug, getPks()));
|
|
4233
|
+
} : void 0,
|
|
3534
4234
|
async update(id, data) {
|
|
3535
4235
|
return rowToEntity(await driver.save({
|
|
3536
4236
|
path: slug,
|
|
3537
4237
|
values: data,
|
|
3538
4238
|
id,
|
|
3539
4239
|
status: "existing"
|
|
3540
|
-
}), slug);
|
|
4240
|
+
}), slug, getPks());
|
|
3541
4241
|
},
|
|
3542
4242
|
async delete(id) {
|
|
3543
4243
|
return driver.delete({ row: {
|
|
@@ -3566,7 +4266,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
3566
4266
|
searchString: params?.searchString,
|
|
3567
4267
|
onUpdate: (entities) => {
|
|
3568
4268
|
onUpdate({
|
|
3569
|
-
data: entities.map((row) => rowToEntity(row, slug)),
|
|
4269
|
+
data: entities.map((row) => rowToEntity(row, slug, getPks())),
|
|
3570
4270
|
meta: {
|
|
3571
4271
|
total: entities.length,
|
|
3572
4272
|
limit,
|
|
@@ -3582,7 +4282,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
3582
4282
|
return driver.listenOne({
|
|
3583
4283
|
path: slug,
|
|
3584
4284
|
id,
|
|
3585
|
-
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
|
|
4285
|
+
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
|
|
3586
4286
|
onError
|
|
3587
4287
|
});
|
|
3588
4288
|
} : void 0,
|
|
@@ -3621,12 +4321,13 @@ function createDriverAccessor(driver, slug) {
|
|
|
3621
4321
|
* await data.products.create({ name: "Camera", price: 299 });
|
|
3622
4322
|
* const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
|
|
3623
4323
|
*/
|
|
3624
|
-
function buildRebaseData(driver) {
|
|
4324
|
+
function buildRebaseData(driver, options) {
|
|
3625
4325
|
const cache = /* @__PURE__ */ new Map();
|
|
4326
|
+
const primaryKeysFor = createPrimaryKeyResolver(options);
|
|
3626
4327
|
function getAccessor(slug) {
|
|
3627
4328
|
let accessor = cache.get(slug);
|
|
3628
4329
|
if (!accessor) {
|
|
3629
|
-
accessor = createDriverAccessor(driver, slug);
|
|
4330
|
+
accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
|
|
3630
4331
|
cache.set(slug, accessor);
|
|
3631
4332
|
}
|
|
3632
4333
|
return accessor;
|
|
@@ -3639,8 +4340,9 @@ function buildRebaseData(driver) {
|
|
|
3639
4340
|
} });
|
|
3640
4341
|
}
|
|
3641
4342
|
/**
|
|
3642
|
-
* Unwrap a Entity into
|
|
3643
|
-
*
|
|
4343
|
+
* Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
|
|
4344
|
+
* the row untouched under `.values` and derives `.id` alongside it, so dropping
|
|
4345
|
+
* the wrapper is the whole operation — the address was never part of the row.
|
|
3644
4346
|
*/
|
|
3645
4347
|
function entityToRow(entity) {
|
|
3646
4348
|
return entity.values;
|
|
@@ -3727,6 +4429,12 @@ function toSdkCollectionClient(snap) {
|
|
|
3727
4429
|
async create(data, id) {
|
|
3728
4430
|
return entityToRow(await snap.create(data, id));
|
|
3729
4431
|
},
|
|
4432
|
+
async createMany(data, options) {
|
|
4433
|
+
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
4434
|
+
if (data.length === 0) return [];
|
|
4435
|
+
if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
|
|
4436
|
+
return (await snap.createMany(data, options)).map(entityToRow);
|
|
4437
|
+
},
|
|
3730
4438
|
async update(id, data) {
|
|
3731
4439
|
return entityToRow(await snap.update(id, data));
|
|
3732
4440
|
},
|
|
@@ -3879,8 +4587,26 @@ function getTableForCollection(collection, registry) {
|
|
|
3879
4587
|
if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
|
|
3880
4588
|
return table;
|
|
3881
4589
|
}
|
|
4590
|
+
/**
|
|
4591
|
+
* The key columns a collection's rows are addressed by.
|
|
4592
|
+
*
|
|
4593
|
+
* Three tiers, in order: properties marked `isId`, the primary keys of the
|
|
4594
|
+
* drizzle schema, and finally a column literally named `id`. Only the first is
|
|
4595
|
+
* visible to the browser, which is why a key known only to drizzle is reported
|
|
4596
|
+
* at boot — see {@link warnOnKeysTheAdminCannotResolve}.
|
|
4597
|
+
*
|
|
4598
|
+
* Returns `[]` when nothing resolves, rather than throwing. It used to open by
|
|
4599
|
+
* resolving the table, which throws when there is none — so the `isId` tier,
|
|
4600
|
+
* which needs no table at all, was unreachable for exactly the collections
|
|
4601
|
+
* most likely to have no table registered. Every caller that wanted "no keys"
|
|
4602
|
+
* to mean "no keys" had to spell that out in a try/catch.
|
|
4603
|
+
*
|
|
4604
|
+
* Callers that cannot proceed without a key must say so themselves, naming the
|
|
4605
|
+
* collection: an empty array here means "this collection has no address", which
|
|
4606
|
+
* is a different answer in a notification (broadcast a wildcard) than in a save
|
|
4607
|
+
* (fail).
|
|
4608
|
+
*/
|
|
3882
4609
|
function getPrimaryKeys(collection, registry) {
|
|
3883
|
-
const table = getTableForCollection(collection, registry);
|
|
3884
4610
|
if (collection.properties) {
|
|
3885
4611
|
const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
|
|
3886
4612
|
fieldName: key,
|
|
@@ -3889,6 +4615,8 @@ function getPrimaryKeys(collection, registry) {
|
|
|
3889
4615
|
}));
|
|
3890
4616
|
if (idProps.length > 0) return idProps;
|
|
3891
4617
|
}
|
|
4618
|
+
const table = registry.getTable(getTableName$1(collection));
|
|
4619
|
+
if (!table) return [];
|
|
3892
4620
|
const keys = [];
|
|
3893
4621
|
for (const [key, colRaw] of Object.entries(table)) {
|
|
3894
4622
|
const col = colRaw;
|
|
@@ -3916,35 +4644,89 @@ function getPrimaryKeys(collection, registry) {
|
|
|
3916
4644
|
}
|
|
3917
4645
|
return keys;
|
|
3918
4646
|
}
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
4647
|
+
/**
|
|
4648
|
+
* The key columns, for callers that cannot do their job without one.
|
|
4649
|
+
*
|
|
4650
|
+
* {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
|
|
4651
|
+
* collection with no address. Most of this driver, though, is building a WHERE
|
|
4652
|
+
* clause and has no meaning without a key — for those, an empty array is not an
|
|
4653
|
+
* answer, and indexing `[0]` into it produces `Cannot read properties of
|
|
4654
|
+
* undefined` three frames from where the real problem is. This says what is
|
|
4655
|
+
* wrong and which collection it is wrong about.
|
|
4656
|
+
*/
|
|
4657
|
+
function requirePrimaryKeys(collection, registry) {
|
|
4658
|
+
const keys = getPrimaryKeys(collection, registry);
|
|
4659
|
+
if (keys.length === 0) throw new Error(`Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`);
|
|
4660
|
+
return keys;
|
|
4661
|
+
}
|
|
4662
|
+
/**
|
|
4663
|
+
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
4664
|
+
* instead.
|
|
4665
|
+
*
|
|
4666
|
+
* The two sides resolve keys from different evidence. This driver reads, in
|
|
4667
|
+
* order: properties marked `isId`, the primary keys of the Drizzle schema, then
|
|
4668
|
+
* a column literally named `id`. The admin shares the `CollectionConfig` — it
|
|
4669
|
+
* compiles the same collection files into its bundle — but never the Drizzle
|
|
4670
|
+
* schema, so the middle tier is invisible to it.
|
|
4671
|
+
*
|
|
4672
|
+
* Nothing can normalize this at runtime: the server does not serve the admin
|
|
4673
|
+
* its collections, so a key resolved here cannot be handed over there. The
|
|
4674
|
+
* config files are the only thing both sides read, so the fix is an edit to
|
|
4675
|
+
* them, and the most this can do is say exactly which edit.
|
|
4676
|
+
*
|
|
4677
|
+
* Two shapes, and the second is the dangerous one:
|
|
4678
|
+
*
|
|
4679
|
+
* - No `isId`, no `id` property → the admin resolves no address, warns in the
|
|
4680
|
+
* console, and rows cannot be opened or linked.
|
|
4681
|
+
* - No `isId`, but an `id` property that is *not* the key → the admin addresses
|
|
4682
|
+
* rows by `id` while this driver reads the address as the real key. Nothing
|
|
4683
|
+
* errors: the addresses look right and route wrong.
|
|
4684
|
+
*/
|
|
4685
|
+
function findUnresolvableKeyCollections(collections, registry) {
|
|
4686
|
+
const findings = [];
|
|
4687
|
+
for (const collection of collections) {
|
|
4688
|
+
if (getDeclaredPrimaryKeys(collection).length > 0) continue;
|
|
4689
|
+
const keys = getPrimaryKeys(collection, registry);
|
|
4690
|
+
if (keys.length === 0) continue;
|
|
4691
|
+
if (keys.length === 1 && keys[0].fieldName === "id") continue;
|
|
4692
|
+
findings.push({
|
|
4693
|
+
collection,
|
|
4694
|
+
keys,
|
|
4695
|
+
shadowedByIdProperty: Boolean(collection.properties?.id)
|
|
4696
|
+
});
|
|
3941
4697
|
}
|
|
3942
|
-
return
|
|
4698
|
+
return findings;
|
|
3943
4699
|
}
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
4700
|
+
/**
|
|
4701
|
+
* Report the collections from {@link findUnresolvableKeyCollections} at boot,
|
|
4702
|
+
* with the edit that fixes each one.
|
|
4703
|
+
*
|
|
4704
|
+
* Grouped by failure, not by collection: the shadowed case is a routing bug and
|
|
4705
|
+
* the silent case is a missing feature, and they deserve different urgency.
|
|
4706
|
+
*/
|
|
4707
|
+
function warnOnKeysTheAdminCannotResolve(collections, registry) {
|
|
4708
|
+
const findings = findUnresolvableKeyCollections(collections, registry);
|
|
4709
|
+
if (findings.length === 0) return;
|
|
4710
|
+
const edit = (f) => `${f.collection.slug}: mark ${f.keys.map((k) => `\`${k.fieldName}\``).join(" and ")} with \`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
|
|
4711
|
+
const shadowed = findings.filter((f) => f.shadowedByIdProperty);
|
|
4712
|
+
const silent = findings.filter((f) => !f.shadowedByIdProperty);
|
|
4713
|
+
if (shadowed.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema — but they do have a property called `id`. The admin has no way to know `id` is not the key, so it will address rows by it while this server reads the address as the real key: the links look right and route wrong. Nothing will error.\n\n" + shadowed.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
|
|
4714
|
+
if (silent.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema, which the admin never sees. It will resolve no address for their rows, so detail links, caching and relations will not work for them:\n\n" + silent.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
|
|
4715
|
+
}
|
|
4716
|
+
/**
|
|
4717
|
+
* The address of a row: derived from the collection's primary keys, because a
|
|
4718
|
+
* row does not carry one — it is exactly its columns.
|
|
4719
|
+
*
|
|
4720
|
+
* Falls back to a literal `id` column, for a row that reached us from somewhere
|
|
4721
|
+
* other than this driver. Returns `""` when there is no key and no `id` —
|
|
4722
|
+
* callers decide what that means, since "unaddressable" is a different answer
|
|
4723
|
+
* in a notification (broadcast a wildcard) than in a save (fail).
|
|
4724
|
+
*/
|
|
4725
|
+
function deriveRowAddress(row, collection, registry) {
|
|
4726
|
+
const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
|
|
4727
|
+
if (composite && composite.split(":::").some((part) => part !== "")) return composite;
|
|
4728
|
+
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
4729
|
+
return "";
|
|
3948
4730
|
}
|
|
3949
4731
|
//#endregion
|
|
3950
4732
|
//#region src/utils/drizzle-conditions.ts
|
|
@@ -4484,6 +5266,7 @@ var DrizzleConditionBuilder = class {
|
|
|
4484
5266
|
static buildVectorSearchConditions(table, vectorSearch) {
|
|
4485
5267
|
const column = table[vectorSearch.property];
|
|
4486
5268
|
if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
5269
|
+
if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
4487
5270
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
4488
5271
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
4489
5272
|
let operator;
|
|
@@ -4567,12 +5350,10 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
4567
5350
|
continue;
|
|
4568
5351
|
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
4569
5352
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
4570
|
-
const pks = getPrimaryKeys(collection, registry);
|
|
4571
5353
|
inverseRelationUpdates.push({
|
|
4572
5354
|
relationKey: key,
|
|
4573
5355
|
relation,
|
|
4574
|
-
newValue: serializedValue
|
|
4575
|
-
currentId: row.id || buildCompositeId(row, pks)
|
|
5356
|
+
newValue: serializedValue
|
|
4576
5357
|
});
|
|
4577
5358
|
continue;
|
|
4578
5359
|
} else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
@@ -4582,15 +5363,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
4582
5363
|
relation,
|
|
4583
5364
|
newTargetId: serializedValue
|
|
4584
5365
|
});
|
|
4585
|
-
else {
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
newValue: serializedValue,
|
|
4591
|
-
currentId: row.id || buildCompositeId(row, pks)
|
|
4592
|
-
});
|
|
4593
|
-
}
|
|
5366
|
+
else inverseRelationUpdates.push({
|
|
5367
|
+
relationKey: key,
|
|
5368
|
+
relation,
|
|
5369
|
+
newValue: serializedValue
|
|
5370
|
+
});
|
|
4594
5371
|
continue;
|
|
4595
5372
|
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
4596
5373
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
@@ -4990,6 +5767,63 @@ var RelationService = class {
|
|
|
4990
5767
|
this.registry = registry;
|
|
4991
5768
|
}
|
|
4992
5769
|
/**
|
|
5770
|
+
* One target row, as the {@link RelatedRow} everything here returns.
|
|
5771
|
+
*
|
|
5772
|
+
* Eight sites built this by hand, which is how the address came to be the
|
|
5773
|
+
* target's first key column in all eight — one edit, eight places to miss.
|
|
5774
|
+
*
|
|
5775
|
+
* `resolveNested` is the one thing they did not agree on, and the
|
|
5776
|
+
* disagreement was invisible: the single-parent fetches pass `db` and
|
|
5777
|
+
* `registry` to `parseDataFromServer`, so the target's *own* relations get
|
|
5778
|
+
* resolved too, while the batch paths deliberately do not — a query per
|
|
5779
|
+
* target row is the N+1 the batching exists to avoid. Naming the parameter
|
|
5780
|
+
* makes that a decision rather than a difference between two call sites
|
|
5781
|
+
* nobody was comparing.
|
|
5782
|
+
*/
|
|
5783
|
+
async toRelatedRow(targetRow, targetCollection, targetPks, options) {
|
|
5784
|
+
const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
|
|
5785
|
+
return {
|
|
5786
|
+
id: buildCompositeId(targetRow, targetPks),
|
|
5787
|
+
path: targetCollection.slug,
|
|
5788
|
+
values
|
|
5789
|
+
};
|
|
5790
|
+
}
|
|
5791
|
+
/**
|
|
5792
|
+
* A WHERE matching any of `parentIds`, by the whole key.
|
|
5793
|
+
*
|
|
5794
|
+
* A single key is an `IN (…)`. A composite one cannot be: matching
|
|
5795
|
+
* `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
|
|
5796
|
+
* share their first column each receive the other's relations. It becomes
|
|
5797
|
+
* an OR of ANDs — one exact address per parent — which Postgres indexes the
|
|
5798
|
+
* same way it would a multi-column key lookup.
|
|
5799
|
+
*/
|
|
5800
|
+
parentKeyCondition(parentTable, parentPks, parentIds) {
|
|
5801
|
+
const columnFor = (fieldName) => {
|
|
5802
|
+
const col = parentTable[fieldName];
|
|
5803
|
+
if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
|
|
5804
|
+
return col;
|
|
5805
|
+
};
|
|
5806
|
+
if (parentPks.length === 1) {
|
|
5807
|
+
const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
|
|
5808
|
+
return inArray(columnFor(parentPks[0].fieldName), values);
|
|
5809
|
+
}
|
|
5810
|
+
return or(...parentIds.map((id) => {
|
|
5811
|
+
const values = parseIdValues(id, parentPks);
|
|
5812
|
+
return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
|
|
5813
|
+
}));
|
|
5814
|
+
}
|
|
5815
|
+
/**
|
|
5816
|
+
* Reject a relation that cannot express a composite-keyed parent.
|
|
5817
|
+
*
|
|
5818
|
+
* `localKey` and `foreignKeyOnTarget` are single column names: one column
|
|
5819
|
+
* cannot reference a two-column key, so such a relation has no correct
|
|
5820
|
+
* reading. Left alone it would silently match on the first key column and
|
|
5821
|
+
* hand a tenant's rows to its neighbour — say so instead.
|
|
5822
|
+
*/
|
|
5823
|
+
assertSingleKeyAddressable(parentCollection, parentPks, via) {
|
|
5824
|
+
if (parentPks.length > 1) throw new Error(`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but '${parentCollection.slug}' is keyed on ${parentPks.map((k) => `'${k.fieldName}'`).join(" + ")}. One column cannot reference a composite key — express this relation with \`joinPath\`, whose \`on.from\`/\`on.to\` take every key column.`);
|
|
5825
|
+
}
|
|
5826
|
+
/**
|
|
4993
5827
|
* Fetch rows related to a parent row through a specific relation
|
|
4994
5828
|
*/
|
|
4995
5829
|
async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
|
|
@@ -5008,9 +5842,9 @@ var RelationService = class {
|
|
|
5008
5842
|
async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
|
|
5009
5843
|
const targetCollection = relation.target();
|
|
5010
5844
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5011
|
-
const idInfo =
|
|
5845
|
+
const idInfo = requirePrimaryKeys(targetCollection, this.registry);
|
|
5012
5846
|
const idField = targetTable[idInfo[0].fieldName];
|
|
5013
|
-
const parentPks =
|
|
5847
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5014
5848
|
const parentIdInfo = parentPks[0];
|
|
5015
5849
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5016
5850
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5034,7 +5868,7 @@ var RelationService = class {
|
|
|
5034
5868
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5035
5869
|
currentTable = joinTable;
|
|
5036
5870
|
}
|
|
5037
|
-
const parentIdField = parentTable[
|
|
5871
|
+
const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
|
|
5038
5872
|
query = query.where(eq(parentIdField, parsedParentId));
|
|
5039
5873
|
if (options.limit) query = query.limit(options.limit);
|
|
5040
5874
|
const results = await query;
|
|
@@ -5042,13 +5876,7 @@ var RelationService = class {
|
|
|
5042
5876
|
const rows = [];
|
|
5043
5877
|
for (const row of results) {
|
|
5044
5878
|
const targetRow = row[targetTableName] || row;
|
|
5045
|
-
|
|
5046
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
5047
|
-
rows.push({
|
|
5048
|
-
id: id?.toString() || "",
|
|
5049
|
-
path: targetCollection.slug,
|
|
5050
|
-
values: parsedValues
|
|
5051
|
-
});
|
|
5879
|
+
rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
|
|
5052
5880
|
}
|
|
5053
5881
|
return rows;
|
|
5054
5882
|
}
|
|
@@ -5067,13 +5895,7 @@ var RelationService = class {
|
|
|
5067
5895
|
const rows = [];
|
|
5068
5896
|
for (const row of results) {
|
|
5069
5897
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
5070
|
-
|
|
5071
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
5072
|
-
rows.push({
|
|
5073
|
-
id: id?.toString() || "",
|
|
5074
|
-
path: targetCollection.slug,
|
|
5075
|
-
values: parsedValues
|
|
5076
|
-
});
|
|
5898
|
+
rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
|
|
5077
5899
|
}
|
|
5078
5900
|
return rows;
|
|
5079
5901
|
}
|
|
@@ -5090,8 +5912,8 @@ var RelationService = class {
|
|
|
5090
5912
|
}
|
|
5091
5913
|
const targetCollection = relation.target();
|
|
5092
5914
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5093
|
-
const targetIdField = targetTable[
|
|
5094
|
-
const parentPks =
|
|
5915
|
+
const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
|
|
5916
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5095
5917
|
const parentIdInfo = parentPks[0];
|
|
5096
5918
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5097
5919
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5110,9 +5932,10 @@ var RelationService = class {
|
|
|
5110
5932
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5111
5933
|
const targetCollection = relation.target();
|
|
5112
5934
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5113
|
-
const
|
|
5935
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5936
|
+
const targetIdInfo = targetPks[0];
|
|
5114
5937
|
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
5115
|
-
const parentPks =
|
|
5938
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5116
5939
|
const parentIdInfo = parentPks[0];
|
|
5117
5940
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
5118
5941
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -5136,25 +5959,19 @@ var RelationService = class {
|
|
|
5136
5959
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5137
5960
|
currentTable = joinTable;
|
|
5138
5961
|
}
|
|
5139
|
-
|
|
5140
|
-
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
5962
|
+
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
5141
5963
|
const results = await query;
|
|
5142
5964
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
5143
5965
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5144
5966
|
for (const row of results) {
|
|
5145
5967
|
const parentRow = row[getTableName$1(parentCollection)] || row;
|
|
5146
5968
|
const targetRow = row[targetTableName] || row;
|
|
5147
|
-
|
|
5148
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5149
|
-
resultMap.set(String(parentId), {
|
|
5150
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5151
|
-
path: targetCollection.slug,
|
|
5152
|
-
values: parsedValues
|
|
5153
|
-
});
|
|
5969
|
+
resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5154
5970
|
}
|
|
5155
5971
|
return resultMap;
|
|
5156
5972
|
}
|
|
5157
5973
|
if (relation.direction === "owning" && relation.localKey) {
|
|
5974
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
5158
5975
|
const localKeyCol = parentTable[relation.localKey];
|
|
5159
5976
|
if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
5160
5977
|
const fkRows = await this.db.select({
|
|
@@ -5183,17 +6000,11 @@ var RelationService = class {
|
|
|
5183
6000
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5184
6001
|
for (const [parentIdStr, fkValue] of parentToFk) {
|
|
5185
6002
|
const targetRow = targetById.get(String(fkValue));
|
|
5186
|
-
if (targetRow)
|
|
5187
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5188
|
-
resultMap.set(parentIdStr, {
|
|
5189
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5190
|
-
path: targetCollection.slug,
|
|
5191
|
-
values: parsedValues
|
|
5192
|
-
});
|
|
5193
|
-
}
|
|
6003
|
+
if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5194
6004
|
}
|
|
5195
6005
|
return resultMap;
|
|
5196
6006
|
}
|
|
6007
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
|
|
5197
6008
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
5198
6009
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
5199
6010
|
const results = await query;
|
|
@@ -5204,14 +6015,7 @@ var RelationService = class {
|
|
|
5204
6015
|
let parentId;
|
|
5205
6016
|
if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
5206
6017
|
else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
5207
|
-
if (parentId !== void 0 && parentIdSet.has(String(parentId)))
|
|
5208
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5209
|
-
resultMap.set(String(parentId), {
|
|
5210
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5211
|
-
path: targetCollection.slug,
|
|
5212
|
-
values: parsedValues
|
|
5213
|
-
});
|
|
5214
|
-
}
|
|
6018
|
+
if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5215
6019
|
}
|
|
5216
6020
|
return resultMap;
|
|
5217
6021
|
}
|
|
@@ -5225,9 +6029,9 @@ var RelationService = class {
|
|
|
5225
6029
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5226
6030
|
const targetCollection = relation.target();
|
|
5227
6031
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5228
|
-
const
|
|
5229
|
-
const targetIdField = targetTable[
|
|
5230
|
-
const parentPks =
|
|
6032
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
6033
|
+
const targetIdField = targetTable[targetPks[0].fieldName];
|
|
6034
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5231
6035
|
const parentIdInfo = parentPks[0];
|
|
5232
6036
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
5233
6037
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -5249,27 +6053,22 @@ var RelationService = class {
|
|
|
5249
6053
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5250
6054
|
currentTable = joinTable;
|
|
5251
6055
|
}
|
|
5252
|
-
|
|
5253
|
-
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
6056
|
+
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
5254
6057
|
const results = await query;
|
|
5255
6058
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
5256
6059
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5257
6060
|
for (const row of results) {
|
|
5258
6061
|
const parentRow = row[getTableName$1(parentCollection)] || row;
|
|
5259
6062
|
const targetRow = row[targetTableName] || row;
|
|
5260
|
-
const parentId =
|
|
5261
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
6063
|
+
const parentId = buildCompositeId(parentRow, parentPks);
|
|
5262
6064
|
const arr = resultMap.get(parentId) || [];
|
|
5263
|
-
arr.push(
|
|
5264
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5265
|
-
path: targetCollection.slug,
|
|
5266
|
-
values: parsedValues
|
|
5267
|
-
});
|
|
6065
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5268
6066
|
resultMap.set(parentId, arr);
|
|
5269
6067
|
}
|
|
5270
6068
|
return resultMap;
|
|
5271
6069
|
}
|
|
5272
6070
|
if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
|
|
6071
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
5273
6072
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
5274
6073
|
if (!junctionTable) {
|
|
5275
6074
|
logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
@@ -5288,17 +6087,13 @@ var RelationService = class {
|
|
|
5288
6087
|
const junctionData = row[relation.through.table] || row;
|
|
5289
6088
|
const targetData = row[targetTableName] || row;
|
|
5290
6089
|
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
5291
|
-
const parsedValues = await parseDataFromServer(targetData, targetCollection);
|
|
5292
6090
|
const arr = resultMap.get(parentId) || [];
|
|
5293
|
-
arr.push(
|
|
5294
|
-
id: String(targetData[targetIdInfo.fieldName]),
|
|
5295
|
-
path: targetCollection.slug,
|
|
5296
|
-
values: parsedValues
|
|
5297
|
-
});
|
|
6091
|
+
arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
|
|
5298
6092
|
resultMap.set(parentId, arr);
|
|
5299
6093
|
}
|
|
5300
6094
|
return resultMap;
|
|
5301
6095
|
}
|
|
6096
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
|
|
5302
6097
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
5303
6098
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
5304
6099
|
const results = await query;
|
|
@@ -5311,14 +6106,9 @@ var RelationService = class {
|
|
|
5311
6106
|
else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
5312
6107
|
else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
5313
6108
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
|
|
5314
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5315
6109
|
const key = String(parentId);
|
|
5316
6110
|
const arr = resultMap.get(key) || [];
|
|
5317
|
-
arr.push(
|
|
5318
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5319
|
-
path: targetCollection.slug,
|
|
5320
|
-
values: parsedValues
|
|
5321
|
-
});
|
|
6111
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5322
6112
|
resultMap.set(key, arr);
|
|
5323
6113
|
}
|
|
5324
6114
|
}
|
|
@@ -5366,12 +6156,12 @@ var RelationService = class {
|
|
|
5366
6156
|
logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
|
|
5367
6157
|
continue;
|
|
5368
6158
|
}
|
|
5369
|
-
const parentPks =
|
|
6159
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5370
6160
|
const parentIdInfo = parentPks[0];
|
|
5371
6161
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5372
6162
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
5373
6163
|
if (targetEntityIds.length > 0) {
|
|
5374
|
-
const targetPks =
|
|
6164
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5375
6165
|
const targetIdInfo = targetPks[0];
|
|
5376
6166
|
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5377
6167
|
[sourceJunctionColumn.name]: parsedParentId,
|
|
@@ -5391,12 +6181,12 @@ var RelationService = class {
|
|
|
5391
6181
|
logger.warn(`Junction columns not found for relation '${key}'`);
|
|
5392
6182
|
continue;
|
|
5393
6183
|
}
|
|
5394
|
-
const parentPks =
|
|
6184
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5395
6185
|
const parentIdInfo = parentPks[0];
|
|
5396
6186
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5397
6187
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
5398
6188
|
if (targetEntityIds.length > 0) {
|
|
5399
|
-
const targetPks =
|
|
6189
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5400
6190
|
const targetIdInfo = targetPks[0];
|
|
5401
6191
|
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5402
6192
|
[sourceJunctionColumn.name]: parsedParentId,
|
|
@@ -5407,7 +6197,7 @@ var RelationService = class {
|
|
|
5407
6197
|
} else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
|
|
5408
6198
|
else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
5409
6199
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5410
|
-
const targetPks =
|
|
6200
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5411
6201
|
const targetIdInfo = targetPks[0];
|
|
5412
6202
|
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
5413
6203
|
const fkCol = targetTable[relation.foreignKeyOnTarget];
|
|
@@ -5415,7 +6205,7 @@ var RelationService = class {
|
|
|
5415
6205
|
logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
5416
6206
|
continue;
|
|
5417
6207
|
}
|
|
5418
|
-
const parentPks =
|
|
6208
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5419
6209
|
const parentIdInfo = parentPks[0];
|
|
5420
6210
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5421
6211
|
if (targetEntityIds.length > 0) {
|
|
@@ -5435,9 +6225,9 @@ var RelationService = class {
|
|
|
5435
6225
|
try {
|
|
5436
6226
|
const targetCollection = relation.target();
|
|
5437
6227
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5438
|
-
const targetPks =
|
|
6228
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5439
6229
|
const targetIdInfo = targetPks[0];
|
|
5440
|
-
const sourcePks =
|
|
6230
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5441
6231
|
const sourceIdInfo = sourcePks[0];
|
|
5442
6232
|
if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
5443
6233
|
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
@@ -5518,12 +6308,12 @@ var RelationService = class {
|
|
|
5518
6308
|
logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
|
|
5519
6309
|
return;
|
|
5520
6310
|
}
|
|
5521
|
-
const sourcePks =
|
|
6311
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5522
6312
|
const sourceIdInfo = sourcePks[0];
|
|
5523
6313
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
5524
6314
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
5525
6315
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
5526
|
-
const targetPks =
|
|
6316
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5527
6317
|
const targetIdInfo = targetPks[0];
|
|
5528
6318
|
const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5529
6319
|
[sourceJunctionColumn.name]: parsedSourceId,
|
|
@@ -5531,7 +6321,7 @@ var RelationService = class {
|
|
|
5531
6321
|
}));
|
|
5532
6322
|
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
5533
6323
|
} else if (newValue && !Array.isArray(newValue)) {
|
|
5534
|
-
const targetPks =
|
|
6324
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5535
6325
|
const targetIdInfo = targetPks[0];
|
|
5536
6326
|
const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
|
|
5537
6327
|
const newLink = {
|
|
@@ -5562,12 +6352,12 @@ var RelationService = class {
|
|
|
5562
6352
|
logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
|
|
5563
6353
|
return;
|
|
5564
6354
|
}
|
|
5565
|
-
const sourcePks =
|
|
6355
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5566
6356
|
const sourceIdInfo = sourcePks[0];
|
|
5567
6357
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
5568
6358
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
5569
6359
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
5570
|
-
const targetPks =
|
|
6360
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5571
6361
|
const targetIdInfo = targetPks[0];
|
|
5572
6362
|
const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5573
6363
|
[sourceJunctionColumn.name]: parsedSourceId,
|
|
@@ -5588,12 +6378,12 @@ var RelationService = class {
|
|
|
5588
6378
|
const { relation, newTargetId } = upd;
|
|
5589
6379
|
const targetCollection = relation.target();
|
|
5590
6380
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5591
|
-
const targetPks =
|
|
6381
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5592
6382
|
const targetIdInfo = targetPks[0];
|
|
5593
6383
|
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
5594
6384
|
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
5595
6385
|
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
5596
|
-
const parentPks =
|
|
6386
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5597
6387
|
const parentIdInfo = parentPks[0];
|
|
5598
6388
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5599
6389
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
@@ -5664,7 +6454,7 @@ var RelationService = class {
|
|
|
5664
6454
|
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
5665
6455
|
return;
|
|
5666
6456
|
}
|
|
5667
|
-
const targetPks =
|
|
6457
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5668
6458
|
const targetIdInfo = targetPks[0];
|
|
5669
6459
|
const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
|
|
5670
6460
|
const junctionData = {
|
|
@@ -5680,6 +6470,131 @@ var RelationService = class {
|
|
|
5680
6470
|
}
|
|
5681
6471
|
};
|
|
5682
6472
|
//#endregion
|
|
6473
|
+
//#region src/services/row-pipeline.ts
|
|
6474
|
+
/**
|
|
6475
|
+
* Whether a many-relation reaches its target through a junction table.
|
|
6476
|
+
*
|
|
6477
|
+
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
6478
|
+
* the query has to nest one level deeper for a junction, and the row walk has
|
|
6479
|
+
* to unwrap that same level back out.
|
|
6480
|
+
*/
|
|
6481
|
+
function isJunctionRelation(relation) {
|
|
6482
|
+
if (relation.through) return true;
|
|
6483
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
6484
|
+
return false;
|
|
6485
|
+
}
|
|
6486
|
+
/**
|
|
6487
|
+
* Reach the target row inside a junction row.
|
|
6488
|
+
*
|
|
6489
|
+
* A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
|
|
6490
|
+
* the foreign keys, and the target nested under one of them. The target is the
|
|
6491
|
+
* only object among them, so that is how it is found. A junction row that has
|
|
6492
|
+
* not been nested (no `with` on the join) has no object and is returned as-is.
|
|
6493
|
+
*/
|
|
6494
|
+
function unwrapJunctionRow(item) {
|
|
6495
|
+
const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
|
|
6496
|
+
return nestedKey ? item[nestedKey] : item;
|
|
6497
|
+
}
|
|
6498
|
+
/**
|
|
6499
|
+
* Give back the number a `number` property was declared to be.
|
|
6500
|
+
*
|
|
6501
|
+
* Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
|
|
6502
|
+
* numeric can hold more precision than a double. But the collection declared
|
|
6503
|
+
* this property `number` and the OpenAPI spec this server publishes says
|
|
6504
|
+
* `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
|
|
6505
|
+
* asymmetrically, because a create answers with the number and the read that
|
|
6506
|
+
* follows answers with the string. Any client that multiplies a price works
|
|
6507
|
+
* until the first refresh.
|
|
6508
|
+
*
|
|
6509
|
+
* Declaring a property `number` already accepts double precision — that is what
|
|
6510
|
+
* the admin has always parsed it to. Columns REST serves that no property
|
|
6511
|
+
* declares are left exactly as the database returned them.
|
|
6512
|
+
*/
|
|
6513
|
+
function coerceDeclaredNumber(value, property) {
|
|
6514
|
+
if (property?.type !== "number" || typeof value !== "string") return value;
|
|
6515
|
+
const parsed = parseFloat(value);
|
|
6516
|
+
return isNaN(parsed) ? null : parsed;
|
|
6517
|
+
}
|
|
6518
|
+
/** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
|
|
6519
|
+
function coerceDeclaredNumbers(row, collection) {
|
|
6520
|
+
const properties = collection.properties;
|
|
6521
|
+
if (!properties) return row;
|
|
6522
|
+
const out = {};
|
|
6523
|
+
for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
|
|
6524
|
+
return out;
|
|
6525
|
+
}
|
|
6526
|
+
/** Render one target row in the requested style. */
|
|
6527
|
+
function renderTarget(targetRow, targetCollection, style, registry) {
|
|
6528
|
+
if (style === "inline") return coerceDeclaredNumbers({ ...targetRow }, targetCollection);
|
|
6529
|
+
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
6530
|
+
const path = targetCollection.slug;
|
|
6531
|
+
return createRelationRefWithData(address, path, {
|
|
6532
|
+
id: address,
|
|
6533
|
+
path,
|
|
6534
|
+
values: normalizeDbValues(targetRow, targetCollection)
|
|
6535
|
+
});
|
|
6536
|
+
}
|
|
6537
|
+
/**
|
|
6538
|
+
* The address a relation ref points at.
|
|
6539
|
+
*
|
|
6540
|
+
* The whole key, not its first column: a composite-keyed target addressed by
|
|
6541
|
+
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
6542
|
+
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
6543
|
+
* array — taking down the parent's fetch over a relation it may not even have
|
|
6544
|
+
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
6545
|
+
* beats no rows at all.
|
|
6546
|
+
*/
|
|
6547
|
+
function relationTargetAddress(targetRow, targetCollection, registry) {
|
|
6548
|
+
const address = deriveRowAddress(targetRow, targetCollection, registry);
|
|
6549
|
+
if (address) return address;
|
|
6550
|
+
return String(targetRow[Object.keys(targetRow)[0]] ?? "");
|
|
6551
|
+
}
|
|
6552
|
+
/**
|
|
6553
|
+
* The row the admin renders: every column, with relations as references.
|
|
6554
|
+
*
|
|
6555
|
+
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
6556
|
+
* expects real types. The row's own address is *not* among the columns — it is
|
|
6557
|
+
* derived by the consumer from the collection's primary keys.
|
|
6558
|
+
*/
|
|
6559
|
+
function toCmsRow(row, collection, registry) {
|
|
6560
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6561
|
+
const normalized = normalizeDbValues(row, collection);
|
|
6562
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
6563
|
+
const relData = row[relation.relationName || key];
|
|
6564
|
+
if (relData === void 0 || relData === null) continue;
|
|
6565
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
6566
|
+
const targetCollection = relation.target();
|
|
6567
|
+
normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
|
|
6568
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
|
|
6569
|
+
}
|
|
6570
|
+
return normalized;
|
|
6571
|
+
}
|
|
6572
|
+
/**
|
|
6573
|
+
* The row REST serves: every column under its own name, with the value Postgres
|
|
6574
|
+
* returned, and relations inlined as the target's columns.
|
|
6575
|
+
*
|
|
6576
|
+
* Values are the ones the database returned, except where that contradicts the
|
|
6577
|
+
* declared type: a `number` property is served as a number (see
|
|
6578
|
+
* {@link coerceDeclaredNumber}). Dates stay as the database returned them —
|
|
6579
|
+
* JSON has its own opinions about dates that the admin's view-model does not
|
|
6580
|
+
* share.
|
|
6581
|
+
*
|
|
6582
|
+
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
6583
|
+
* the relations `include` asked for, so the row is the authority on which are
|
|
6584
|
+
* actually there.
|
|
6585
|
+
*/
|
|
6586
|
+
function toRestRow(row, collection, registry) {
|
|
6587
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6588
|
+
const flat = {};
|
|
6589
|
+
for (const [key, value] of Object.entries(row)) {
|
|
6590
|
+
const relation = findRelation(resolvedRelations, key);
|
|
6591
|
+
if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
|
|
6592
|
+
else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
|
|
6593
|
+
else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
|
|
6594
|
+
}
|
|
6595
|
+
return flat;
|
|
6596
|
+
}
|
|
6597
|
+
//#endregion
|
|
5683
6598
|
//#region src/services/FetchService.ts
|
|
5684
6599
|
/**
|
|
5685
6600
|
* Service for handling all row read operations.
|
|
@@ -5738,78 +6653,21 @@ var FetchService = class {
|
|
|
5738
6653
|
if (!shouldInclude(key)) continue;
|
|
5739
6654
|
const drizzleRelName = relation.relationName || key;
|
|
5740
6655
|
if (relation.joinPath && relation.joinPath.length > 0) continue;
|
|
5741
|
-
if (relation.cardinality === "many" &&
|
|
6656
|
+
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
5742
6657
|
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
5743
6658
|
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
5744
6659
|
else withConfig[drizzleRelName] = true;
|
|
5745
6660
|
} else withConfig[drizzleRelName] = true;
|
|
5746
|
-
}
|
|
5747
|
-
return withConfig;
|
|
5748
|
-
}
|
|
5749
|
-
/**
|
|
5750
|
-
*
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
if (relation.
|
|
5755
|
-
return
|
|
5756
|
-
}
|
|
5757
|
-
/**
|
|
5758
|
-
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
5759
|
-
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
5760
|
-
*/
|
|
5761
|
-
getJunctionTargetRelationName(relation, _collection) {
|
|
5762
|
-
if (relation.through) return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
5763
|
-
return null;
|
|
5764
|
-
}
|
|
5765
|
-
/**
|
|
5766
|
-
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
5767
|
-
* Handles:
|
|
5768
|
-
* - Placing `id` at the top level as a string
|
|
5769
|
-
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
5770
|
-
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
5771
|
-
* - Flattening junction-table many-to-many results
|
|
5772
|
-
*/
|
|
5773
|
-
drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
|
|
5774
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5775
|
-
const normalizedValues = normalizeDbValues(row, collection);
|
|
5776
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
5777
|
-
const relData = row[relation.relationName || key];
|
|
5778
|
-
if (relData === void 0 || relData === null) continue;
|
|
5779
|
-
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
5780
|
-
const targetCollection = relation.target();
|
|
5781
|
-
const targetPath = targetCollection.slug;
|
|
5782
|
-
const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
|
|
5783
|
-
normalizedValues[key] = relData.map((item) => {
|
|
5784
|
-
let targetRow = item;
|
|
5785
|
-
if (this.isJunctionRelation(relation, collection)) {
|
|
5786
|
-
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
5787
|
-
if (nestedKey) targetRow = item[nestedKey];
|
|
5788
|
-
}
|
|
5789
|
-
const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
|
|
5790
|
-
return createRelationRefWithData(relId, targetPath, {
|
|
5791
|
-
id: relId,
|
|
5792
|
-
path: targetPath,
|
|
5793
|
-
values: normalizeDbValues(targetRow, targetCollection)
|
|
5794
|
-
});
|
|
5795
|
-
});
|
|
5796
|
-
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
5797
|
-
const targetCollection = relation.target();
|
|
5798
|
-
const targetPath = targetCollection.slug;
|
|
5799
|
-
const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
|
|
5800
|
-
const relObj = relData;
|
|
5801
|
-
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
5802
|
-
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
5803
|
-
id: relId,
|
|
5804
|
-
path: targetPath,
|
|
5805
|
-
values: normalizeDbValues(relObj, targetCollection)
|
|
5806
|
-
});
|
|
5807
|
-
}
|
|
5808
|
-
}
|
|
5809
|
-
return {
|
|
5810
|
-
...normalizedValues,
|
|
5811
|
-
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
5812
|
-
};
|
|
6661
|
+
}
|
|
6662
|
+
return withConfig;
|
|
6663
|
+
}
|
|
6664
|
+
/**
|
|
6665
|
+
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
6666
|
+
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
6667
|
+
*/
|
|
6668
|
+
getJunctionTargetRelationName(relation, _collection) {
|
|
6669
|
+
if (relation.through) return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
6670
|
+
return null;
|
|
5813
6671
|
}
|
|
5814
6672
|
/**
|
|
5815
6673
|
* Post-fetch joinPath relations for a single flat row.
|
|
@@ -5832,31 +6690,6 @@ var FetchService = class {
|
|
|
5832
6690
|
await Promise.all(promises);
|
|
5833
6691
|
}
|
|
5834
6692
|
/**
|
|
5835
|
-
* Post-fetch joinPath relations for a batch of flat rows.
|
|
5836
|
-
* Uses batch fetching to avoid N+1 queries for list views.
|
|
5837
|
-
*/
|
|
5838
|
-
async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
|
|
5839
|
-
if (rows.length === 0) return;
|
|
5840
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5841
|
-
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
|
|
5842
|
-
if (joinPathRelations.length === 0) return;
|
|
5843
|
-
for (const [key, relation] of joinPathRelations) try {
|
|
5844
|
-
const rowIds = rows.map((r) => {
|
|
5845
|
-
return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
|
|
5846
|
-
});
|
|
5847
|
-
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
5848
|
-
for (const row of rows) {
|
|
5849
|
-
const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
|
|
5850
|
-
const relatedRow = resultMap.get(String(id));
|
|
5851
|
-
if (relatedRow) {
|
|
5852
|
-
if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
5853
|
-
}
|
|
5854
|
-
}
|
|
5855
|
-
} catch (e) {
|
|
5856
|
-
logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
|
|
5857
|
-
}
|
|
5858
|
-
}
|
|
5859
|
-
/**
|
|
5860
6693
|
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
5861
6694
|
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
5862
6695
|
*/
|
|
@@ -5867,72 +6700,29 @@ var FetchService = class {
|
|
|
5867
6700
|
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
5868
6701
|
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
|
|
5869
6702
|
if (joinPathRelations.length === 0) return;
|
|
5870
|
-
const
|
|
6703
|
+
const parentIdOf = (row) => {
|
|
6704
|
+
const address = buildCompositeId(row, idInfoArray);
|
|
6705
|
+
return address && address.split(":::").some((part) => part !== "") ? address : void 0;
|
|
6706
|
+
};
|
|
5871
6707
|
for (const [key, relation] of joinPathRelations) try {
|
|
5872
|
-
const
|
|
5873
|
-
|
|
5874
|
-
|
|
6708
|
+
const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
|
|
6709
|
+
if (addressable.length === 0) continue;
|
|
6710
|
+
const rowIds = addressable.map((r) => parentIdOf(r));
|
|
5875
6711
|
if (relation.cardinality === "one") {
|
|
5876
6712
|
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
5877
|
-
for (const row of
|
|
5878
|
-
const
|
|
5879
|
-
|
|
5880
|
-
if (relatedRow) row[key] = {
|
|
5881
|
-
...relatedRow.values,
|
|
5882
|
-
id: relatedRow.id
|
|
5883
|
-
};
|
|
5884
|
-
else row[key] = null;
|
|
6713
|
+
for (const row of addressable) {
|
|
6714
|
+
const relatedRow = resultMap.get(String(parentIdOf(row)));
|
|
6715
|
+
row[key] = relatedRow ? { ...relatedRow.values } : null;
|
|
5885
6716
|
}
|
|
5886
6717
|
} else if (relation.cardinality === "many") {
|
|
5887
6718
|
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
|
|
5888
|
-
for (const row of
|
|
5889
|
-
const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
|
|
5890
|
-
row[key] = (resultMap.get(String(id)) || []).map((e) => ({
|
|
5891
|
-
...e.values,
|
|
5892
|
-
id: e.id
|
|
5893
|
-
}));
|
|
5894
|
-
}
|
|
6719
|
+
for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
|
|
5895
6720
|
}
|
|
5896
6721
|
} catch (e) {
|
|
5897
6722
|
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
5898
6723
|
}
|
|
5899
6724
|
}
|
|
5900
6725
|
/**
|
|
5901
|
-
* Convert a db.query result row to a flat REST-style object with populated relations.
|
|
5902
|
-
*/
|
|
5903
|
-
drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
|
|
5904
|
-
const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
5905
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5906
|
-
for (const [k, v] of Object.entries(row)) {
|
|
5907
|
-
if (k === idInfo.fieldName) continue;
|
|
5908
|
-
const relation = findRelation(resolvedRelations, k);
|
|
5909
|
-
if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
|
|
5910
|
-
if (this.isJunctionRelation(relation, collection)) {
|
|
5911
|
-
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
5912
|
-
if (nestedKey) {
|
|
5913
|
-
const nested = item[nestedKey];
|
|
5914
|
-
return {
|
|
5915
|
-
...nested,
|
|
5916
|
-
id: String(nested.id ?? nested[Object.keys(nested)[0]])
|
|
5917
|
-
};
|
|
5918
|
-
}
|
|
5919
|
-
}
|
|
5920
|
-
return {
|
|
5921
|
-
...item,
|
|
5922
|
-
id: String(item.id ?? item[Object.keys(item)[0]])
|
|
5923
|
-
};
|
|
5924
|
-
});
|
|
5925
|
-
else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
5926
|
-
const relObj = v;
|
|
5927
|
-
flat[k] = {
|
|
5928
|
-
...relObj,
|
|
5929
|
-
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
|
|
5930
|
-
};
|
|
5931
|
-
} else flat[k] = v;
|
|
5932
|
-
}
|
|
5933
|
-
return flat;
|
|
5934
|
-
}
|
|
5935
|
-
/**
|
|
5936
6726
|
* Build db.query-compatible options from standard fetch options.
|
|
5937
6727
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
5938
6728
|
*/
|
|
@@ -6002,7 +6792,7 @@ var FetchService = class {
|
|
|
6002
6792
|
async fetchOne(collectionPath, id, databaseId) {
|
|
6003
6793
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6004
6794
|
const table = getTableForCollection(collection, this.registry);
|
|
6005
|
-
const idInfoArray =
|
|
6795
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6006
6796
|
const idInfo = idInfoArray[0];
|
|
6007
6797
|
const idField = table[idInfo.fieldName];
|
|
6008
6798
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
@@ -6016,7 +6806,7 @@ var FetchService = class {
|
|
|
6016
6806
|
with: withConfig
|
|
6017
6807
|
});
|
|
6018
6808
|
if (!row) return void 0;
|
|
6019
|
-
const flatRow =
|
|
6809
|
+
const flatRow = toCmsRow(row, collection, this.registry);
|
|
6020
6810
|
await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
6021
6811
|
return flatRow;
|
|
6022
6812
|
} catch (e) {
|
|
@@ -6058,7 +6848,7 @@ var FetchService = class {
|
|
|
6058
6848
|
async fetchRowsWithConditions(collectionPath, options = {}) {
|
|
6059
6849
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6060
6850
|
const table = getTableForCollection(collection, this.registry);
|
|
6061
|
-
const idInfoArray =
|
|
6851
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6062
6852
|
const idInfo = idInfoArray[0];
|
|
6063
6853
|
const idField = table[idInfo.fieldName];
|
|
6064
6854
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
@@ -6068,7 +6858,7 @@ var FetchService = class {
|
|
|
6068
6858
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
6069
6859
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
6070
6860
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
|
|
6071
|
-
return (await qb.findMany(queryOpts)).map((row) =>
|
|
6861
|
+
return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
|
|
6072
6862
|
} catch (e) {
|
|
6073
6863
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
6074
6864
|
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
@@ -6129,8 +6919,10 @@ var FetchService = class {
|
|
|
6129
6919
|
}
|
|
6130
6920
|
/**
|
|
6131
6921
|
* Fallback path used when db.query is unavailable.
|
|
6132
|
-
*
|
|
6133
|
-
*
|
|
6922
|
+
*
|
|
6923
|
+
* The primary path runs the results through `toCmsRow`, which maps
|
|
6924
|
+
* relations from what drizzle already nested — no query per row. This one
|
|
6925
|
+
* has no nesting to read, so it resolves relations itself, in batches.
|
|
6134
6926
|
*
|
|
6135
6927
|
* Process raw database results into flat rows with relations.
|
|
6136
6928
|
*/
|
|
@@ -6139,8 +6931,7 @@ var FetchService = class {
|
|
|
6139
6931
|
const parsedRows = await Promise.all(results.map(async (rawRow) => {
|
|
6140
6932
|
return {
|
|
6141
6933
|
rawRow,
|
|
6142
|
-
values: await parseDataFromServer(rawRow, collection)
|
|
6143
|
-
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
|
|
6934
|
+
values: await parseDataFromServer(rawRow, collection)
|
|
6144
6935
|
};
|
|
6145
6936
|
}));
|
|
6146
6937
|
if (!skipRelations) {
|
|
@@ -6180,10 +6971,7 @@ var FetchService = class {
|
|
|
6180
6971
|
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
6181
6972
|
}
|
|
6182
6973
|
}
|
|
6183
|
-
return parsedRows.map((item) =>
|
|
6184
|
-
...item.values,
|
|
6185
|
-
id: item.id
|
|
6186
|
-
}));
|
|
6974
|
+
return parsedRows.map((item) => item.values);
|
|
6187
6975
|
}
|
|
6188
6976
|
/**
|
|
6189
6977
|
* Fetch a collection of rows
|
|
@@ -6214,10 +7002,7 @@ var FetchService = class {
|
|
|
6214
7002
|
const relationKey = pathSegments[i];
|
|
6215
7003
|
const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
|
|
6216
7004
|
if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
6217
|
-
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
|
|
6218
|
-
...row.values,
|
|
6219
|
-
id: row.id
|
|
6220
|
-
}));
|
|
7005
|
+
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
|
|
6221
7006
|
if (i + 1 < pathSegments.length) {
|
|
6222
7007
|
const nextEntityId = pathSegments[i + 1];
|
|
6223
7008
|
currentCollection = relation.target();
|
|
@@ -6279,7 +7064,7 @@ var FetchService = class {
|
|
|
6279
7064
|
if (value === void 0 || value === null) return true;
|
|
6280
7065
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6281
7066
|
const table = getTableForCollection(collection, this.registry);
|
|
6282
|
-
const idInfoArray =
|
|
7067
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6283
7068
|
const idInfo = idInfoArray[0];
|
|
6284
7069
|
const idField = table[idInfo.fieldName];
|
|
6285
7070
|
const field = table[fieldName];
|
|
@@ -6306,7 +7091,7 @@ var FetchService = class {
|
|
|
6306
7091
|
async fetchCollectionForRest(collectionPath, options = {}, include) {
|
|
6307
7092
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6308
7093
|
const table = getTableForCollection(collection, this.registry);
|
|
6309
|
-
const idInfoArray =
|
|
7094
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6310
7095
|
const idInfo = idInfoArray[0];
|
|
6311
7096
|
const idField = table[idInfo.fieldName];
|
|
6312
7097
|
const tableName = getTableName(table);
|
|
@@ -6314,7 +7099,7 @@ var FetchService = class {
|
|
|
6314
7099
|
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
6315
7100
|
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
6316
7101
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
|
|
6317
|
-
const restRows = (await qb.findMany(queryOpts)).map((row) =>
|
|
7102
|
+
const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
|
|
6318
7103
|
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
6319
7104
|
return restRows;
|
|
6320
7105
|
} catch (e) {
|
|
@@ -6325,10 +7110,7 @@ var FetchService = class {
|
|
|
6325
7110
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
6326
7111
|
}
|
|
6327
7112
|
const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
|
|
6328
|
-
if (!include || include.length === 0) return rows
|
|
6329
|
-
...row,
|
|
6330
|
-
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6331
|
-
}));
|
|
7113
|
+
if (!include || include.length === 0) return rows;
|
|
6332
7114
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6333
7115
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
6334
7116
|
const shouldInclude = (key) => include[0] === "*" || include.includes(key);
|
|
@@ -6340,10 +7122,7 @@ var FetchService = class {
|
|
|
6340
7122
|
for (const row of rows) {
|
|
6341
7123
|
const eid = row[idInfo.fieldName];
|
|
6342
7124
|
const related = batchResults.get(String(eid));
|
|
6343
|
-
if (related) row[key] = {
|
|
6344
|
-
...related.values,
|
|
6345
|
-
id: related.id
|
|
6346
|
-
};
|
|
7125
|
+
if (related) row[key] = { ...related.values };
|
|
6347
7126
|
}
|
|
6348
7127
|
} catch (e) {
|
|
6349
7128
|
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
@@ -6361,10 +7140,7 @@ var FetchService = class {
|
|
|
6361
7140
|
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
6362
7141
|
}
|
|
6363
7142
|
}
|
|
6364
|
-
return rows
|
|
6365
|
-
...row,
|
|
6366
|
-
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6367
|
-
}));
|
|
7143
|
+
return rows;
|
|
6368
7144
|
}
|
|
6369
7145
|
/**
|
|
6370
7146
|
* Fetch a single row with optional relation includes for REST API.
|
|
@@ -6372,7 +7148,7 @@ var FetchService = class {
|
|
|
6372
7148
|
async fetchOneForRest(collectionPath, id, include, databaseId) {
|
|
6373
7149
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6374
7150
|
const table = getTableForCollection(collection, this.registry);
|
|
6375
|
-
const idInfoArray =
|
|
7151
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6376
7152
|
const idInfo = idInfoArray[0];
|
|
6377
7153
|
const idField = table[idInfo.fieldName];
|
|
6378
7154
|
const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
|
|
@@ -6385,7 +7161,7 @@ var FetchService = class {
|
|
|
6385
7161
|
...withConfig ? { with: withConfig } : {}
|
|
6386
7162
|
});
|
|
6387
7163
|
if (!row) return null;
|
|
6388
|
-
const restRow =
|
|
7164
|
+
const restRow = toRestRow(row, collection, this.registry);
|
|
6389
7165
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
6390
7166
|
return restRow;
|
|
6391
7167
|
} catch (e) {
|
|
@@ -6397,11 +7173,7 @@ var FetchService = class {
|
|
|
6397
7173
|
}
|
|
6398
7174
|
const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
|
|
6399
7175
|
if (result.length === 0) return null;
|
|
6400
|
-
const
|
|
6401
|
-
const flatEntity = {
|
|
6402
|
-
...raw,
|
|
6403
|
-
id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
|
|
6404
|
-
};
|
|
7176
|
+
const flatEntity = { ...result[0] };
|
|
6405
7177
|
if (!include || include.length === 0) return flatEntity;
|
|
6406
7178
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6407
7179
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
@@ -6434,7 +7206,7 @@ var FetchService = class {
|
|
|
6434
7206
|
async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
|
|
6435
7207
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6436
7208
|
const table = getTableForCollection(collection, this.registry);
|
|
6437
|
-
const idField = table[
|
|
7209
|
+
const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
|
|
6438
7210
|
let vectorMeta;
|
|
6439
7211
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
6440
7212
|
let query = vectorMeta ? this.db.select({
|
|
@@ -6513,32 +7285,15 @@ var FetchService = class {
|
|
|
6513
7285
|
if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
6514
7286
|
}
|
|
6515
7287
|
return (await queryTarget.findMany(queryOpts)).map((row) => {
|
|
6516
|
-
const flat = {
|
|
6517
|
-
for (const [k, v] of Object.entries(row)) {
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
...nested,
|
|
6526
|
-
id: String(nested.id ?? nested[Object.keys(nested)[0]])
|
|
6527
|
-
};
|
|
6528
|
-
}
|
|
6529
|
-
return {
|
|
6530
|
-
...item,
|
|
6531
|
-
id: String(item.id ?? item[Object.keys(item)[0]])
|
|
6532
|
-
};
|
|
6533
|
-
});
|
|
6534
|
-
else if (typeof v === "object" && v !== null) {
|
|
6535
|
-
const relObj = v;
|
|
6536
|
-
flat[k] = {
|
|
6537
|
-
...relObj,
|
|
6538
|
-
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
|
|
6539
|
-
};
|
|
6540
|
-
} else flat[k] = v;
|
|
6541
|
-
}
|
|
7288
|
+
const flat = {};
|
|
7289
|
+
for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
|
|
7290
|
+
const keys = Object.keys(item);
|
|
7291
|
+
const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
7292
|
+
if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
|
|
7293
|
+
return { ...item };
|
|
7294
|
+
});
|
|
7295
|
+
else if (typeof v === "object" && v !== null) flat[k] = { ...v };
|
|
7296
|
+
else flat[k] = v;
|
|
6542
7297
|
return flat;
|
|
6543
7298
|
});
|
|
6544
7299
|
} catch (e) {
|
|
@@ -6754,6 +7509,27 @@ var PersistService = class {
|
|
|
6754
7509
|
this.fetchService = new FetchService(db, registry);
|
|
6755
7510
|
}
|
|
6756
7511
|
/**
|
|
7512
|
+
* Explain a write that matched no rows.
|
|
7513
|
+
*
|
|
7514
|
+
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
7515
|
+
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
7516
|
+
* like a successful one that happened to match nothing. Left unchecked, a
|
|
7517
|
+
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
7518
|
+
* the row is untouched.
|
|
7519
|
+
*
|
|
7520
|
+
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
7521
|
+
* cases. A visible row means the policy rejected the write (403); an
|
|
7522
|
+
* invisible one means there is nothing there to write for this caller (404,
|
|
7523
|
+
* matching what a GET would say). The re-read is bound by the caller's own
|
|
7524
|
+
* policies, so it discloses nothing a plain read wouldn't.
|
|
7525
|
+
*
|
|
7526
|
+
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
7527
|
+
*/
|
|
7528
|
+
async explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
|
|
7529
|
+
if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(`Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`, "WRITE_DENIED");
|
|
7530
|
+
return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
|
|
7531
|
+
}
|
|
7532
|
+
/**
|
|
6757
7533
|
* Delete an row by ID
|
|
6758
7534
|
*/
|
|
6759
7535
|
async delete(collectionPath, id, _databaseId) {
|
|
@@ -6767,7 +7543,7 @@ var PersistService = class {
|
|
|
6767
7543
|
if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
6768
7544
|
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
6769
7545
|
}
|
|
6770
|
-
await this.db.delete(table).where(and(...conditions));
|
|
7546
|
+
if (((await this.db.delete(table).where(and(...conditions))).rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
|
|
6771
7547
|
}
|
|
6772
7548
|
/**
|
|
6773
7549
|
* Delete all rows from a collection
|
|
@@ -6778,8 +7554,14 @@ var PersistService = class {
|
|
|
6778
7554
|
}
|
|
6779
7555
|
/**
|
|
6780
7556
|
* Save an row (create or update)
|
|
7557
|
+
*
|
|
7558
|
+
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
7559
|
+
* UPDATE against the primary key rather than a plain UPDATE. That is one
|
|
7560
|
+
* statement, so it cannot lose a race the way a read-then-write can, and it
|
|
7561
|
+
* does not care whether the row already exists — which is what a re-runnable
|
|
7562
|
+
* import needs.
|
|
6781
7563
|
*/
|
|
6782
|
-
async save(collectionPath, values, id, databaseId) {
|
|
7564
|
+
async save(collectionPath, values, id, databaseId, options) {
|
|
6783
7565
|
let effectiveCollectionPath = collectionPath;
|
|
6784
7566
|
const effectiveValues = { ...values };
|
|
6785
7567
|
let junctionTableInfo;
|
|
@@ -6870,7 +7652,7 @@ var PersistService = class {
|
|
|
6870
7652
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
6871
7653
|
savedId = await this.db.transaction(async (tx) => {
|
|
6872
7654
|
let currentId;
|
|
6873
|
-
if (id) {
|
|
7655
|
+
if (id && !options?.upsert) {
|
|
6874
7656
|
currentId = id;
|
|
6875
7657
|
const idValues = parseIdValues(id, idInfoArray);
|
|
6876
7658
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
@@ -6881,13 +7663,28 @@ var PersistService = class {
|
|
|
6881
7663
|
const field = table[info.fieldName];
|
|
6882
7664
|
conditions.push(eq(field, idValues[info.fieldName]));
|
|
6883
7665
|
}
|
|
6884
|
-
await updateQuery.where(and(...conditions));
|
|
7666
|
+
if (((await updateQuery.where(and(...conditions))).rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(tx, table, conditions, effectiveCollectionPath, currentId, "update");
|
|
6885
7667
|
}
|
|
6886
7668
|
} else {
|
|
6887
7669
|
const dataForInsert = { ...entityData };
|
|
7670
|
+
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
6888
7671
|
for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
|
|
6889
|
-
const
|
|
6890
|
-
|
|
7672
|
+
const insertQuery = tx.insert(table).values(dataForInsert);
|
|
7673
|
+
const hasFullKey = idInfoArray.length > 0 && idInfoArray.every((info) => dataForInsert[info.fieldName] !== void 0);
|
|
7674
|
+
let result;
|
|
7675
|
+
if (options?.upsert && hasFullKey) {
|
|
7676
|
+
const target = idInfoArray.map((info) => table[info.fieldName]);
|
|
7677
|
+
const set = { ...dataForInsert };
|
|
7678
|
+
for (const info of idInfoArray) delete set[info.fieldName];
|
|
7679
|
+
result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
|
|
7680
|
+
target,
|
|
7681
|
+
set
|
|
7682
|
+
}).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
|
|
7683
|
+
} else result = await insertQuery.returning(returningKeys);
|
|
7684
|
+
const resultRow = result[0];
|
|
7685
|
+
if (!resultRow) if (id) currentId = id;
|
|
7686
|
+
else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
|
|
7687
|
+
else currentId = buildCompositeId(resultRow, idInfoArray);
|
|
6891
7688
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
6892
7689
|
}
|
|
6893
7690
|
if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
@@ -6898,7 +7695,7 @@ var PersistService = class {
|
|
|
6898
7695
|
} catch (error) {
|
|
6899
7696
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
6900
7697
|
}
|
|
6901
|
-
const finalEntity = await this.fetchService.
|
|
7698
|
+
const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
|
|
6902
7699
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
6903
7700
|
return finalEntity;
|
|
6904
7701
|
}
|
|
@@ -6918,6 +7715,7 @@ var PersistService = class {
|
|
|
6918
7715
|
* Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
|
|
6919
7716
|
*/
|
|
6920
7717
|
toUserFriendlyError(error, collectionSlug) {
|
|
7718
|
+
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
6921
7719
|
const pgError = extractPgError(error);
|
|
6922
7720
|
if (pgError) {
|
|
6923
7721
|
const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
@@ -6995,8 +7793,8 @@ var DataService = class {
|
|
|
6995
7793
|
/**
|
|
6996
7794
|
* Save an row (create or update)
|
|
6997
7795
|
*/
|
|
6998
|
-
async save(collectionPath, values, id, databaseId) {
|
|
6999
|
-
return this.persistService.save(collectionPath, values, id, databaseId);
|
|
7796
|
+
async save(collectionPath, values, id, databaseId, options) {
|
|
7797
|
+
return this.persistService.save(collectionPath, values, id, databaseId, options);
|
|
7000
7798
|
}
|
|
7001
7799
|
/**
|
|
7002
7800
|
* Delete an row by ID
|
|
@@ -7064,6 +7862,26 @@ var DataService = class {
|
|
|
7064
7862
|
*/
|
|
7065
7863
|
/** Internal prefix applied to branch database names to avoid collisions. */
|
|
7066
7864
|
var BRANCH_DB_PREFIX = "rb_";
|
|
7865
|
+
/** `duplicate_database` — the target database name is already taken. */
|
|
7866
|
+
var PG_DUPLICATE_DATABASE = "42P04";
|
|
7867
|
+
/** `object_in_use` — the database still has connections attached. */
|
|
7868
|
+
var PG_OBJECT_IN_USE = "55006";
|
|
7869
|
+
/**
|
|
7870
|
+
* Describe a failed branch DDL statement in terms a user can act on.
|
|
7871
|
+
*
|
|
7872
|
+
* Drizzle reports failures as `Failed query: <sql> params:` and hides the real
|
|
7873
|
+
* PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
|
|
7874
|
+
* the actual problem. Match on the PG error code instead — it survives wrapping
|
|
7875
|
+
* and, unlike the message text, is not locale-dependent.
|
|
7876
|
+
*/
|
|
7877
|
+
function describeBranchDdlError(err, fallbackContext) {
|
|
7878
|
+
const pgError = extractPgError(err);
|
|
7879
|
+
if (pgError?.code === PG_DUPLICATE_DATABASE) return /* @__PURE__ */ new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
|
|
7880
|
+
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.`);
|
|
7881
|
+
const detail = pgError?.message ?? extractCauseMessage(err);
|
|
7882
|
+
if (detail) return new Error(detail);
|
|
7883
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
7884
|
+
}
|
|
7067
7885
|
/** Fully-qualified metadata table in the rebase schema. */
|
|
7068
7886
|
var BRANCHES_TABLE = "rebase.branches";
|
|
7069
7887
|
/**
|
|
@@ -7132,10 +7950,8 @@ var BranchService = class {
|
|
|
7132
7950
|
try {
|
|
7133
7951
|
await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
|
|
7134
7952
|
} catch (err) {
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
if (msg.includes("being accessed by other users")) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
|
|
7138
|
-
throw err;
|
|
7953
|
+
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.`);
|
|
7954
|
+
throw describeBranchDdlError(err, dbName);
|
|
7139
7955
|
}
|
|
7140
7956
|
const now = /* @__PURE__ */ new Date();
|
|
7141
7957
|
await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
@@ -7160,8 +7976,8 @@ var BranchService = class {
|
|
|
7160
7976
|
try {
|
|
7161
7977
|
await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
|
|
7162
7978
|
} catch (err) {
|
|
7163
|
-
if ((err
|
|
7164
|
-
throw err;
|
|
7979
|
+
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.`);
|
|
7980
|
+
throw describeBranchDdlError(err, dbName);
|
|
7165
7981
|
}
|
|
7166
7982
|
await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
|
|
7167
7983
|
}
|
|
@@ -7793,17 +8609,19 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7793
8609
|
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
7794
8610
|
};
|
|
7795
8611
|
}
|
|
7796
|
-
async save({ path, id, values, collection, status }) {
|
|
8612
|
+
async save({ path, id, values, collection, status, upsert }) {
|
|
7797
8613
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
7798
8614
|
let updatedValues = values;
|
|
7799
8615
|
const contextForCallback = this.buildCallContext();
|
|
7800
8616
|
let previousValuesForHistory;
|
|
7801
|
-
if (status === "existing" && id) {
|
|
7802
|
-
const existing = await this.dataService.
|
|
8617
|
+
if (status === "existing" && id) try {
|
|
8618
|
+
const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
|
|
7803
8619
|
if (existing) {
|
|
7804
8620
|
const { id: _existingId, ...existingValues } = existing;
|
|
7805
8621
|
previousValuesForHistory = existingValues;
|
|
7806
8622
|
}
|
|
8623
|
+
} catch (err) {
|
|
8624
|
+
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
7807
8625
|
}
|
|
7808
8626
|
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
7809
8627
|
if (globalCallbacks?.beforeSave) {
|
|
@@ -7850,7 +8668,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7850
8668
|
timestampNowValue: /* @__PURE__ */ new Date()
|
|
7851
8669
|
});
|
|
7852
8670
|
try {
|
|
7853
|
-
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
|
|
8671
|
+
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
|
|
7854
8672
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
7855
8673
|
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
7856
8674
|
collection: resolvedCollection,
|
|
@@ -7871,8 +8689,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7871
8689
|
context: contextForCallback
|
|
7872
8690
|
});
|
|
7873
8691
|
}
|
|
7874
|
-
const savedId = savedRow.
|
|
7875
|
-
const
|
|
8692
|
+
const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
|
|
8693
|
+
const savedValues = savedRow;
|
|
7876
8694
|
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
7877
8695
|
if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
|
|
7878
8696
|
collection: resolvedCollection,
|
|
@@ -7904,7 +8722,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7904
8722
|
}
|
|
7905
8723
|
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
7906
8724
|
tableName: path,
|
|
7907
|
-
id: savedId
|
|
8725
|
+
id: savedId,
|
|
7908
8726
|
action: status === "new" ? "create" : "update",
|
|
7909
8727
|
values: savedValues,
|
|
7910
8728
|
previousValues: previousValuesForHistory,
|
|
@@ -7912,11 +8730,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7912
8730
|
});
|
|
7913
8731
|
if (this._deferNotifications) this._pendingNotifications.push({
|
|
7914
8732
|
path,
|
|
7915
|
-
id: savedId
|
|
8733
|
+
id: savedId,
|
|
7916
8734
|
row: savedRow,
|
|
7917
8735
|
databaseId: resolvedCollection?.databaseId
|
|
7918
8736
|
});
|
|
7919
|
-
else await this.realtimeService.notifyUpdate(path, savedId
|
|
8737
|
+
else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
|
|
7920
8738
|
return savedRow;
|
|
7921
8739
|
} catch (error) {
|
|
7922
8740
|
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
@@ -7951,12 +8769,52 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7951
8769
|
throw error;
|
|
7952
8770
|
}
|
|
7953
8771
|
}
|
|
8772
|
+
/**
|
|
8773
|
+
* Write many rows through the same pipeline as {@link save}.
|
|
8774
|
+
*
|
|
8775
|
+
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
8776
|
+
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
8777
|
+
* state. When this driver is already inside a transaction (the authenticated
|
|
8778
|
+
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
8779
|
+
* still atomic and still commits once.
|
|
8780
|
+
*
|
|
8781
|
+
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
8782
|
+
* with the last write winning, exactly as separate calls would.
|
|
8783
|
+
*/
|
|
8784
|
+
async saveMany({ path, rows, collection, upsert }) {
|
|
8785
|
+
return this.db.transaction(async (tx) => {
|
|
8786
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
8787
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
8788
|
+
txDriver.client = this.client;
|
|
8789
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
8790
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
8791
|
+
const saved = [];
|
|
8792
|
+
for (let i = 0; i < rows.length; i++) {
|
|
8793
|
+
const values = rows[i];
|
|
8794
|
+
const id = values?.id;
|
|
8795
|
+
try {
|
|
8796
|
+
saved.push(await txDriver.save({
|
|
8797
|
+
path,
|
|
8798
|
+
values,
|
|
8799
|
+
collection,
|
|
8800
|
+
status: "new",
|
|
8801
|
+
upsert
|
|
8802
|
+
}));
|
|
8803
|
+
} catch (error) {
|
|
8804
|
+
const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
|
|
8805
|
+
throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
8806
|
+
statusCode: error?.statusCode,
|
|
8807
|
+
code: error?.code,
|
|
8808
|
+
name: error?.name
|
|
8809
|
+
});
|
|
8810
|
+
}
|
|
8811
|
+
}
|
|
8812
|
+
return saved;
|
|
8813
|
+
});
|
|
8814
|
+
}
|
|
7954
8815
|
async delete({ row, collection }) {
|
|
7955
8816
|
const targetPath = row.path;
|
|
7956
|
-
const targetRow = {
|
|
7957
|
-
id: row.id,
|
|
7958
|
-
...row.values ?? {}
|
|
7959
|
-
};
|
|
8817
|
+
const targetRow = { ...row.values ?? {} };
|
|
7960
8818
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
|
|
7961
8819
|
const contextForCallback = this.buildCallContext();
|
|
7962
8820
|
if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
|
|
@@ -8315,6 +9173,18 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
8315
9173
|
async save(props) {
|
|
8316
9174
|
return this.withTransaction((delegate) => delegate.save(props));
|
|
8317
9175
|
}
|
|
9176
|
+
/**
|
|
9177
|
+
* One transaction for the whole batch, rather than one per row.
|
|
9178
|
+
*
|
|
9179
|
+
* This is the point of the method: `save` opens a transaction per call, so
|
|
9180
|
+
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
9181
|
+
* round trips). Here the RLS context is established once and every row lands
|
|
9182
|
+
* or none does. Realtime notifications are already deferred to commit by
|
|
9183
|
+
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
9184
|
+
*/
|
|
9185
|
+
async saveMany(props) {
|
|
9186
|
+
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
9187
|
+
}
|
|
8318
9188
|
async delete(props) {
|
|
8319
9189
|
return this.withTransaction((delegate) => delegate.delete(props));
|
|
8320
9190
|
}
|
|
@@ -8364,6 +9234,7 @@ var DatabasePoolManager = class {
|
|
|
8364
9234
|
pool.on("error", (err) => {
|
|
8365
9235
|
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
8366
9236
|
});
|
|
9237
|
+
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
8367
9238
|
this.pools.set(databaseName, pool);
|
|
8368
9239
|
return pool;
|
|
8369
9240
|
}
|
|
@@ -8561,105 +9432,6 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
|
|
|
8561
9432
|
references: [users.id]
|
|
8562
9433
|
}) }));
|
|
8563
9434
|
//#endregion
|
|
8564
|
-
//#region src/schema/auth-default-policies.ts
|
|
8565
|
-
/**
|
|
8566
|
-
* Default RLS policies injected by the schema generator.
|
|
8567
|
-
*
|
|
8568
|
-
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
8569
|
-
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
8570
|
-
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
8571
|
-
* authorization model. The server context (auth flows, migrations,
|
|
8572
|
-
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
8573
|
-
*
|
|
8574
|
-
* Because RLS default-denies, every collection is **locked by default**: with
|
|
8575
|
-
* no rules, only the server context and admins can touch it. The generator
|
|
8576
|
-
* injects that safe baseline:
|
|
8577
|
-
*
|
|
8578
|
-
* **For every collection**
|
|
8579
|
-
* 1. A permissive **server-or-admin SELECT** grant.
|
|
8580
|
-
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
8581
|
-
*
|
|
8582
|
-
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
8583
|
-
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
8584
|
-
* rows").
|
|
8585
|
-
*
|
|
8586
|
-
* **For auth collections additionally**
|
|
8587
|
-
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
8588
|
-
* their own row (profile, session bootstrap) without every app re-declaring
|
|
8589
|
-
* it.
|
|
8590
|
-
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
8591
|
-
* every other policy, so a write is rejected unless the caller is an admin
|
|
8592
|
-
* (or the server context) — even if the author also wrote a permissive rule
|
|
8593
|
-
* such as "a user may edit their own row". Without this, a permissive owner
|
|
8594
|
-
* rule would let a user change their own `roles`.
|
|
8595
|
-
*
|
|
8596
|
-
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
8597
|
-
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
8598
|
-
* GUC — which also lets the owner connection satisfy these policies even under
|
|
8599
|
-
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
8600
|
-
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
8601
|
-
*
|
|
8602
|
-
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
8603
|
-
* the collection's RLS.
|
|
8604
|
-
*/
|
|
8605
|
-
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
8606
|
-
/** Write operations that must be admin-gated by default on auth collections. */
|
|
8607
|
-
var DEFAULT_GUARDED_OPS = [
|
|
8608
|
-
"insert",
|
|
8609
|
-
"update",
|
|
8610
|
-
"delete"
|
|
8611
|
-
];
|
|
8612
|
-
/** Whether a collection is flagged as an authentication collection. */
|
|
8613
|
-
function isAuthCollection(collection) {
|
|
8614
|
-
const auth = collection.auth;
|
|
8615
|
-
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
8616
|
-
}
|
|
8617
|
-
/** The property marked as the row id (falls back to `id`). */
|
|
8618
|
-
function getIdPropertyName(collection) {
|
|
8619
|
-
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
8620
|
-
return "id";
|
|
8621
|
-
}
|
|
8622
|
-
/**
|
|
8623
|
-
* Returns the security rules that should be applied to a collection: the
|
|
8624
|
-
* author's explicit `securityRules` plus the framework defaults described in
|
|
8625
|
-
* the module doc (baseline server/admin read for all collections; self-read
|
|
8626
|
-
* and the admin write gate for auth collections).
|
|
8627
|
-
*
|
|
8628
|
-
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
8629
|
-
*/
|
|
8630
|
-
function getEffectiveSecurityRules(collection) {
|
|
8631
|
-
const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
|
|
8632
|
-
if (collection.disableDefaultPolicies) return explicit;
|
|
8633
|
-
const tableName = getTableName$1(collection);
|
|
8634
|
-
const injected = [];
|
|
8635
|
-
injected.push({
|
|
8636
|
-
name: `${tableName}_default_admin_read`,
|
|
8637
|
-
operations: ["select"],
|
|
8638
|
-
condition: SERVER_OR_ADMIN_EXPR
|
|
8639
|
-
});
|
|
8640
|
-
injected.push({
|
|
8641
|
-
name: `${tableName}_default_admin_write`,
|
|
8642
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
8643
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
8644
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
8645
|
-
});
|
|
8646
|
-
if (isAuthCollection(collection)) {
|
|
8647
|
-
injected.push({
|
|
8648
|
-
name: `${tableName}_default_self_read`,
|
|
8649
|
-
operations: ["select"],
|
|
8650
|
-
condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
|
|
8651
|
-
});
|
|
8652
|
-
injected.push({
|
|
8653
|
-
name: `${tableName}_require_admin_write`,
|
|
8654
|
-
mode: "restrictive",
|
|
8655
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
8656
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
8657
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
8658
|
-
});
|
|
8659
|
-
}
|
|
8660
|
-
return [...explicit, ...injected];
|
|
8661
|
-
}
|
|
8662
|
-
//#endregion
|
|
8663
9435
|
//#region src/schema/generate-drizzle-schema-logic.ts
|
|
8664
9436
|
/**
|
|
8665
9437
|
* Resolve the SQL column name for a property.
|
|
@@ -8857,31 +9629,12 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
8857
9629
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
8858
9630
|
*/
|
|
8859
9631
|
var wrapSql = (clause) => `sql\`${clause}\``;
|
|
8860
|
-
/**
|
|
8861
|
-
* Generates a deterministic hash based on the rule configuration.
|
|
8862
|
-
*/
|
|
8863
|
-
var getPolicyNameHash = (rule) => {
|
|
8864
|
-
const data = JSON.stringify({
|
|
8865
|
-
a: rule.access,
|
|
8866
|
-
m: rule.mode,
|
|
8867
|
-
op: rule.operation,
|
|
8868
|
-
ops: rule.operations?.slice().sort(),
|
|
8869
|
-
own: rule.ownerField,
|
|
8870
|
-
rol: rule.roles?.slice().sort(),
|
|
8871
|
-
pg: rule.pgRoles?.slice().sort(),
|
|
8872
|
-
u: rule.using,
|
|
8873
|
-
w: rule.withCheck,
|
|
8874
|
-
c: rule.condition,
|
|
8875
|
-
ch: rule.check
|
|
8876
|
-
});
|
|
8877
|
-
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
8878
|
-
};
|
|
8879
9632
|
var generatePolicyCode = (collection, rule, index, resolveCollection) => {
|
|
8880
9633
|
const tableName = getTableName$1(collection);
|
|
8881
9634
|
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
8882
|
-
const
|
|
9635
|
+
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
8883
9636
|
return ops.map((op, opIdx) => {
|
|
8884
|
-
return generateSinglePolicyCode(collection, rule, op,
|
|
9637
|
+
return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
8885
9638
|
}).join("");
|
|
8886
9639
|
};
|
|
8887
9640
|
/**
|
|
@@ -9010,6 +9763,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9010
9763
|
});
|
|
9011
9764
|
});
|
|
9012
9765
|
schemaContent += "\n";
|
|
9766
|
+
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
9013
9767
|
for (const collection of collections) {
|
|
9014
9768
|
const tableName = getTableName$1(collection);
|
|
9015
9769
|
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
@@ -9043,9 +9797,17 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9043
9797
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
9044
9798
|
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
9045
9799
|
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
9046
|
-
schemaContent += "}, (table) => (
|
|
9047
|
-
schemaContent += `
|
|
9048
|
-
|
|
9800
|
+
schemaContent += "}, (table) => ([\n";
|
|
9801
|
+
schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
|
|
9802
|
+
const junctionSpec = junctionSpecs.get(baseTableName);
|
|
9803
|
+
if (!stripPolicies && junctionSpec) {
|
|
9804
|
+
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
9805
|
+
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
9806
|
+
getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
|
|
9807
|
+
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
9808
|
+
});
|
|
9809
|
+
}
|
|
9810
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
9049
9811
|
} else if (!isJunction) {
|
|
9050
9812
|
const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
|
|
9051
9813
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
@@ -9736,7 +10498,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9736
10498
|
startAfter: request.startAfter,
|
|
9737
10499
|
searchString: request.searchString
|
|
9738
10500
|
}, authContext);
|
|
9739
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows);
|
|
10501
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
9740
10502
|
} catch (error) {
|
|
9741
10503
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
9742
10504
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -9837,7 +10599,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9837
10599
|
if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
9838
10600
|
else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
|
|
9839
10601
|
else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
9840
|
-
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
|
|
10602
|
+
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
|
|
9841
10603
|
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
9842
10604
|
}
|
|
9843
10605
|
} catch (error) {
|
|
@@ -9867,7 +10629,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9867
10629
|
if (!this._subscriptions.has(subscriptionId)) return;
|
|
9868
10630
|
try {
|
|
9869
10631
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
|
|
9870
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
|
|
10632
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
9871
10633
|
} catch (error) {
|
|
9872
10634
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
9873
10635
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -10081,11 +10843,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10081
10843
|
}
|
|
10082
10844
|
return await this.dataService.fetchOne(notifyPath, id);
|
|
10083
10845
|
}
|
|
10084
|
-
sendCollectionUpdate(clientId, subscriptionId, rows) {
|
|
10846
|
+
sendCollectionUpdate(clientId, subscriptionId, rows, path) {
|
|
10085
10847
|
const message = {
|
|
10086
10848
|
type: "collection_update",
|
|
10087
10849
|
subscriptionId,
|
|
10088
|
-
rows
|
|
10850
|
+
rows,
|
|
10851
|
+
pks: this.primaryKeysForPath(path)
|
|
10089
10852
|
};
|
|
10090
10853
|
this.sendMessage(clientId, message);
|
|
10091
10854
|
}
|
|
@@ -10100,16 +10863,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10100
10863
|
/**
|
|
10101
10864
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
10102
10865
|
* The client can merge this into its cached data for instant feedback.
|
|
10866
|
+
*
|
|
10867
|
+
* The key columns ride along: the patch names a row by address, and the
|
|
10868
|
+
* client has to find that row among the ones it cached — which carry
|
|
10869
|
+
* columns and no address. The SDK holds no collection config to derive one
|
|
10870
|
+
* from, so this is the only place the mapping can come from.
|
|
10103
10871
|
*/
|
|
10104
|
-
sendCollectionPatch(clientId, subscriptionId, id, row) {
|
|
10872
|
+
sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
|
|
10105
10873
|
const message = {
|
|
10106
10874
|
type: "collection_patch",
|
|
10107
10875
|
subscriptionId,
|
|
10108
10876
|
id,
|
|
10109
|
-
row
|
|
10877
|
+
row,
|
|
10878
|
+
pks: this.primaryKeysForPath(notifyPath)
|
|
10110
10879
|
};
|
|
10111
10880
|
this.sendMessage(clientId, message);
|
|
10112
10881
|
}
|
|
10882
|
+
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
10883
|
+
primaryKeysForPath(path) {
|
|
10884
|
+
try {
|
|
10885
|
+
const collection = this.registry.getCollectionByPath(path);
|
|
10886
|
+
if (!collection) return void 0;
|
|
10887
|
+
const keys = getPrimaryKeys(collection, this.registry);
|
|
10888
|
+
return keys.length > 0 ? keys : void 0;
|
|
10889
|
+
} catch {
|
|
10890
|
+
return;
|
|
10891
|
+
}
|
|
10892
|
+
}
|
|
10113
10893
|
sendError(clientId, error, subscriptionId, code) {
|
|
10114
10894
|
const message = {
|
|
10115
10895
|
type: "error",
|
|
@@ -10357,12 +11137,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10357
11137
|
}
|
|
10358
11138
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
10359
11139
|
extractIdFromCdcRow(collection, row) {
|
|
10360
|
-
|
|
10361
|
-
const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
|
|
10362
|
-
if (composite && composite !== ":::") return composite;
|
|
10363
|
-
} catch {}
|
|
10364
|
-
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
10365
|
-
return "*";
|
|
11140
|
+
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
10366
11141
|
}
|
|
10367
11142
|
dedupKey(path, id, databaseId) {
|
|
10368
11143
|
return `${databaseId ?? ""}::${path}::${id}`;
|
|
@@ -10978,7 +11753,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10978
11753
|
} catch (error) {
|
|
10979
11754
|
logger.error("💥 [WebSocket Server] Error handling message", { error });
|
|
10980
11755
|
if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
|
|
10981
|
-
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error
|
|
11756
|
+
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
|
|
10982
11757
|
const errorResponse = {
|
|
10983
11758
|
type: "ERROR",
|
|
10984
11759
|
requestId,
|
|
@@ -19822,6 +20597,33 @@ function formatBytes(bytes) {
|
|
|
19822
20597
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
19823
20598
|
}
|
|
19824
20599
|
//#endregion
|
|
20600
|
+
//#region src/collections/buildRegistry.ts
|
|
20601
|
+
/**
|
|
20602
|
+
* Build the collection registry for a driver.
|
|
20603
|
+
*
|
|
20604
|
+
* The order matters and is the reason this is one function rather than a run of
|
|
20605
|
+
* statements in the bootstrapper. Keys are resolved from the drizzle schema, so
|
|
20606
|
+
* anything that inspects them has to run *after* the tables are registered —
|
|
20607
|
+
* and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
|
|
20608
|
+
* collection whose table it cannot look up is one it has nothing to say about.
|
|
20609
|
+
* Warned too early, it would skip every collection and report nothing, which
|
|
20610
|
+
* reads exactly like having nothing to report.
|
|
20611
|
+
*/
|
|
20612
|
+
function buildCollectionRegistry(schema) {
|
|
20613
|
+
const registry = new PostgresCollectionRegistry();
|
|
20614
|
+
if (schema.collections) {
|
|
20615
|
+
registry.registerMultiple(schema.collections);
|
|
20616
|
+
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
20617
|
+
}
|
|
20618
|
+
if (schema.tables) Object.values(schema.tables).forEach((table) => {
|
|
20619
|
+
if (isTable(table)) registry.registerTable(table, getTableName(table));
|
|
20620
|
+
});
|
|
20621
|
+
if (schema.enums) registry.registerEnums(schema.enums);
|
|
20622
|
+
if (schema.relations) registry.registerRelations(schema.relations);
|
|
20623
|
+
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
20624
|
+
return registry;
|
|
20625
|
+
}
|
|
20626
|
+
//#endregion
|
|
19825
20627
|
//#region src/auth/ensure-tables.ts
|
|
19826
20628
|
/**
|
|
19827
20629
|
* Auto-create auth tables if they don't exist.
|
|
@@ -19993,14 +20795,22 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
19993
20795
|
$$ LANGUAGE sql STABLE
|
|
19994
20796
|
`);
|
|
19995
20797
|
});
|
|
19996
|
-
|
|
19997
|
-
|
|
19998
|
-
|
|
19999
|
-
|
|
20000
|
-
|
|
20001
|
-
|
|
20002
|
-
|
|
20003
|
-
|
|
20798
|
+
for (const columnDef of [
|
|
20799
|
+
"display_name VARCHAR(255)",
|
|
20800
|
+
"photo_url VARCHAR(500)",
|
|
20801
|
+
"roles TEXT[] DEFAULT '{}' NOT NULL",
|
|
20802
|
+
"password_hash VARCHAR(255)",
|
|
20803
|
+
"email_verified BOOLEAN DEFAULT FALSE NOT NULL",
|
|
20804
|
+
"email_verification_token VARCHAR(255)",
|
|
20805
|
+
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
20806
|
+
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
20807
|
+
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
20808
|
+
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
20809
|
+
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
20810
|
+
]) await db.execute(sql`
|
|
20811
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
20812
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
|
|
20813
|
+
`);
|
|
20004
20814
|
try {
|
|
20005
20815
|
if ((await db.execute(sql`
|
|
20006
20816
|
SELECT EXISTS (
|
|
@@ -20071,6 +20881,34 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20071
20881
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
20072
20882
|
ON ${sql.raw(recoveryCodesTableName)}(user_id)
|
|
20073
20883
|
`);
|
|
20884
|
+
try {
|
|
20885
|
+
const authTablePairs = [
|
|
20886
|
+
[usersSchema, resolvedTable],
|
|
20887
|
+
[authSchema, "user_identities"],
|
|
20888
|
+
[authSchema, "refresh_tokens"],
|
|
20889
|
+
[authSchema, "password_reset_tokens"],
|
|
20890
|
+
[authSchema, "app_config"],
|
|
20891
|
+
[authSchema, "mfa_factors"],
|
|
20892
|
+
[authSchema, "mfa_challenges"],
|
|
20893
|
+
[authSchema, "recovery_codes"]
|
|
20894
|
+
];
|
|
20895
|
+
for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
|
|
20896
|
+
SELECT 1
|
|
20897
|
+
FROM pg_class c
|
|
20898
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
20899
|
+
WHERE n.nspname = ${schemaName}
|
|
20900
|
+
AND c.relname = ${tableName}
|
|
20901
|
+
AND c.relforcerowsecurity
|
|
20902
|
+
`)).rows.length > 0) {
|
|
20903
|
+
await db.execute(sql`
|
|
20904
|
+
ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
|
|
20905
|
+
NO FORCE ROW LEVEL SECURITY
|
|
20906
|
+
`);
|
|
20907
|
+
logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
|
|
20908
|
+
}
|
|
20909
|
+
} catch (rlsReconcileError) {
|
|
20910
|
+
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
20911
|
+
}
|
|
20074
20912
|
logger.info("✅ Auth tables ready");
|
|
20075
20913
|
} catch (error) {
|
|
20076
20914
|
logger.error("❌ Failed to create auth tables", { error });
|
|
@@ -20118,6 +20956,31 @@ var UserService = class {
|
|
|
20118
20956
|
const name = getTableName(this.usersTable);
|
|
20119
20957
|
return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
|
|
20120
20958
|
}
|
|
20959
|
+
/**
|
|
20960
|
+
* Run a privileged auth write with an explicitly cleared RLS context.
|
|
20961
|
+
*
|
|
20962
|
+
* The auth services run on the base/owner connection, which by design
|
|
20963
|
+
* carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
|
|
20964
|
+
* in the default policies applies. That NULL is normally guaranteed by
|
|
20965
|
+
* `set_config(..., is_local = true)` resetting at transaction end — but a
|
|
20966
|
+
* GUC that survives on a pooled connection (or a connection role that
|
|
20967
|
+
* doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
|
|
20968
|
+
* turns the trusted write into an RLS-scoped one and denies it with
|
|
20969
|
+
* SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
|
|
20970
|
+
* single chokepoint, makes the server context deterministic instead of
|
|
20971
|
+
* trusting whatever state the pool hands us. `auth.uid()` reads '' as
|
|
20972
|
+
* NULL via NULLIF, so '' is the server context.
|
|
20973
|
+
*/
|
|
20974
|
+
async withServerContext(fn) {
|
|
20975
|
+
return await this.db.transaction(async (tx) => {
|
|
20976
|
+
await tx.execute(sql`
|
|
20977
|
+
SELECT set_config('app.user_id', '', true),
|
|
20978
|
+
set_config('app.user_roles', '', true),
|
|
20979
|
+
set_config('app.jwt', '', true)
|
|
20980
|
+
`);
|
|
20981
|
+
return await fn(tx);
|
|
20982
|
+
});
|
|
20983
|
+
}
|
|
20121
20984
|
mapRowToUser(row) {
|
|
20122
20985
|
if (!row) return row;
|
|
20123
20986
|
const id = row.id ?? row.uid;
|
|
@@ -20215,7 +21078,7 @@ var UserService = class {
|
|
|
20215
21078
|
}
|
|
20216
21079
|
async createUser(data) {
|
|
20217
21080
|
const payload = this.mapPayload(data);
|
|
20218
|
-
const [row] = await this.db.insert(this.usersTable).values(payload).returning();
|
|
21081
|
+
const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
|
|
20219
21082
|
return this.mapRowToUser(row);
|
|
20220
21083
|
}
|
|
20221
21084
|
async getUserById(id) {
|
|
@@ -20254,12 +21117,12 @@ var UserService = class {
|
|
|
20254
21117
|
}));
|
|
20255
21118
|
}
|
|
20256
21119
|
async linkUserIdentity(userId, provider, providerId, profileData) {
|
|
20257
|
-
await this.db.insert(this.userIdentitiesTable).values({
|
|
21120
|
+
await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
|
|
20258
21121
|
userId,
|
|
20259
21122
|
provider,
|
|
20260
21123
|
providerId,
|
|
20261
21124
|
profileData: profileData || null
|
|
20262
|
-
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
|
|
21125
|
+
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
|
|
20263
21126
|
}
|
|
20264
21127
|
async updateUser(id, data) {
|
|
20265
21128
|
const idCol = getColumn(this.usersTable, "id");
|
|
@@ -20267,13 +21130,13 @@ var UserService = class {
|
|
|
20267
21130
|
const payload = this.mapPayload(data);
|
|
20268
21131
|
const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20269
21132
|
payload[updatedAtKey] = /* @__PURE__ */ new Date();
|
|
20270
|
-
const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
|
|
21133
|
+
const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
|
|
20271
21134
|
return row ? this.mapRowToUser(row) : null;
|
|
20272
21135
|
}
|
|
20273
21136
|
async deleteUser(id) {
|
|
20274
21137
|
const idCol = getColumn(this.usersTable, "id");
|
|
20275
21138
|
if (!idCol) return;
|
|
20276
|
-
await this.db.delete(this.usersTable).where(eq(idCol, id));
|
|
21139
|
+
await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
|
|
20277
21140
|
}
|
|
20278
21141
|
async listUsers() {
|
|
20279
21142
|
return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
|
|
@@ -20327,10 +21190,10 @@ var UserService = class {
|
|
|
20327
21190
|
if (!idCol) return;
|
|
20328
21191
|
const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
|
|
20329
21192
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20330
|
-
await this.db.update(this.usersTable).set({
|
|
21193
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20331
21194
|
[passwordHashColKey]: passwordHash,
|
|
20332
21195
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20333
|
-
}).where(eq(idCol, id));
|
|
21196
|
+
}).where(eq(idCol, id)));
|
|
20334
21197
|
}
|
|
20335
21198
|
/**
|
|
20336
21199
|
* Set email verification status
|
|
@@ -20341,11 +21204,11 @@ var UserService = class {
|
|
|
20341
21204
|
const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
|
|
20342
21205
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
20343
21206
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20344
|
-
await this.db.update(this.usersTable).set({
|
|
21207
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20345
21208
|
[emailVerifiedColKey]: verified,
|
|
20346
21209
|
[emailVerificationTokenColKey]: null,
|
|
20347
21210
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20348
|
-
}).where(eq(idCol, id));
|
|
21211
|
+
}).where(eq(idCol, id)));
|
|
20349
21212
|
}
|
|
20350
21213
|
/**
|
|
20351
21214
|
* Set email verification token
|
|
@@ -20356,11 +21219,11 @@ var UserService = class {
|
|
|
20356
21219
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
20357
21220
|
const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
|
|
20358
21221
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20359
|
-
await this.db.update(this.usersTable).set({
|
|
21222
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20360
21223
|
[emailVerificationTokenColKey]: token,
|
|
20361
21224
|
[emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
|
|
20362
21225
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20363
|
-
}).where(eq(idCol, id));
|
|
21226
|
+
}).where(eq(idCol, id)));
|
|
20364
21227
|
}
|
|
20365
21228
|
/**
|
|
20366
21229
|
* Find user by email verification token
|
|
@@ -20405,22 +21268,22 @@ var UserService = class {
|
|
|
20405
21268
|
async setUserRoles(userId, roleIds) {
|
|
20406
21269
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
20407
21270
|
const rolesArray = `{${roleIds.join(",")}}`;
|
|
20408
|
-
await this.db.execute(sql`
|
|
21271
|
+
await this.withServerContext(async (db) => db.execute(sql`
|
|
20409
21272
|
UPDATE ${sql.raw(usersTableName)}
|
|
20410
21273
|
SET roles = ${rolesArray}::text[], updated_at = NOW()
|
|
20411
21274
|
WHERE id = ${userId}
|
|
20412
|
-
`);
|
|
21275
|
+
`));
|
|
20413
21276
|
}
|
|
20414
21277
|
/**
|
|
20415
21278
|
* Assign a specific role to new user (appends if not present)
|
|
20416
21279
|
*/
|
|
20417
21280
|
async assignDefaultRole(userId, roleId) {
|
|
20418
21281
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
20419
|
-
await this.db.execute(sql`
|
|
21282
|
+
await this.withServerContext(async (db) => db.execute(sql`
|
|
20420
21283
|
UPDATE ${sql.raw(usersTableName)}
|
|
20421
21284
|
SET roles = array_append(roles, ${roleId}), updated_at = NOW()
|
|
20422
21285
|
WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
|
|
20423
|
-
`);
|
|
21286
|
+
`));
|
|
20424
21287
|
}
|
|
20425
21288
|
/**
|
|
20426
21289
|
* Get user with their roles
|
|
@@ -21804,21 +22667,14 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
21804
22667
|
logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
|
|
21805
22668
|
}
|
|
21806
22669
|
const activeCollections = introspectedCollections ?? collections;
|
|
21807
|
-
const registry = new PostgresCollectionRegistry();
|
|
21808
|
-
if (activeCollections) {
|
|
21809
|
-
registry.registerMultiple(activeCollections);
|
|
21810
|
-
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
21811
|
-
}
|
|
21812
22670
|
const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
|
|
21813
|
-
if (schemaTables) Object.values(schemaTables).forEach((table) => {
|
|
21814
|
-
if (isTable(table)) {
|
|
21815
|
-
const tableName = getTableName(table);
|
|
21816
|
-
registry.registerTable(table, tableName);
|
|
21817
|
-
}
|
|
21818
|
-
});
|
|
21819
|
-
if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
|
|
21820
22671
|
const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
|
|
21821
|
-
|
|
22672
|
+
const registry = buildCollectionRegistry({
|
|
22673
|
+
collections: activeCollections,
|
|
22674
|
+
tables: schemaTables,
|
|
22675
|
+
enums: pgConfig.schema?.enums,
|
|
22676
|
+
relations: schemaRelations
|
|
22677
|
+
});
|
|
21822
22678
|
if (schemaTables) patchPgArrayNullSafety(schemaTables);
|
|
21823
22679
|
const mergedSchema = {
|
|
21824
22680
|
...schemaTables,
|
|
@@ -21897,6 +22753,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
21897
22753
|
const wantsCdc = cdcMode !== "off";
|
|
21898
22754
|
const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
|
|
21899
22755
|
let cdcEnabled = false;
|
|
22756
|
+
let provisionCdcForTables;
|
|
21900
22757
|
if (wantsCdc && !directUrl) {
|
|
21901
22758
|
const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
|
|
21902
22759
|
if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
|
|
@@ -21913,6 +22770,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
21913
22770
|
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
|
|
21914
22771
|
await realtimeService.enableCdc(directUrl);
|
|
21915
22772
|
cdcEnabled = true;
|
|
22773
|
+
provisionCdcForTables = async (tables) => {
|
|
22774
|
+
await provisionTriggerCdc(cdcRunSql, tables);
|
|
22775
|
+
};
|
|
21916
22776
|
logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
|
|
21917
22777
|
} catch (err) {
|
|
21918
22778
|
if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
|
|
@@ -21937,13 +22797,14 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
21937
22797
|
const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
|
|
21938
22798
|
const missing = [];
|
|
21939
22799
|
for (const col of registeredCollections) {
|
|
22800
|
+
if (col.auth?.enabled) continue;
|
|
21940
22801
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
21941
22802
|
const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
|
|
21942
22803
|
const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
|
|
21943
22804
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
21944
22805
|
if (!dbTables.has(fullCheckName)) missing.push({
|
|
21945
22806
|
slug: col.slug,
|
|
21946
|
-
table:
|
|
22807
|
+
table: fullCheckName
|
|
21947
22808
|
});
|
|
21948
22809
|
}
|
|
21949
22810
|
if (missing.length > 0) {
|
|
@@ -21977,7 +22838,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
21977
22838
|
registry,
|
|
21978
22839
|
realtimeService,
|
|
21979
22840
|
driver,
|
|
21980
|
-
poolManager
|
|
22841
|
+
poolManager,
|
|
22842
|
+
provisionCdcForTables
|
|
21981
22843
|
}
|
|
21982
22844
|
};
|
|
21983
22845
|
},
|
|
@@ -21989,6 +22851,18 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
21989
22851
|
const registry = internals.registry;
|
|
21990
22852
|
const authCollection = authConfig.collection;
|
|
21991
22853
|
await ensureAuthTablesExist(db, authCollection);
|
|
22854
|
+
if (authCollection && internals.provisionCdcForTables) {
|
|
22855
|
+
const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
|
|
22856
|
+
const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
|
|
22857
|
+
if (authTable) try {
|
|
22858
|
+
await internals.provisionCdcForTables([{
|
|
22859
|
+
schema: authSchema,
|
|
22860
|
+
table: authTable
|
|
22861
|
+
}]);
|
|
22862
|
+
} catch (err) {
|
|
22863
|
+
logger.warn(`⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — writes to it won't emit database-level events.`, { detail: err instanceof Error ? err.message : String(err) });
|
|
22864
|
+
}
|
|
22865
|
+
}
|
|
21992
22866
|
let emailService;
|
|
21993
22867
|
if (authConfig.email) emailService = createEmailService(authConfig.email);
|
|
21994
22868
|
const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
|
|
@@ -22059,6 +22933,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
22059
22933
|
};
|
|
22060
22934
|
}
|
|
22061
22935
|
//#endregion
|
|
22062
|
-
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
|
|
22936
|
+
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
|
|
22063
22937
|
|
|
22064
22938
|
//# sourceMappingURL=index.es.js.map
|