@rebasepro/server-postgres 0.9.1-canary.682a9cf → 0.9.1-canary.73476f2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +2 -25
- package/dist/PostgresBootstrapper.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -16
- package/dist/connection.d.ts +0 -21
- package/dist/data-transformer.d.ts +2 -9
- package/dist/index.es.js +561 -1454
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/services/FetchService.d.ts +24 -4
- package/dist/services/PersistService.d.ts +1 -27
- package/dist/services/RelationService.d.ts +1 -34
- package/dist/services/collection-helpers.d.ts +14 -79
- package/dist/services/dataService.d.ts +1 -3
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +0 -7
- package/package.json +9 -11
- package/src/PostgresBackendDriver.ts +13 -127
- package/src/PostgresBootstrapper.ts +25 -62
- package/src/auth/ensure-tables.ts +11 -73
- package/src/auth/services.ts +19 -49
- package/src/connection.ts +1 -61
- package/src/data-transformer.ts +9 -11
- package/src/databasePoolManager.ts +0 -2
- package/src/schema/auth-default-policies.ts +132 -0
- package/src/schema/doctor.ts +20 -45
- package/src/schema/generate-drizzle-schema-logic.ts +29 -24
- package/src/schema/generate-postgres-ddl-logic.ts +28 -76
- package/src/schema/introspect-db.ts +2 -19
- package/src/services/BranchService.ts +10 -42
- package/src/services/FetchService.ts +270 -65
- package/src/services/PersistService.ts +14 -130
- package/src/services/RelationService.ts +94 -153
- package/src/services/collection-helpers.ts +47 -164
- package/src/services/dataService.ts +2 -3
- package/src/services/index.ts +0 -1
- package/src/services/realtimeService.ts +19 -40
- package/src/utils/drizzle-conditions.ts +0 -13
- package/src/websocket.ts +1 -4
- package/dist/collections/buildRegistry.d.ts +0 -27
- package/dist/services/row-pipeline.d.ts +0 -63
- package/src/collections/buildRegistry.ts +0 -59
- package/src/services/row-pipeline.ts +0 -239
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 {
|
|
6
|
+
import { createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
|
|
7
7
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
8
8
|
import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
9
|
+
import { createHash, randomUUID } from "crypto";
|
|
9
10
|
import fs, { promises } from "fs";
|
|
10
11
|
import path from "path";
|
|
11
12
|
import chokidar from "chokidar";
|
|
12
13
|
import { WebSocket, WebSocketServer } from "ws";
|
|
13
14
|
import { EventEmitter } from "events";
|
|
14
|
-
import { randomUUID } from "crypto";
|
|
15
15
|
import { inspect } from "util";
|
|
16
16
|
import os from "os";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -71,51 +71,16 @@ 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
|
|
75
|
-
guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
|
|
74
|
+
createReadReplicaConnection: () => createReadReplicaConnection
|
|
76
75
|
});
|
|
77
76
|
var DEFAULT_POOL = {
|
|
78
77
|
max: 20,
|
|
79
78
|
idleTimeoutMillis: 3e4,
|
|
80
79
|
connectionTimeoutMillis: 1e4,
|
|
81
|
-
queryTimeout:
|
|
80
|
+
queryTimeout: 3e4,
|
|
82
81
|
statementTimeout: 3e4,
|
|
83
82
|
keepAlive: true
|
|
84
83
|
};
|
|
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
|
-
}
|
|
119
84
|
/**
|
|
120
85
|
* Create a Drizzle-backed Postgres connection with a production-grade
|
|
121
86
|
* connection pool.
|
|
@@ -147,7 +112,6 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
|
|
|
147
112
|
logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
|
|
148
113
|
if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
|
|
149
114
|
});
|
|
150
|
-
guardPoolAgainstDirtyRelease(pool, "pg-pool");
|
|
151
115
|
return {
|
|
152
116
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
153
117
|
pool,
|
|
@@ -180,7 +144,6 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
|
|
|
180
144
|
pool.on("error", (err) => {
|
|
181
145
|
logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
|
|
182
146
|
});
|
|
183
|
-
guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
|
|
184
147
|
return {
|
|
185
148
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
186
149
|
pool,
|
|
@@ -210,7 +173,6 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
|
|
|
210
173
|
pool.on("error", (err) => {
|
|
211
174
|
logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
|
|
212
175
|
});
|
|
213
|
-
guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
|
|
214
176
|
return {
|
|
215
177
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
216
178
|
pool,
|
|
@@ -1540,140 +1502,6 @@ function removeFunctions(o) {
|
|
|
1540
1502
|
return o;
|
|
1541
1503
|
}
|
|
1542
1504
|
//#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
|
|
1677
1505
|
//#region ../utils/src/names.ts
|
|
1678
1506
|
/**
|
|
1679
1507
|
* Generates a foreign key column name from a given string, typically a collection slug or name.
|
|
@@ -1798,109 +1626,6 @@ function createRelationRefWithData(id, path, data) {
|
|
|
1798
1626
|
data
|
|
1799
1627
|
};
|
|
1800
1628
|
}
|
|
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
|
-
}
|
|
1904
1629
|
//#endregion
|
|
1905
1630
|
//#region ../common/src/util/enums.ts
|
|
1906
1631
|
function enumToObjectEntries(enumValues) {
|
|
@@ -2135,109 +1860,15 @@ function getSubcollections(collection) {
|
|
|
2135
1860
|
* - `field = 'literal'`
|
|
2136
1861
|
* - `field != 'literal'`
|
|
2137
1862
|
* - `field = current_setting('app.user_id')`
|
|
2138
|
-
* - `A AND B
|
|
1863
|
+
* - `A AND B`
|
|
2139
1864
|
* - `true`
|
|
2140
1865
|
* - `IN (...)` (as optimistic true)
|
|
2141
1866
|
*
|
|
2142
1867
|
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
2143
1868
|
* 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.
|
|
2149
1869
|
*/
|
|
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
|
-
}
|
|
2239
1870
|
function sqlToPolicy(sql) {
|
|
2240
|
-
const trimmed =
|
|
1871
|
+
const trimmed = sql.trim();
|
|
2241
1872
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
2242
1873
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
2243
1874
|
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
@@ -2250,10 +1881,14 @@ function sqlToPolicy(sql) {
|
|
|
2250
1881
|
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
2251
1882
|
return policy.rolesContain(roles);
|
|
2252
1883
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
1884
|
+
if (trimmed.toUpperCase().includes(" OR ")) {
|
|
1885
|
+
const parts = trimmed.split(/ OR /i);
|
|
1886
|
+
return policy.or(...parts.map(sqlToPolicy));
|
|
1887
|
+
}
|
|
1888
|
+
if (trimmed.toUpperCase().includes(" AND ")) {
|
|
1889
|
+
const parts = trimmed.split(/ AND /i);
|
|
1890
|
+
return policy.and(...parts.map(sqlToPolicy));
|
|
1891
|
+
}
|
|
2257
1892
|
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
2258
1893
|
if (match) {
|
|
2259
1894
|
const [, leftStr, op, rightStr] = match;
|
|
@@ -2559,307 +2194,6 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
2559
2194
|
};
|
|
2560
2195
|
return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
|
|
2561
2196
|
};
|
|
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
|
-
}
|
|
2863
2197
|
(/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
2864
2198
|
(function(root, factory) {
|
|
2865
2199
|
if (typeof define === "function" && define.amd) define(factory);
|
|
@@ -4129,45 +3463,18 @@ function deserializeFilter(query) {
|
|
|
4129
3463
|
}
|
|
4130
3464
|
//#endregion
|
|
4131
3465
|
//#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
|
-
}
|
|
4152
3466
|
/**
|
|
4153
|
-
*
|
|
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.
|
|
3467
|
+
* Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
|
|
3468
|
+
* Mirrors the client SDK's rowToEntity conversion.
|
|
4162
3469
|
*/
|
|
4163
|
-
function rowToEntity(row, slug
|
|
3470
|
+
function rowToEntity(row, slug) {
|
|
4164
3471
|
return {
|
|
4165
|
-
id:
|
|
3472
|
+
id: row.id,
|
|
4166
3473
|
path: slug,
|
|
4167
3474
|
values: row
|
|
4168
3475
|
};
|
|
4169
3476
|
}
|
|
4170
|
-
function createDriverAccessor(driver, slug
|
|
3477
|
+
function createDriverAccessor(driver, slug) {
|
|
4171
3478
|
const accessor = {
|
|
4172
3479
|
async find(params) {
|
|
4173
3480
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
@@ -4200,7 +3507,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4200
3507
|
hasMore = offset + rows.length < total;
|
|
4201
3508
|
}
|
|
4202
3509
|
return {
|
|
4203
|
-
data: rows.map((row) => rowToEntity(row, slug
|
|
3510
|
+
data: rows.map((row) => rowToEntity(row, slug)),
|
|
4204
3511
|
meta: {
|
|
4205
3512
|
total,
|
|
4206
3513
|
limit,
|
|
@@ -4214,7 +3521,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4214
3521
|
path: slug,
|
|
4215
3522
|
id
|
|
4216
3523
|
});
|
|
4217
|
-
return row ? rowToEntity(row, slug
|
|
3524
|
+
return row ? rowToEntity(row, slug) : void 0;
|
|
4218
3525
|
},
|
|
4219
3526
|
async create(data, id) {
|
|
4220
3527
|
return rowToEntity(await driver.save({
|
|
@@ -4222,22 +3529,15 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4222
3529
|
values: data,
|
|
4223
3530
|
id,
|
|
4224
3531
|
status: "new"
|
|
4225
|
-
}), slug
|
|
3532
|
+
}), slug);
|
|
4226
3533
|
},
|
|
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,
|
|
4234
3534
|
async update(id, data) {
|
|
4235
3535
|
return rowToEntity(await driver.save({
|
|
4236
3536
|
path: slug,
|
|
4237
3537
|
values: data,
|
|
4238
3538
|
id,
|
|
4239
3539
|
status: "existing"
|
|
4240
|
-
}), slug
|
|
3540
|
+
}), slug);
|
|
4241
3541
|
},
|
|
4242
3542
|
async delete(id) {
|
|
4243
3543
|
return driver.delete({ row: {
|
|
@@ -4266,7 +3566,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4266
3566
|
searchString: params?.searchString,
|
|
4267
3567
|
onUpdate: (entities) => {
|
|
4268
3568
|
onUpdate({
|
|
4269
|
-
data: entities.map((row) => rowToEntity(row, slug
|
|
3569
|
+
data: entities.map((row) => rowToEntity(row, slug)),
|
|
4270
3570
|
meta: {
|
|
4271
3571
|
total: entities.length,
|
|
4272
3572
|
limit,
|
|
@@ -4282,7 +3582,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4282
3582
|
return driver.listenOne({
|
|
4283
3583
|
path: slug,
|
|
4284
3584
|
id,
|
|
4285
|
-
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug
|
|
3585
|
+
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
|
|
4286
3586
|
onError
|
|
4287
3587
|
});
|
|
4288
3588
|
} : void 0,
|
|
@@ -4321,13 +3621,12 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4321
3621
|
* await data.products.create({ name: "Camera", price: 299 });
|
|
4322
3622
|
* const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
|
|
4323
3623
|
*/
|
|
4324
|
-
function buildRebaseData(driver
|
|
3624
|
+
function buildRebaseData(driver) {
|
|
4325
3625
|
const cache = /* @__PURE__ */ new Map();
|
|
4326
|
-
const primaryKeysFor = createPrimaryKeyResolver(options);
|
|
4327
3626
|
function getAccessor(slug) {
|
|
4328
3627
|
let accessor = cache.get(slug);
|
|
4329
3628
|
if (!accessor) {
|
|
4330
|
-
accessor = createDriverAccessor(driver, slug
|
|
3629
|
+
accessor = createDriverAccessor(driver, slug);
|
|
4331
3630
|
cache.set(slug, accessor);
|
|
4332
3631
|
}
|
|
4333
3632
|
return accessor;
|
|
@@ -4340,9 +3639,8 @@ function buildRebaseData(driver, options) {
|
|
|
4340
3639
|
} });
|
|
4341
3640
|
}
|
|
4342
3641
|
/**
|
|
4343
|
-
* Unwrap a Entity
|
|
4344
|
-
*
|
|
4345
|
-
* the wrapper is the whole operation — the address was never part of the row.
|
|
3642
|
+
* Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
|
|
3643
|
+
* (id included) under `.values`, so this is just that payload.
|
|
4346
3644
|
*/
|
|
4347
3645
|
function entityToRow(entity) {
|
|
4348
3646
|
return entity.values;
|
|
@@ -4429,12 +3727,6 @@ function toSdkCollectionClient(snap) {
|
|
|
4429
3727
|
async create(data, id) {
|
|
4430
3728
|
return entityToRow(await snap.create(data, id));
|
|
4431
3729
|
},
|
|
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
|
-
},
|
|
4438
3730
|
async update(id, data) {
|
|
4439
3731
|
return entityToRow(await snap.update(id, data));
|
|
4440
3732
|
},
|
|
@@ -4587,26 +3879,8 @@ function getTableForCollection(collection, registry) {
|
|
|
4587
3879
|
if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
|
|
4588
3880
|
return table;
|
|
4589
3881
|
}
|
|
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
|
-
*/
|
|
4609
3882
|
function getPrimaryKeys(collection, registry) {
|
|
3883
|
+
const table = getTableForCollection(collection, registry);
|
|
4610
3884
|
if (collection.properties) {
|
|
4611
3885
|
const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
|
|
4612
3886
|
fieldName: key,
|
|
@@ -4615,8 +3889,6 @@ function getPrimaryKeys(collection, registry) {
|
|
|
4615
3889
|
}));
|
|
4616
3890
|
if (idProps.length > 0) return idProps;
|
|
4617
3891
|
}
|
|
4618
|
-
const table = registry.getTable(getTableName$1(collection));
|
|
4619
|
-
if (!table) return [];
|
|
4620
3892
|
const keys = [];
|
|
4621
3893
|
for (const [key, colRaw] of Object.entries(table)) {
|
|
4622
3894
|
const col = colRaw;
|
|
@@ -4644,89 +3916,35 @@ function getPrimaryKeys(collection, registry) {
|
|
|
4644
3916
|
}
|
|
4645
3917
|
return keys;
|
|
4646
3918
|
}
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
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
|
-
});
|
|
3919
|
+
function parseIdValues(idValue, primaryKeys) {
|
|
3920
|
+
const result = {};
|
|
3921
|
+
if (primaryKeys.length === 0) return result;
|
|
3922
|
+
if (primaryKeys.length === 1) {
|
|
3923
|
+
const pk = primaryKeys[0];
|
|
3924
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
3925
|
+
const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
|
|
3926
|
+
if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
3927
|
+
result[pk.fieldName] = parsed;
|
|
3928
|
+
} else result[pk.fieldName] = String(idValue);
|
|
3929
|
+
return result;
|
|
4697
3930
|
}
|
|
4698
|
-
|
|
4699
|
-
}
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
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");
|
|
3931
|
+
const parts = String(idValue).split(":::");
|
|
3932
|
+
if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
|
|
3933
|
+
for (let i = 0; i < primaryKeys.length; i++) {
|
|
3934
|
+
const pk = primaryKeys[i];
|
|
3935
|
+
const val = parts[i];
|
|
3936
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
3937
|
+
const parsed = parseInt(val, 10);
|
|
3938
|
+
if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
|
|
3939
|
+
result[pk.fieldName] = parsed;
|
|
3940
|
+
} else result[pk.fieldName] = val;
|
|
3941
|
+
}
|
|
3942
|
+
return result;
|
|
4715
3943
|
}
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
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 "";
|
|
3944
|
+
function buildCompositeId(values, primaryKeys) {
|
|
3945
|
+
if (primaryKeys.length === 0) return "";
|
|
3946
|
+
if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
|
|
3947
|
+
return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
|
|
4730
3948
|
}
|
|
4731
3949
|
//#endregion
|
|
4732
3950
|
//#region src/utils/drizzle-conditions.ts
|
|
@@ -5266,7 +4484,6 @@ var DrizzleConditionBuilder = class {
|
|
|
5266
4484
|
static buildVectorSearchConditions(table, vectorSearch) {
|
|
5267
4485
|
const column = table[vectorSearch.property];
|
|
5268
4486
|
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");
|
|
5270
4487
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
5271
4488
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
5272
4489
|
let operator;
|
|
@@ -5350,10 +4567,12 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
5350
4567
|
continue;
|
|
5351
4568
|
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
5352
4569
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
4570
|
+
const pks = getPrimaryKeys(collection, registry);
|
|
5353
4571
|
inverseRelationUpdates.push({
|
|
5354
4572
|
relationKey: key,
|
|
5355
4573
|
relation,
|
|
5356
|
-
newValue: serializedValue
|
|
4574
|
+
newValue: serializedValue,
|
|
4575
|
+
currentId: row.id || buildCompositeId(row, pks)
|
|
5357
4576
|
});
|
|
5358
4577
|
continue;
|
|
5359
4578
|
} else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
@@ -5363,11 +4582,15 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
5363
4582
|
relation,
|
|
5364
4583
|
newTargetId: serializedValue
|
|
5365
4584
|
});
|
|
5366
|
-
else
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
4585
|
+
else {
|
|
4586
|
+
const pks = getPrimaryKeys(collection, registry);
|
|
4587
|
+
inverseRelationUpdates.push({
|
|
4588
|
+
relationKey: key,
|
|
4589
|
+
relation,
|
|
4590
|
+
newValue: serializedValue,
|
|
4591
|
+
currentId: row.id || buildCompositeId(row, pks)
|
|
4592
|
+
});
|
|
4593
|
+
}
|
|
5371
4594
|
continue;
|
|
5372
4595
|
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
5373
4596
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
@@ -5767,63 +4990,6 @@ var RelationService = class {
|
|
|
5767
4990
|
this.registry = registry;
|
|
5768
4991
|
}
|
|
5769
4992
|
/**
|
|
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
|
-
/**
|
|
5827
4993
|
* Fetch rows related to a parent row through a specific relation
|
|
5828
4994
|
*/
|
|
5829
4995
|
async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
|
|
@@ -5842,9 +5008,9 @@ var RelationService = class {
|
|
|
5842
5008
|
async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
|
|
5843
5009
|
const targetCollection = relation.target();
|
|
5844
5010
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5845
|
-
const idInfo =
|
|
5011
|
+
const idInfo = getPrimaryKeys(targetCollection, this.registry);
|
|
5846
5012
|
const idField = targetTable[idInfo[0].fieldName];
|
|
5847
|
-
const parentPks =
|
|
5013
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
5848
5014
|
const parentIdInfo = parentPks[0];
|
|
5849
5015
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5850
5016
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5868,7 +5034,7 @@ var RelationService = class {
|
|
|
5868
5034
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5869
5035
|
currentTable = joinTable;
|
|
5870
5036
|
}
|
|
5871
|
-
const parentIdField = parentTable[
|
|
5037
|
+
const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
|
|
5872
5038
|
query = query.where(eq(parentIdField, parsedParentId));
|
|
5873
5039
|
if (options.limit) query = query.limit(options.limit);
|
|
5874
5040
|
const results = await query;
|
|
@@ -5876,7 +5042,13 @@ var RelationService = class {
|
|
|
5876
5042
|
const rows = [];
|
|
5877
5043
|
for (const row of results) {
|
|
5878
5044
|
const targetRow = row[targetTableName] || row;
|
|
5879
|
-
|
|
5045
|
+
const id = targetRow[idInfo[0].fieldName];
|
|
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
|
+
});
|
|
5880
5052
|
}
|
|
5881
5053
|
return rows;
|
|
5882
5054
|
}
|
|
@@ -5895,7 +5067,13 @@ var RelationService = class {
|
|
|
5895
5067
|
const rows = [];
|
|
5896
5068
|
for (const row of results) {
|
|
5897
5069
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
5898
|
-
|
|
5070
|
+
const id = targetRow[idInfo[0].fieldName];
|
|
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
|
+
});
|
|
5899
5077
|
}
|
|
5900
5078
|
return rows;
|
|
5901
5079
|
}
|
|
@@ -5912,8 +5090,8 @@ var RelationService = class {
|
|
|
5912
5090
|
}
|
|
5913
5091
|
const targetCollection = relation.target();
|
|
5914
5092
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5915
|
-
const targetIdField = targetTable[
|
|
5916
|
-
const parentPks =
|
|
5093
|
+
const targetIdField = targetTable[getPrimaryKeys(targetCollection, this.registry)[0].fieldName];
|
|
5094
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
5917
5095
|
const parentIdInfo = parentPks[0];
|
|
5918
5096
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5919
5097
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5932,10 +5110,9 @@ var RelationService = class {
|
|
|
5932
5110
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5933
5111
|
const targetCollection = relation.target();
|
|
5934
5112
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5935
|
-
const
|
|
5936
|
-
const targetIdInfo = targetPks[0];
|
|
5113
|
+
const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
|
|
5937
5114
|
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
5938
|
-
const parentPks =
|
|
5115
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
5939
5116
|
const parentIdInfo = parentPks[0];
|
|
5940
5117
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
5941
5118
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -5959,19 +5136,25 @@ var RelationService = class {
|
|
|
5959
5136
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5960
5137
|
currentTable = joinTable;
|
|
5961
5138
|
}
|
|
5962
|
-
|
|
5139
|
+
const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
|
|
5140
|
+
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
5963
5141
|
const results = await query;
|
|
5964
5142
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
5965
5143
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5966
5144
|
for (const row of results) {
|
|
5967
5145
|
const parentRow = row[getTableName$1(parentCollection)] || row;
|
|
5968
5146
|
const targetRow = row[targetTableName] || row;
|
|
5969
|
-
|
|
5147
|
+
const parentId = parentRow[parentIdInfo.fieldName];
|
|
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
|
+
});
|
|
5970
5154
|
}
|
|
5971
5155
|
return resultMap;
|
|
5972
5156
|
}
|
|
5973
5157
|
if (relation.direction === "owning" && relation.localKey) {
|
|
5974
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
5975
5158
|
const localKeyCol = parentTable[relation.localKey];
|
|
5976
5159
|
if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
5977
5160
|
const fkRows = await this.db.select({
|
|
@@ -6000,11 +5183,17 @@ var RelationService = class {
|
|
|
6000
5183
|
const resultMap = /* @__PURE__ */ new Map();
|
|
6001
5184
|
for (const [parentIdStr, fkValue] of parentToFk) {
|
|
6002
5185
|
const targetRow = targetById.get(String(fkValue));
|
|
6003
|
-
if (targetRow)
|
|
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
|
+
}
|
|
6004
5194
|
}
|
|
6005
5195
|
return resultMap;
|
|
6006
5196
|
}
|
|
6007
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
|
|
6008
5197
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
6009
5198
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
6010
5199
|
const results = await query;
|
|
@@ -6015,7 +5204,14 @@ var RelationService = class {
|
|
|
6015
5204
|
let parentId;
|
|
6016
5205
|
if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
6017
5206
|
else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
6018
|
-
if (parentId !== void 0 && parentIdSet.has(String(parentId)))
|
|
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
|
+
}
|
|
6019
5215
|
}
|
|
6020
5216
|
return resultMap;
|
|
6021
5217
|
}
|
|
@@ -6029,9 +5225,9 @@ var RelationService = class {
|
|
|
6029
5225
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
6030
5226
|
const targetCollection = relation.target();
|
|
6031
5227
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
6032
|
-
const
|
|
6033
|
-
const targetIdField = targetTable[
|
|
6034
|
-
const parentPks =
|
|
5228
|
+
const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
|
|
5229
|
+
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
5230
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
6035
5231
|
const parentIdInfo = parentPks[0];
|
|
6036
5232
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
6037
5233
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -6053,22 +5249,27 @@ var RelationService = class {
|
|
|
6053
5249
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
6054
5250
|
currentTable = joinTable;
|
|
6055
5251
|
}
|
|
6056
|
-
|
|
5252
|
+
const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
|
|
5253
|
+
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
6057
5254
|
const results = await query;
|
|
6058
5255
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
6059
5256
|
const resultMap = /* @__PURE__ */ new Map();
|
|
6060
5257
|
for (const row of results) {
|
|
6061
5258
|
const parentRow = row[getTableName$1(parentCollection)] || row;
|
|
6062
5259
|
const targetRow = row[targetTableName] || row;
|
|
6063
|
-
const parentId =
|
|
5260
|
+
const parentId = String(parentRow[parentIdInfo.fieldName]);
|
|
5261
|
+
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
6064
5262
|
const arr = resultMap.get(parentId) || [];
|
|
6065
|
-
arr.push(
|
|
5263
|
+
arr.push({
|
|
5264
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5265
|
+
path: targetCollection.slug,
|
|
5266
|
+
values: parsedValues
|
|
5267
|
+
});
|
|
6066
5268
|
resultMap.set(parentId, arr);
|
|
6067
5269
|
}
|
|
6068
5270
|
return resultMap;
|
|
6069
5271
|
}
|
|
6070
5272
|
if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
|
|
6071
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
6072
5273
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
6073
5274
|
if (!junctionTable) {
|
|
6074
5275
|
logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
@@ -6087,13 +5288,17 @@ var RelationService = class {
|
|
|
6087
5288
|
const junctionData = row[relation.through.table] || row;
|
|
6088
5289
|
const targetData = row[targetTableName] || row;
|
|
6089
5290
|
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
5291
|
+
const parsedValues = await parseDataFromServer(targetData, targetCollection);
|
|
6090
5292
|
const arr = resultMap.get(parentId) || [];
|
|
6091
|
-
arr.push(
|
|
5293
|
+
arr.push({
|
|
5294
|
+
id: String(targetData[targetIdInfo.fieldName]),
|
|
5295
|
+
path: targetCollection.slug,
|
|
5296
|
+
values: parsedValues
|
|
5297
|
+
});
|
|
6092
5298
|
resultMap.set(parentId, arr);
|
|
6093
5299
|
}
|
|
6094
5300
|
return resultMap;
|
|
6095
5301
|
}
|
|
6096
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
|
|
6097
5302
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
6098
5303
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
6099
5304
|
const results = await query;
|
|
@@ -6106,9 +5311,14 @@ var RelationService = class {
|
|
|
6106
5311
|
else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
6107
5312
|
else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
6108
5313
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
|
|
5314
|
+
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
6109
5315
|
const key = String(parentId);
|
|
6110
5316
|
const arr = resultMap.get(key) || [];
|
|
6111
|
-
arr.push(
|
|
5317
|
+
arr.push({
|
|
5318
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5319
|
+
path: targetCollection.slug,
|
|
5320
|
+
values: parsedValues
|
|
5321
|
+
});
|
|
6112
5322
|
resultMap.set(key, arr);
|
|
6113
5323
|
}
|
|
6114
5324
|
}
|
|
@@ -6156,12 +5366,12 @@ var RelationService = class {
|
|
|
6156
5366
|
logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
|
|
6157
5367
|
continue;
|
|
6158
5368
|
}
|
|
6159
|
-
const parentPks =
|
|
5369
|
+
const parentPks = getPrimaryKeys(collection, this.registry);
|
|
6160
5370
|
const parentIdInfo = parentPks[0];
|
|
6161
5371
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
6162
5372
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
6163
5373
|
if (targetEntityIds.length > 0) {
|
|
6164
|
-
const targetPks =
|
|
5374
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6165
5375
|
const targetIdInfo = targetPks[0];
|
|
6166
5376
|
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
6167
5377
|
[sourceJunctionColumn.name]: parsedParentId,
|
|
@@ -6181,12 +5391,12 @@ var RelationService = class {
|
|
|
6181
5391
|
logger.warn(`Junction columns not found for relation '${key}'`);
|
|
6182
5392
|
continue;
|
|
6183
5393
|
}
|
|
6184
|
-
const parentPks =
|
|
5394
|
+
const parentPks = getPrimaryKeys(collection, this.registry);
|
|
6185
5395
|
const parentIdInfo = parentPks[0];
|
|
6186
5396
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
6187
5397
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
6188
5398
|
if (targetEntityIds.length > 0) {
|
|
6189
|
-
const targetPks =
|
|
5399
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6190
5400
|
const targetIdInfo = targetPks[0];
|
|
6191
5401
|
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
6192
5402
|
[sourceJunctionColumn.name]: parsedParentId,
|
|
@@ -6197,7 +5407,7 @@ var RelationService = class {
|
|
|
6197
5407
|
} 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.`);
|
|
6198
5408
|
else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
6199
5409
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
6200
|
-
const targetPks =
|
|
5410
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6201
5411
|
const targetIdInfo = targetPks[0];
|
|
6202
5412
|
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
6203
5413
|
const fkCol = targetTable[relation.foreignKeyOnTarget];
|
|
@@ -6205,7 +5415,7 @@ var RelationService = class {
|
|
|
6205
5415
|
logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
6206
5416
|
continue;
|
|
6207
5417
|
}
|
|
6208
|
-
const parentPks =
|
|
5418
|
+
const parentPks = getPrimaryKeys(collection, this.registry);
|
|
6209
5419
|
const parentIdInfo = parentPks[0];
|
|
6210
5420
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
6211
5421
|
if (targetEntityIds.length > 0) {
|
|
@@ -6225,9 +5435,9 @@ var RelationService = class {
|
|
|
6225
5435
|
try {
|
|
6226
5436
|
const targetCollection = relation.target();
|
|
6227
5437
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
6228
|
-
const targetPks =
|
|
5438
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6229
5439
|
const targetIdInfo = targetPks[0];
|
|
6230
|
-
const sourcePks =
|
|
5440
|
+
const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
|
|
6231
5441
|
const sourceIdInfo = sourcePks[0];
|
|
6232
5442
|
if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
6233
5443
|
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
@@ -6308,12 +5518,12 @@ var RelationService = class {
|
|
|
6308
5518
|
logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
|
|
6309
5519
|
return;
|
|
6310
5520
|
}
|
|
6311
|
-
const sourcePks =
|
|
5521
|
+
const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
|
|
6312
5522
|
const sourceIdInfo = sourcePks[0];
|
|
6313
5523
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
6314
5524
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
6315
5525
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
6316
|
-
const targetPks =
|
|
5526
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6317
5527
|
const targetIdInfo = targetPks[0];
|
|
6318
5528
|
const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
6319
5529
|
[sourceJunctionColumn.name]: parsedSourceId,
|
|
@@ -6321,7 +5531,7 @@ var RelationService = class {
|
|
|
6321
5531
|
}));
|
|
6322
5532
|
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
6323
5533
|
} else if (newValue && !Array.isArray(newValue)) {
|
|
6324
|
-
const targetPks =
|
|
5534
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6325
5535
|
const targetIdInfo = targetPks[0];
|
|
6326
5536
|
const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
|
|
6327
5537
|
const newLink = {
|
|
@@ -6352,12 +5562,12 @@ var RelationService = class {
|
|
|
6352
5562
|
logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
|
|
6353
5563
|
return;
|
|
6354
5564
|
}
|
|
6355
|
-
const sourcePks =
|
|
5565
|
+
const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
|
|
6356
5566
|
const sourceIdInfo = sourcePks[0];
|
|
6357
5567
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
6358
5568
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
6359
5569
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
6360
|
-
const targetPks =
|
|
5570
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6361
5571
|
const targetIdInfo = targetPks[0];
|
|
6362
5572
|
const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
6363
5573
|
[sourceJunctionColumn.name]: parsedSourceId,
|
|
@@ -6378,12 +5588,12 @@ var RelationService = class {
|
|
|
6378
5588
|
const { relation, newTargetId } = upd;
|
|
6379
5589
|
const targetCollection = relation.target();
|
|
6380
5590
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
6381
|
-
const targetPks =
|
|
5591
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6382
5592
|
const targetIdInfo = targetPks[0];
|
|
6383
5593
|
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
6384
5594
|
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
6385
5595
|
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
6386
|
-
const parentPks =
|
|
5596
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
6387
5597
|
const parentIdInfo = parentPks[0];
|
|
6388
5598
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
6389
5599
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
@@ -6454,7 +5664,7 @@ var RelationService = class {
|
|
|
6454
5664
|
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
6455
5665
|
return;
|
|
6456
5666
|
}
|
|
6457
|
-
const targetPks =
|
|
5667
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
6458
5668
|
const targetIdInfo = targetPks[0];
|
|
6459
5669
|
const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
|
|
6460
5670
|
const junctionData = {
|
|
@@ -6470,150 +5680,6 @@ var RelationService = class {
|
|
|
6470
5680
|
}
|
|
6471
5681
|
};
|
|
6472
5682
|
//#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
|
-
/**
|
|
6528
|
-
* Drop every column the collection marked `excludeFromApi`.
|
|
6529
|
-
*
|
|
6530
|
-
* Password hashes and verification tokens have to be readable server-side but
|
|
6531
|
-
* must never reach a client — and "never" has to mean every exit from this
|
|
6532
|
-
* pipeline, including relation targets, or a secret leaks through whichever
|
|
6533
|
-
* path was overlooked. Keyed by both the property name and its column name,
|
|
6534
|
-
* since a row can arrive keyed either way depending on the caller.
|
|
6535
|
-
*/
|
|
6536
|
-
function stripExcluded(row, collection) {
|
|
6537
|
-
const properties = collection.properties;
|
|
6538
|
-
if (!properties) return row;
|
|
6539
|
-
for (const [key, property] of Object.entries(properties)) {
|
|
6540
|
-
if (!property?.excludeFromApi) continue;
|
|
6541
|
-
delete row[key];
|
|
6542
|
-
if (property.columnName) delete row[property.columnName];
|
|
6543
|
-
}
|
|
6544
|
-
return row;
|
|
6545
|
-
}
|
|
6546
|
-
function renderTarget(targetRow, targetCollection, style, registry) {
|
|
6547
|
-
if (style === "inline") return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
|
|
6548
|
-
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
6549
|
-
const path = targetCollection.slug;
|
|
6550
|
-
return createRelationRefWithData(address, path, {
|
|
6551
|
-
id: address,
|
|
6552
|
-
path,
|
|
6553
|
-
values: normalizeDbValues(targetRow, targetCollection)
|
|
6554
|
-
});
|
|
6555
|
-
}
|
|
6556
|
-
/**
|
|
6557
|
-
* The address a relation ref points at.
|
|
6558
|
-
*
|
|
6559
|
-
* The whole key, not its first column: a composite-keyed target addressed by
|
|
6560
|
-
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
6561
|
-
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
6562
|
-
* array — taking down the parent's fetch over a relation it may not even have
|
|
6563
|
-
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
6564
|
-
* beats no rows at all.
|
|
6565
|
-
*/
|
|
6566
|
-
function relationTargetAddress(targetRow, targetCollection, registry) {
|
|
6567
|
-
const address = deriveRowAddress(targetRow, targetCollection, registry);
|
|
6568
|
-
if (address) return address;
|
|
6569
|
-
return String(targetRow[Object.keys(targetRow)[0]] ?? "");
|
|
6570
|
-
}
|
|
6571
|
-
/**
|
|
6572
|
-
* The row the admin renders: every column, with relations as references.
|
|
6573
|
-
*
|
|
6574
|
-
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
6575
|
-
* expects real types. The row's own address is *not* among the columns — it is
|
|
6576
|
-
* derived by the consumer from the collection's primary keys.
|
|
6577
|
-
*/
|
|
6578
|
-
function toCmsRow(row, collection, registry) {
|
|
6579
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6580
|
-
const normalized = normalizeDbValues(row, collection);
|
|
6581
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
6582
|
-
const relData = row[relation.relationName || key];
|
|
6583
|
-
if (relData === void 0 || relData === null) continue;
|
|
6584
|
-
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
6585
|
-
const targetCollection = relation.target();
|
|
6586
|
-
normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
|
|
6587
|
-
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
|
|
6588
|
-
}
|
|
6589
|
-
return stripExcluded(normalized, collection);
|
|
6590
|
-
}
|
|
6591
|
-
/**
|
|
6592
|
-
* The row REST serves: every column under its own name, with the value Postgres
|
|
6593
|
-
* returned, and relations inlined as the target's columns.
|
|
6594
|
-
*
|
|
6595
|
-
* Values are the ones the database returned, except where that contradicts the
|
|
6596
|
-
* declared type: a `number` property is served as a number (see
|
|
6597
|
-
* {@link coerceDeclaredNumber}). Dates stay as the database returned them —
|
|
6598
|
-
* JSON has its own opinions about dates that the admin's view-model does not
|
|
6599
|
-
* share.
|
|
6600
|
-
*
|
|
6601
|
-
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
6602
|
-
* the relations `include` asked for, so the row is the authority on which are
|
|
6603
|
-
* actually there.
|
|
6604
|
-
*/
|
|
6605
|
-
function toRestRow(row, collection, registry) {
|
|
6606
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6607
|
-
const flat = {};
|
|
6608
|
-
for (const [key, value] of Object.entries(row)) {
|
|
6609
|
-
const relation = findRelation(resolvedRelations, key);
|
|
6610
|
-
if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
|
|
6611
|
-
else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
|
|
6612
|
-
else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
|
|
6613
|
-
}
|
|
6614
|
-
return stripExcluded(flat, collection);
|
|
6615
|
-
}
|
|
6616
|
-
//#endregion
|
|
6617
5683
|
//#region src/services/FetchService.ts
|
|
6618
5684
|
/**
|
|
6619
5685
|
* Service for handling all row read operations.
|
|
@@ -6672,7 +5738,7 @@ var FetchService = class {
|
|
|
6672
5738
|
if (!shouldInclude(key)) continue;
|
|
6673
5739
|
const drizzleRelName = relation.relationName || key;
|
|
6674
5740
|
if (relation.joinPath && relation.joinPath.length > 0) continue;
|
|
6675
|
-
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
5741
|
+
if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
|
|
6676
5742
|
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
6677
5743
|
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
6678
5744
|
else withConfig[drizzleRelName] = true;
|
|
@@ -6681,6 +5747,14 @@ var FetchService = class {
|
|
|
6681
5747
|
return withConfig;
|
|
6682
5748
|
}
|
|
6683
5749
|
/**
|
|
5750
|
+
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
5751
|
+
*/
|
|
5752
|
+
isJunctionRelation(relation, _collection) {
|
|
5753
|
+
if (relation.through) return true;
|
|
5754
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
5755
|
+
return false;
|
|
5756
|
+
}
|
|
5757
|
+
/**
|
|
6684
5758
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
6685
5759
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
6686
5760
|
*/
|
|
@@ -6689,6 +5763,55 @@ var FetchService = class {
|
|
|
6689
5763
|
return null;
|
|
6690
5764
|
}
|
|
6691
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
|
+
};
|
|
5813
|
+
}
|
|
5814
|
+
/**
|
|
6692
5815
|
* Post-fetch joinPath relations for a single flat row.
|
|
6693
5816
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
6694
5817
|
* so they must be loaded separately after the primary query.
|
|
@@ -6709,6 +5832,31 @@ var FetchService = class {
|
|
|
6709
5832
|
await Promise.all(promises);
|
|
6710
5833
|
}
|
|
6711
5834
|
/**
|
|
5835
|
+
* Post-fetch joinPath relations for a batch of flat rows.
|
|
5836
|
+
* Uses batch fetching to avoid N+1 queries for list views.
|
|
5837
|
+
*/
|
|
5838
|
+
async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
|
|
5839
|
+
if (rows.length === 0) return;
|
|
5840
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5841
|
+
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
|
|
5842
|
+
if (joinPathRelations.length === 0) return;
|
|
5843
|
+
for (const [key, relation] of joinPathRelations) try {
|
|
5844
|
+
const rowIds = rows.map((r) => {
|
|
5845
|
+
return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
|
|
5846
|
+
});
|
|
5847
|
+
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
5848
|
+
for (const row of rows) {
|
|
5849
|
+
const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
|
|
5850
|
+
const relatedRow = resultMap.get(String(id));
|
|
5851
|
+
if (relatedRow) {
|
|
5852
|
+
if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
5853
|
+
}
|
|
5854
|
+
}
|
|
5855
|
+
} catch (e) {
|
|
5856
|
+
logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
|
|
5857
|
+
}
|
|
5858
|
+
}
|
|
5859
|
+
/**
|
|
6712
5860
|
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
6713
5861
|
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
6714
5862
|
*/
|
|
@@ -6719,29 +5867,72 @@ var FetchService = class {
|
|
|
6719
5867
|
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
6720
5868
|
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
|
|
6721
5869
|
if (joinPathRelations.length === 0) return;
|
|
6722
|
-
const
|
|
6723
|
-
const address = buildCompositeId(row, idInfoArray);
|
|
6724
|
-
return address && address.split(":::").some((part) => part !== "") ? address : void 0;
|
|
6725
|
-
};
|
|
5870
|
+
const idInfo = idInfoArray[0];
|
|
6726
5871
|
for (const [key, relation] of joinPathRelations) try {
|
|
6727
|
-
const
|
|
6728
|
-
|
|
6729
|
-
|
|
5872
|
+
const rowIds = rows.map((r) => {
|
|
5873
|
+
return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
|
|
5874
|
+
});
|
|
6730
5875
|
if (relation.cardinality === "one") {
|
|
6731
5876
|
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
6732
|
-
for (const row of
|
|
6733
|
-
const
|
|
6734
|
-
|
|
5877
|
+
for (const row of rows) {
|
|
5878
|
+
const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
|
|
5879
|
+
const relatedRow = resultMap.get(String(id));
|
|
5880
|
+
if (relatedRow) row[key] = {
|
|
5881
|
+
...relatedRow.values,
|
|
5882
|
+
id: relatedRow.id
|
|
5883
|
+
};
|
|
5884
|
+
else row[key] = null;
|
|
6735
5885
|
}
|
|
6736
5886
|
} else if (relation.cardinality === "many") {
|
|
6737
5887
|
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
|
|
6738
|
-
for (const row of
|
|
5888
|
+
for (const row of rows) {
|
|
5889
|
+
const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
|
|
5890
|
+
row[key] = (resultMap.get(String(id)) || []).map((e) => ({
|
|
5891
|
+
...e.values,
|
|
5892
|
+
id: e.id
|
|
5893
|
+
}));
|
|
5894
|
+
}
|
|
6739
5895
|
}
|
|
6740
5896
|
} catch (e) {
|
|
6741
5897
|
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
6742
5898
|
}
|
|
6743
5899
|
}
|
|
6744
5900
|
/**
|
|
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
|
+
/**
|
|
6745
5936
|
* Build db.query-compatible options from standard fetch options.
|
|
6746
5937
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
6747
5938
|
*/
|
|
@@ -6811,7 +6002,7 @@ var FetchService = class {
|
|
|
6811
6002
|
async fetchOne(collectionPath, id, databaseId) {
|
|
6812
6003
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6813
6004
|
const table = getTableForCollection(collection, this.registry);
|
|
6814
|
-
const idInfoArray =
|
|
6005
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
6815
6006
|
const idInfo = idInfoArray[0];
|
|
6816
6007
|
const idField = table[idInfo.fieldName];
|
|
6817
6008
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
@@ -6825,7 +6016,7 @@ var FetchService = class {
|
|
|
6825
6016
|
with: withConfig
|
|
6826
6017
|
});
|
|
6827
6018
|
if (!row) return void 0;
|
|
6828
|
-
const flatRow =
|
|
6019
|
+
const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
|
|
6829
6020
|
await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
6830
6021
|
return flatRow;
|
|
6831
6022
|
} catch (e) {
|
|
@@ -6867,7 +6058,7 @@ var FetchService = class {
|
|
|
6867
6058
|
async fetchRowsWithConditions(collectionPath, options = {}) {
|
|
6868
6059
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6869
6060
|
const table = getTableForCollection(collection, this.registry);
|
|
6870
|
-
const idInfoArray =
|
|
6061
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
6871
6062
|
const idInfo = idInfoArray[0];
|
|
6872
6063
|
const idField = table[idInfo.fieldName];
|
|
6873
6064
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
@@ -6877,7 +6068,7 @@ var FetchService = class {
|
|
|
6877
6068
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
6878
6069
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
6879
6070
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
|
|
6880
|
-
return (await qb.findMany(queryOpts)).map((row) =>
|
|
6071
|
+
return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray));
|
|
6881
6072
|
} catch (e) {
|
|
6882
6073
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
6883
6074
|
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
@@ -6938,10 +6129,8 @@ var FetchService = class {
|
|
|
6938
6129
|
}
|
|
6939
6130
|
/**
|
|
6940
6131
|
* Fallback path used when db.query is unavailable.
|
|
6941
|
-
*
|
|
6942
|
-
*
|
|
6943
|
-
* relations from what drizzle already nested — no query per row. This one
|
|
6944
|
-
* has no nesting to read, so it resolves relations itself, in batches.
|
|
6132
|
+
* The primary path uses drizzleResultToRow which handles relation
|
|
6133
|
+
* mapping without N+1 queries.
|
|
6945
6134
|
*
|
|
6946
6135
|
* Process raw database results into flat rows with relations.
|
|
6947
6136
|
*/
|
|
@@ -6950,7 +6139,8 @@ var FetchService = class {
|
|
|
6950
6139
|
const parsedRows = await Promise.all(results.map(async (rawRow) => {
|
|
6951
6140
|
return {
|
|
6952
6141
|
rawRow,
|
|
6953
|
-
values: await parseDataFromServer(rawRow, collection)
|
|
6142
|
+
values: await parseDataFromServer(rawRow, collection),
|
|
6143
|
+
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
|
|
6954
6144
|
};
|
|
6955
6145
|
}));
|
|
6956
6146
|
if (!skipRelations) {
|
|
@@ -6990,7 +6180,10 @@ var FetchService = class {
|
|
|
6990
6180
|
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
6991
6181
|
}
|
|
6992
6182
|
}
|
|
6993
|
-
return parsedRows.map((item) =>
|
|
6183
|
+
return parsedRows.map((item) => ({
|
|
6184
|
+
...item.values,
|
|
6185
|
+
id: item.id
|
|
6186
|
+
}));
|
|
6994
6187
|
}
|
|
6995
6188
|
/**
|
|
6996
6189
|
* Fetch a collection of rows
|
|
@@ -7021,7 +6214,10 @@ var FetchService = class {
|
|
|
7021
6214
|
const relationKey = pathSegments[i];
|
|
7022
6215
|
const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
|
|
7023
6216
|
if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
7024
|
-
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
|
|
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
|
+
}));
|
|
7025
6221
|
if (i + 1 < pathSegments.length) {
|
|
7026
6222
|
const nextEntityId = pathSegments[i + 1];
|
|
7027
6223
|
currentCollection = relation.target();
|
|
@@ -7083,7 +6279,7 @@ var FetchService = class {
|
|
|
7083
6279
|
if (value === void 0 || value === null) return true;
|
|
7084
6280
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
7085
6281
|
const table = getTableForCollection(collection, this.registry);
|
|
7086
|
-
const idInfoArray =
|
|
6282
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
7087
6283
|
const idInfo = idInfoArray[0];
|
|
7088
6284
|
const idField = table[idInfo.fieldName];
|
|
7089
6285
|
const field = table[fieldName];
|
|
@@ -7110,7 +6306,7 @@ var FetchService = class {
|
|
|
7110
6306
|
async fetchCollectionForRest(collectionPath, options = {}, include) {
|
|
7111
6307
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
7112
6308
|
const table = getTableForCollection(collection, this.registry);
|
|
7113
|
-
const idInfoArray =
|
|
6309
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
7114
6310
|
const idInfo = idInfoArray[0];
|
|
7115
6311
|
const idField = table[idInfo.fieldName];
|
|
7116
6312
|
const tableName = getTableName(table);
|
|
@@ -7118,7 +6314,7 @@ var FetchService = class {
|
|
|
7118
6314
|
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
7119
6315
|
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
7120
6316
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
|
|
7121
|
-
const restRows = (await qb.findMany(queryOpts)).map((row) =>
|
|
6317
|
+
const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray));
|
|
7122
6318
|
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
7123
6319
|
return restRows;
|
|
7124
6320
|
} catch (e) {
|
|
@@ -7129,7 +6325,10 @@ var FetchService = class {
|
|
|
7129
6325
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
7130
6326
|
}
|
|
7131
6327
|
const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
|
|
7132
|
-
if (!include || include.length === 0) return rows
|
|
6328
|
+
if (!include || include.length === 0) return rows.map((row) => ({
|
|
6329
|
+
...row,
|
|
6330
|
+
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6331
|
+
}));
|
|
7133
6332
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
7134
6333
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
7135
6334
|
const shouldInclude = (key) => include[0] === "*" || include.includes(key);
|
|
@@ -7141,7 +6340,10 @@ var FetchService = class {
|
|
|
7141
6340
|
for (const row of rows) {
|
|
7142
6341
|
const eid = row[idInfo.fieldName];
|
|
7143
6342
|
const related = batchResults.get(String(eid));
|
|
7144
|
-
if (related) row[key] = {
|
|
6343
|
+
if (related) row[key] = {
|
|
6344
|
+
...related.values,
|
|
6345
|
+
id: related.id
|
|
6346
|
+
};
|
|
7145
6347
|
}
|
|
7146
6348
|
} catch (e) {
|
|
7147
6349
|
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
@@ -7159,7 +6361,10 @@ var FetchService = class {
|
|
|
7159
6361
|
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
7160
6362
|
}
|
|
7161
6363
|
}
|
|
7162
|
-
return rows
|
|
6364
|
+
return rows.map((row) => ({
|
|
6365
|
+
...row,
|
|
6366
|
+
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6367
|
+
}));
|
|
7163
6368
|
}
|
|
7164
6369
|
/**
|
|
7165
6370
|
* Fetch a single row with optional relation includes for REST API.
|
|
@@ -7167,7 +6372,7 @@ var FetchService = class {
|
|
|
7167
6372
|
async fetchOneForRest(collectionPath, id, include, databaseId) {
|
|
7168
6373
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
7169
6374
|
const table = getTableForCollection(collection, this.registry);
|
|
7170
|
-
const idInfoArray =
|
|
6375
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
7171
6376
|
const idInfo = idInfoArray[0];
|
|
7172
6377
|
const idField = table[idInfo.fieldName];
|
|
7173
6378
|
const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
|
|
@@ -7180,7 +6385,7 @@ var FetchService = class {
|
|
|
7180
6385
|
...withConfig ? { with: withConfig } : {}
|
|
7181
6386
|
});
|
|
7182
6387
|
if (!row) return null;
|
|
7183
|
-
const restRow =
|
|
6388
|
+
const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
|
|
7184
6389
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
7185
6390
|
return restRow;
|
|
7186
6391
|
} catch (e) {
|
|
@@ -7192,7 +6397,11 @@ var FetchService = class {
|
|
|
7192
6397
|
}
|
|
7193
6398
|
const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
|
|
7194
6399
|
if (result.length === 0) return null;
|
|
7195
|
-
const
|
|
6400
|
+
const raw = result[0];
|
|
6401
|
+
const flatEntity = {
|
|
6402
|
+
...raw,
|
|
6403
|
+
id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
|
|
6404
|
+
};
|
|
7196
6405
|
if (!include || include.length === 0) return flatEntity;
|
|
7197
6406
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
7198
6407
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
@@ -7225,7 +6434,7 @@ var FetchService = class {
|
|
|
7225
6434
|
async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
|
|
7226
6435
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
7227
6436
|
const table = getTableForCollection(collection, this.registry);
|
|
7228
|
-
const idField = table[
|
|
6437
|
+
const idField = table[getPrimaryKeys(collection, this.registry)[0].fieldName];
|
|
7229
6438
|
let vectorMeta;
|
|
7230
6439
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
7231
6440
|
let query = vectorMeta ? this.db.select({
|
|
@@ -7304,15 +6513,32 @@ var FetchService = class {
|
|
|
7304
6513
|
if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
7305
6514
|
}
|
|
7306
6515
|
return (await queryTarget.findMany(queryOpts)).map((row) => {
|
|
7307
|
-
const flat = {};
|
|
7308
|
-
for (const [k, v] of Object.entries(row))
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7314
|
-
|
|
7315
|
-
|
|
6516
|
+
const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
6517
|
+
for (const [k, v] of Object.entries(row)) {
|
|
6518
|
+
if (k === idInfo.fieldName) continue;
|
|
6519
|
+
if (Array.isArray(v)) flat[k] = v.map((item) => {
|
|
6520
|
+
const keys = Object.keys(item);
|
|
6521
|
+
const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
6522
|
+
if (nestedObj && keys.length <= 3) {
|
|
6523
|
+
const nested = item[nestedObj];
|
|
6524
|
+
return {
|
|
6525
|
+
...nested,
|
|
6526
|
+
id: String(nested.id ?? nested[Object.keys(nested)[0]])
|
|
6527
|
+
};
|
|
6528
|
+
}
|
|
6529
|
+
return {
|
|
6530
|
+
...item,
|
|
6531
|
+
id: String(item.id ?? item[Object.keys(item)[0]])
|
|
6532
|
+
};
|
|
6533
|
+
});
|
|
6534
|
+
else if (typeof v === "object" && v !== null) {
|
|
6535
|
+
const relObj = v;
|
|
6536
|
+
flat[k] = {
|
|
6537
|
+
...relObj,
|
|
6538
|
+
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
|
|
6539
|
+
};
|
|
6540
|
+
} else flat[k] = v;
|
|
6541
|
+
}
|
|
7316
6542
|
return flat;
|
|
7317
6543
|
});
|
|
7318
6544
|
} catch (e) {
|
|
@@ -7528,27 +6754,6 @@ var PersistService = class {
|
|
|
7528
6754
|
this.fetchService = new FetchService(db, registry);
|
|
7529
6755
|
}
|
|
7530
6756
|
/**
|
|
7531
|
-
* Explain a write that matched no rows.
|
|
7532
|
-
*
|
|
7533
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
7534
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
7535
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
7536
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
7537
|
-
* the row is untouched.
|
|
7538
|
-
*
|
|
7539
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
7540
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
7541
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
7542
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
7543
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
7544
|
-
*
|
|
7545
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
7546
|
-
*/
|
|
7547
|
-
async explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
|
|
7548
|
-
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");
|
|
7549
|
-
return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
|
|
7550
|
-
}
|
|
7551
|
-
/**
|
|
7552
6757
|
* Delete an row by ID
|
|
7553
6758
|
*/
|
|
7554
6759
|
async delete(collectionPath, id, _databaseId) {
|
|
@@ -7562,7 +6767,7 @@ var PersistService = class {
|
|
|
7562
6767
|
if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
7563
6768
|
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
7564
6769
|
}
|
|
7565
|
-
|
|
6770
|
+
await this.db.delete(table).where(and(...conditions));
|
|
7566
6771
|
}
|
|
7567
6772
|
/**
|
|
7568
6773
|
* Delete all rows from a collection
|
|
@@ -7573,14 +6778,8 @@ var PersistService = class {
|
|
|
7573
6778
|
}
|
|
7574
6779
|
/**
|
|
7575
6780
|
* Save an row (create or update)
|
|
7576
|
-
*
|
|
7577
|
-
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
7578
|
-
* UPDATE against the primary key rather than a plain UPDATE. That is one
|
|
7579
|
-
* statement, so it cannot lose a race the way a read-then-write can, and it
|
|
7580
|
-
* does not care whether the row already exists — which is what a re-runnable
|
|
7581
|
-
* import needs.
|
|
7582
6781
|
*/
|
|
7583
|
-
async save(collectionPath, values, id, databaseId
|
|
6782
|
+
async save(collectionPath, values, id, databaseId) {
|
|
7584
6783
|
let effectiveCollectionPath = collectionPath;
|
|
7585
6784
|
const effectiveValues = { ...values };
|
|
7586
6785
|
let junctionTableInfo;
|
|
@@ -7671,7 +6870,7 @@ var PersistService = class {
|
|
|
7671
6870
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
7672
6871
|
savedId = await this.db.transaction(async (tx) => {
|
|
7673
6872
|
let currentId;
|
|
7674
|
-
if (id
|
|
6873
|
+
if (id) {
|
|
7675
6874
|
currentId = id;
|
|
7676
6875
|
const idValues = parseIdValues(id, idInfoArray);
|
|
7677
6876
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
@@ -7682,28 +6881,13 @@ var PersistService = class {
|
|
|
7682
6881
|
const field = table[info.fieldName];
|
|
7683
6882
|
conditions.push(eq(field, idValues[info.fieldName]));
|
|
7684
6883
|
}
|
|
7685
|
-
|
|
6884
|
+
await updateQuery.where(and(...conditions));
|
|
7686
6885
|
}
|
|
7687
6886
|
} else {
|
|
7688
6887
|
const dataForInsert = { ...entityData };
|
|
7689
|
-
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
7690
6888
|
for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
|
|
7691
|
-
const
|
|
7692
|
-
|
|
7693
|
-
let result;
|
|
7694
|
-
if (options?.upsert && hasFullKey) {
|
|
7695
|
-
const target = idInfoArray.map((info) => table[info.fieldName]);
|
|
7696
|
-
const set = { ...dataForInsert };
|
|
7697
|
-
for (const info of idInfoArray) delete set[info.fieldName];
|
|
7698
|
-
result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
|
|
7699
|
-
target,
|
|
7700
|
-
set
|
|
7701
|
-
}).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
|
|
7702
|
-
} else result = await insertQuery.returning(returningKeys);
|
|
7703
|
-
const resultRow = result[0];
|
|
7704
|
-
if (!resultRow) if (id) currentId = id;
|
|
7705
|
-
else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
|
|
7706
|
-
else currentId = buildCompositeId(resultRow, idInfoArray);
|
|
6889
|
+
const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
|
|
6890
|
+
currentId = buildCompositeId(resultRow, idInfoArray);
|
|
7707
6891
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
7708
6892
|
}
|
|
7709
6893
|
if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
@@ -7714,7 +6898,7 @@ var PersistService = class {
|
|
|
7714
6898
|
} catch (error) {
|
|
7715
6899
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
7716
6900
|
}
|
|
7717
|
-
const finalEntity = await this.fetchService.
|
|
6901
|
+
const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
|
|
7718
6902
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
7719
6903
|
return finalEntity;
|
|
7720
6904
|
}
|
|
@@ -7734,7 +6918,6 @@ var PersistService = class {
|
|
|
7734
6918
|
* Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
|
|
7735
6919
|
*/
|
|
7736
6920
|
toUserFriendlyError(error, collectionSlug) {
|
|
7737
|
-
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
7738
6921
|
const pgError = extractPgError(error);
|
|
7739
6922
|
if (pgError) {
|
|
7740
6923
|
const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
@@ -7812,8 +6995,8 @@ var DataService = class {
|
|
|
7812
6995
|
/**
|
|
7813
6996
|
* Save an row (create or update)
|
|
7814
6997
|
*/
|
|
7815
|
-
async save(collectionPath, values, id, databaseId
|
|
7816
|
-
return this.persistService.save(collectionPath, values, id, databaseId
|
|
6998
|
+
async save(collectionPath, values, id, databaseId) {
|
|
6999
|
+
return this.persistService.save(collectionPath, values, id, databaseId);
|
|
7817
7000
|
}
|
|
7818
7001
|
/**
|
|
7819
7002
|
* Delete an row by ID
|
|
@@ -7881,26 +7064,6 @@ var DataService = class {
|
|
|
7881
7064
|
*/
|
|
7882
7065
|
/** Internal prefix applied to branch database names to avoid collisions. */
|
|
7883
7066
|
var BRANCH_DB_PREFIX = "rb_";
|
|
7884
|
-
/** `duplicate_database` — the target database name is already taken. */
|
|
7885
|
-
var PG_DUPLICATE_DATABASE = "42P04";
|
|
7886
|
-
/** `object_in_use` — the database still has connections attached. */
|
|
7887
|
-
var PG_OBJECT_IN_USE = "55006";
|
|
7888
|
-
/**
|
|
7889
|
-
* Describe a failed branch DDL statement in terms a user can act on.
|
|
7890
|
-
*
|
|
7891
|
-
* Drizzle reports failures as `Failed query: <sql> params:` and hides the real
|
|
7892
|
-
* PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
|
|
7893
|
-
* the actual problem. Match on the PG error code instead — it survives wrapping
|
|
7894
|
-
* and, unlike the message text, is not locale-dependent.
|
|
7895
|
-
*/
|
|
7896
|
-
function describeBranchDdlError(err, fallbackContext) {
|
|
7897
|
-
const pgError = extractPgError(err);
|
|
7898
|
-
if (pgError?.code === PG_DUPLICATE_DATABASE) return /* @__PURE__ */ new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
|
|
7899
|
-
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.`);
|
|
7900
|
-
const detail = pgError?.message ?? extractCauseMessage(err);
|
|
7901
|
-
if (detail) return new Error(detail);
|
|
7902
|
-
return err instanceof Error ? err : new Error(String(err));
|
|
7903
|
-
}
|
|
7904
7067
|
/** Fully-qualified metadata table in the rebase schema. */
|
|
7905
7068
|
var BRANCHES_TABLE = "rebase.branches";
|
|
7906
7069
|
/**
|
|
@@ -7969,8 +7132,10 @@ var BranchService = class {
|
|
|
7969
7132
|
try {
|
|
7970
7133
|
await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
|
|
7971
7134
|
} catch (err) {
|
|
7972
|
-
|
|
7973
|
-
throw
|
|
7135
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7136
|
+
if (msg.includes("already exists")) throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
|
|
7137
|
+
if (msg.includes("being accessed by other users")) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
|
|
7138
|
+
throw err;
|
|
7974
7139
|
}
|
|
7975
7140
|
const now = /* @__PURE__ */ new Date();
|
|
7976
7141
|
await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
@@ -7995,8 +7160,8 @@ var BranchService = class {
|
|
|
7995
7160
|
try {
|
|
7996
7161
|
await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
|
|
7997
7162
|
} catch (err) {
|
|
7998
|
-
if (
|
|
7999
|
-
throw
|
|
7163
|
+
if ((err instanceof Error ? err.message : String(err)).includes("being accessed by other users")) throw new Error(`Cannot delete branch "${sanitizedName}": the database has active connections. Close other clients and try again.`);
|
|
7164
|
+
throw err;
|
|
8000
7165
|
}
|
|
8001
7166
|
await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
|
|
8002
7167
|
}
|
|
@@ -8628,19 +7793,17 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8628
7793
|
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
8629
7794
|
};
|
|
8630
7795
|
}
|
|
8631
|
-
async save({ path, id, values, collection, status
|
|
7796
|
+
async save({ path, id, values, collection, status }) {
|
|
8632
7797
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
8633
7798
|
let updatedValues = values;
|
|
8634
7799
|
const contextForCallback = this.buildCallContext();
|
|
8635
7800
|
let previousValuesForHistory;
|
|
8636
|
-
if (status === "existing" && id)
|
|
8637
|
-
const existing = await this.dataService.
|
|
7801
|
+
if (status === "existing" && id) {
|
|
7802
|
+
const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
|
|
8638
7803
|
if (existing) {
|
|
8639
7804
|
const { id: _existingId, ...existingValues } = existing;
|
|
8640
7805
|
previousValuesForHistory = existingValues;
|
|
8641
7806
|
}
|
|
8642
|
-
} catch (err) {
|
|
8643
|
-
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
8644
7807
|
}
|
|
8645
7808
|
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
8646
7809
|
if (globalCallbacks?.beforeSave) {
|
|
@@ -8687,7 +7850,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8687
7850
|
timestampNowValue: /* @__PURE__ */ new Date()
|
|
8688
7851
|
});
|
|
8689
7852
|
try {
|
|
8690
|
-
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId
|
|
7853
|
+
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
|
|
8691
7854
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
8692
7855
|
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
8693
7856
|
collection: resolvedCollection,
|
|
@@ -8708,8 +7871,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8708
7871
|
context: contextForCallback
|
|
8709
7872
|
});
|
|
8710
7873
|
}
|
|
8711
|
-
const savedId =
|
|
8712
|
-
const savedValues = savedRow;
|
|
7874
|
+
const savedId = savedRow.id;
|
|
7875
|
+
const { id: _savedId, ...savedValues } = savedRow;
|
|
8713
7876
|
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
8714
7877
|
if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
|
|
8715
7878
|
collection: resolvedCollection,
|
|
@@ -8741,7 +7904,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8741
7904
|
}
|
|
8742
7905
|
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
8743
7906
|
tableName: path,
|
|
8744
|
-
id: savedId,
|
|
7907
|
+
id: savedId.toString(),
|
|
8745
7908
|
action: status === "new" ? "create" : "update",
|
|
8746
7909
|
values: savedValues,
|
|
8747
7910
|
previousValues: previousValuesForHistory,
|
|
@@ -8749,11 +7912,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8749
7912
|
});
|
|
8750
7913
|
if (this._deferNotifications) this._pendingNotifications.push({
|
|
8751
7914
|
path,
|
|
8752
|
-
id: savedId,
|
|
7915
|
+
id: savedId.toString(),
|
|
8753
7916
|
row: savedRow,
|
|
8754
7917
|
databaseId: resolvedCollection?.databaseId
|
|
8755
7918
|
});
|
|
8756
|
-
else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
|
|
7919
|
+
else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
|
|
8757
7920
|
return savedRow;
|
|
8758
7921
|
} catch (error) {
|
|
8759
7922
|
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
@@ -8788,52 +7951,12 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8788
7951
|
throw error;
|
|
8789
7952
|
}
|
|
8790
7953
|
}
|
|
8791
|
-
/**
|
|
8792
|
-
* Write many rows through the same pipeline as {@link save}.
|
|
8793
|
-
*
|
|
8794
|
-
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
8795
|
-
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
8796
|
-
* state. When this driver is already inside a transaction (the authenticated
|
|
8797
|
-
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
8798
|
-
* still atomic and still commits once.
|
|
8799
|
-
*
|
|
8800
|
-
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
8801
|
-
* with the last write winning, exactly as separate calls would.
|
|
8802
|
-
*/
|
|
8803
|
-
async saveMany({ path, rows, collection, upsert }) {
|
|
8804
|
-
return this.db.transaction(async (tx) => {
|
|
8805
|
-
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
8806
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
8807
|
-
txDriver.client = this.client;
|
|
8808
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
8809
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
8810
|
-
const saved = [];
|
|
8811
|
-
for (let i = 0; i < rows.length; i++) {
|
|
8812
|
-
const values = rows[i];
|
|
8813
|
-
const id = values?.id;
|
|
8814
|
-
try {
|
|
8815
|
-
saved.push(await txDriver.save({
|
|
8816
|
-
path,
|
|
8817
|
-
values,
|
|
8818
|
-
collection,
|
|
8819
|
-
status: "new",
|
|
8820
|
-
upsert
|
|
8821
|
-
}));
|
|
8822
|
-
} catch (error) {
|
|
8823
|
-
const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
|
|
8824
|
-
throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
8825
|
-
statusCode: error?.statusCode,
|
|
8826
|
-
code: error?.code,
|
|
8827
|
-
name: error?.name
|
|
8828
|
-
});
|
|
8829
|
-
}
|
|
8830
|
-
}
|
|
8831
|
-
return saved;
|
|
8832
|
-
});
|
|
8833
|
-
}
|
|
8834
7954
|
async delete({ row, collection }) {
|
|
8835
7955
|
const targetPath = row.path;
|
|
8836
|
-
const targetRow = {
|
|
7956
|
+
const targetRow = {
|
|
7957
|
+
id: row.id,
|
|
7958
|
+
...row.values ?? {}
|
|
7959
|
+
};
|
|
8837
7960
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
|
|
8838
7961
|
const contextForCallback = this.buildCallContext();
|
|
8839
7962
|
if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
|
|
@@ -9192,18 +8315,6 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
9192
8315
|
async save(props) {
|
|
9193
8316
|
return this.withTransaction((delegate) => delegate.save(props));
|
|
9194
8317
|
}
|
|
9195
|
-
/**
|
|
9196
|
-
* One transaction for the whole batch, rather than one per row.
|
|
9197
|
-
*
|
|
9198
|
-
* This is the point of the method: `save` opens a transaction per call, so
|
|
9199
|
-
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
9200
|
-
* round trips). Here the RLS context is established once and every row lands
|
|
9201
|
-
* or none does. Realtime notifications are already deferred to commit by
|
|
9202
|
-
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
9203
|
-
*/
|
|
9204
|
-
async saveMany(props) {
|
|
9205
|
-
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
9206
|
-
}
|
|
9207
8318
|
async delete(props) {
|
|
9208
8319
|
return this.withTransaction((delegate) => delegate.delete(props));
|
|
9209
8320
|
}
|
|
@@ -9253,7 +8364,6 @@ var DatabasePoolManager = class {
|
|
|
9253
8364
|
pool.on("error", (err) => {
|
|
9254
8365
|
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
9255
8366
|
});
|
|
9256
|
-
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
9257
8367
|
this.pools.set(databaseName, pool);
|
|
9258
8368
|
return pool;
|
|
9259
8369
|
}
|
|
@@ -9451,6 +8561,105 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
|
|
|
9451
8561
|
references: [users.id]
|
|
9452
8562
|
}) }));
|
|
9453
8563
|
//#endregion
|
|
8564
|
+
//#region src/schema/auth-default-policies.ts
|
|
8565
|
+
/**
|
|
8566
|
+
* Default RLS policies injected by the schema generator.
|
|
8567
|
+
*
|
|
8568
|
+
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
8569
|
+
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
8570
|
+
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
8571
|
+
* authorization model. The server context (auth flows, migrations,
|
|
8572
|
+
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
8573
|
+
*
|
|
8574
|
+
* Because RLS default-denies, every collection is **locked by default**: with
|
|
8575
|
+
* no rules, only the server context and admins can touch it. The generator
|
|
8576
|
+
* injects that safe baseline:
|
|
8577
|
+
*
|
|
8578
|
+
* **For every collection**
|
|
8579
|
+
* 1. A permissive **server-or-admin SELECT** grant.
|
|
8580
|
+
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
8581
|
+
*
|
|
8582
|
+
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
8583
|
+
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
8584
|
+
* rows").
|
|
8585
|
+
*
|
|
8586
|
+
* **For auth collections additionally**
|
|
8587
|
+
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
8588
|
+
* their own row (profile, session bootstrap) without every app re-declaring
|
|
8589
|
+
* it.
|
|
8590
|
+
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
8591
|
+
* every other policy, so a write is rejected unless the caller is an admin
|
|
8592
|
+
* (or the server context) — even if the author also wrote a permissive rule
|
|
8593
|
+
* such as "a user may edit their own row". Without this, a permissive owner
|
|
8594
|
+
* rule would let a user change their own `roles`.
|
|
8595
|
+
*
|
|
8596
|
+
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
8597
|
+
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
8598
|
+
* GUC — which also lets the owner connection satisfy these policies even under
|
|
8599
|
+
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
8600
|
+
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
8601
|
+
*
|
|
8602
|
+
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
8603
|
+
* the collection's RLS.
|
|
8604
|
+
*/
|
|
8605
|
+
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
8606
|
+
/** Write operations that must be admin-gated by default on auth collections. */
|
|
8607
|
+
var DEFAULT_GUARDED_OPS = [
|
|
8608
|
+
"insert",
|
|
8609
|
+
"update",
|
|
8610
|
+
"delete"
|
|
8611
|
+
];
|
|
8612
|
+
/** Whether a collection is flagged as an authentication collection. */
|
|
8613
|
+
function isAuthCollection(collection) {
|
|
8614
|
+
const auth = collection.auth;
|
|
8615
|
+
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
8616
|
+
}
|
|
8617
|
+
/** The property marked as the row id (falls back to `id`). */
|
|
8618
|
+
function getIdPropertyName(collection) {
|
|
8619
|
+
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
8620
|
+
return "id";
|
|
8621
|
+
}
|
|
8622
|
+
/**
|
|
8623
|
+
* Returns the security rules that should be applied to a collection: the
|
|
8624
|
+
* author's explicit `securityRules` plus the framework defaults described in
|
|
8625
|
+
* the module doc (baseline server/admin read for all collections; self-read
|
|
8626
|
+
* and the admin write gate for auth collections).
|
|
8627
|
+
*
|
|
8628
|
+
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
8629
|
+
*/
|
|
8630
|
+
function getEffectiveSecurityRules(collection) {
|
|
8631
|
+
const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
|
|
8632
|
+
if (collection.disableDefaultPolicies) return explicit;
|
|
8633
|
+
const tableName = getTableName$1(collection);
|
|
8634
|
+
const injected = [];
|
|
8635
|
+
injected.push({
|
|
8636
|
+
name: `${tableName}_default_admin_read`,
|
|
8637
|
+
operations: ["select"],
|
|
8638
|
+
condition: SERVER_OR_ADMIN_EXPR
|
|
8639
|
+
});
|
|
8640
|
+
injected.push({
|
|
8641
|
+
name: `${tableName}_default_admin_write`,
|
|
8642
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
8643
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
8644
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
8645
|
+
});
|
|
8646
|
+
if (isAuthCollection(collection)) {
|
|
8647
|
+
injected.push({
|
|
8648
|
+
name: `${tableName}_default_self_read`,
|
|
8649
|
+
operations: ["select"],
|
|
8650
|
+
condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
|
|
8651
|
+
});
|
|
8652
|
+
injected.push({
|
|
8653
|
+
name: `${tableName}_require_admin_write`,
|
|
8654
|
+
mode: "restrictive",
|
|
8655
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
8656
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
8657
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
8658
|
+
});
|
|
8659
|
+
}
|
|
8660
|
+
return [...explicit, ...injected];
|
|
8661
|
+
}
|
|
8662
|
+
//#endregion
|
|
9454
8663
|
//#region src/schema/generate-drizzle-schema-logic.ts
|
|
9455
8664
|
/**
|
|
9456
8665
|
* Resolve the SQL column name for a property.
|
|
@@ -9648,12 +8857,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
9648
8857
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
9649
8858
|
*/
|
|
9650
8859
|
var wrapSql = (clause) => `sql\`${clause}\``;
|
|
8860
|
+
/**
|
|
8861
|
+
* Generates a deterministic hash based on the rule configuration.
|
|
8862
|
+
*/
|
|
8863
|
+
var getPolicyNameHash = (rule) => {
|
|
8864
|
+
const data = JSON.stringify({
|
|
8865
|
+
a: rule.access,
|
|
8866
|
+
m: rule.mode,
|
|
8867
|
+
op: rule.operation,
|
|
8868
|
+
ops: rule.operations?.slice().sort(),
|
|
8869
|
+
own: rule.ownerField,
|
|
8870
|
+
rol: rule.roles?.slice().sort(),
|
|
8871
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
8872
|
+
u: rule.using,
|
|
8873
|
+
w: rule.withCheck,
|
|
8874
|
+
c: rule.condition,
|
|
8875
|
+
ch: rule.check
|
|
8876
|
+
});
|
|
8877
|
+
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
8878
|
+
};
|
|
9651
8879
|
var generatePolicyCode = (collection, rule, index, resolveCollection) => {
|
|
9652
8880
|
const tableName = getTableName$1(collection);
|
|
9653
8881
|
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
9654
|
-
const
|
|
8882
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
9655
8883
|
return ops.map((op, opIdx) => {
|
|
9656
|
-
return generateSinglePolicyCode(collection, rule, op,
|
|
8884
|
+
return generateSinglePolicyCode(collection, rule, op, rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`, resolveCollection);
|
|
9657
8885
|
}).join("");
|
|
9658
8886
|
};
|
|
9659
8887
|
/**
|
|
@@ -9782,7 +9010,6 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9782
9010
|
});
|
|
9783
9011
|
});
|
|
9784
9012
|
schemaContent += "\n";
|
|
9785
|
-
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
9786
9013
|
for (const collection of collections) {
|
|
9787
9014
|
const tableName = getTableName$1(collection);
|
|
9788
9015
|
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
@@ -9816,17 +9043,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9816
9043
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
9817
9044
|
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
9818
9045
|
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
9819
|
-
schemaContent += "}, (table) => (
|
|
9820
|
-
schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })
|
|
9821
|
-
|
|
9822
|
-
if (!stripPolicies && junctionSpec) {
|
|
9823
|
-
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
9824
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
9825
|
-
getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
|
|
9826
|
-
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
9827
|
-
});
|
|
9828
|
-
}
|
|
9829
|
-
schemaContent += "])).enableRLS();\n\n";
|
|
9046
|
+
schemaContent += "}, (table) => ({\n";
|
|
9047
|
+
schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
|
|
9048
|
+
schemaContent += "}));\n\n";
|
|
9830
9049
|
} else if (!isJunction) {
|
|
9831
9050
|
const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
|
|
9832
9051
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
@@ -10517,7 +9736,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10517
9736
|
startAfter: request.startAfter,
|
|
10518
9737
|
searchString: request.searchString
|
|
10519
9738
|
}, authContext);
|
|
10520
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows
|
|
9739
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows);
|
|
10521
9740
|
} catch (error) {
|
|
10522
9741
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
10523
9742
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -10618,7 +9837,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10618
9837
|
if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
10619
9838
|
else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
|
|
10620
9839
|
else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
10621
|
-
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row
|
|
9840
|
+
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
|
|
10622
9841
|
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
10623
9842
|
}
|
|
10624
9843
|
} catch (error) {
|
|
@@ -10648,7 +9867,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10648
9867
|
if (!this._subscriptions.has(subscriptionId)) return;
|
|
10649
9868
|
try {
|
|
10650
9869
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
|
|
10651
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows
|
|
9870
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
|
|
10652
9871
|
} catch (error) {
|
|
10653
9872
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
10654
9873
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -10862,12 +10081,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10862
10081
|
}
|
|
10863
10082
|
return await this.dataService.fetchOne(notifyPath, id);
|
|
10864
10083
|
}
|
|
10865
|
-
sendCollectionUpdate(clientId, subscriptionId, rows
|
|
10084
|
+
sendCollectionUpdate(clientId, subscriptionId, rows) {
|
|
10866
10085
|
const message = {
|
|
10867
10086
|
type: "collection_update",
|
|
10868
10087
|
subscriptionId,
|
|
10869
|
-
rows
|
|
10870
|
-
pks: this.primaryKeysForPath(path)
|
|
10088
|
+
rows
|
|
10871
10089
|
};
|
|
10872
10090
|
this.sendMessage(clientId, message);
|
|
10873
10091
|
}
|
|
@@ -10882,33 +10100,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10882
10100
|
/**
|
|
10883
10101
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
10884
10102
|
* The client can merge this into its cached data for instant feedback.
|
|
10885
|
-
*
|
|
10886
|
-
* The key columns ride along: the patch names a row by address, and the
|
|
10887
|
-
* client has to find that row among the ones it cached — which carry
|
|
10888
|
-
* columns and no address. The SDK holds no collection config to derive one
|
|
10889
|
-
* from, so this is the only place the mapping can come from.
|
|
10890
10103
|
*/
|
|
10891
|
-
sendCollectionPatch(clientId, subscriptionId, id, row
|
|
10104
|
+
sendCollectionPatch(clientId, subscriptionId, id, row) {
|
|
10892
10105
|
const message = {
|
|
10893
10106
|
type: "collection_patch",
|
|
10894
10107
|
subscriptionId,
|
|
10895
10108
|
id,
|
|
10896
|
-
row
|
|
10897
|
-
pks: this.primaryKeysForPath(notifyPath)
|
|
10109
|
+
row
|
|
10898
10110
|
};
|
|
10899
10111
|
this.sendMessage(clientId, message);
|
|
10900
10112
|
}
|
|
10901
|
-
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
10902
|
-
primaryKeysForPath(path) {
|
|
10903
|
-
try {
|
|
10904
|
-
const collection = this.registry.getCollectionByPath(path);
|
|
10905
|
-
if (!collection) return void 0;
|
|
10906
|
-
const keys = getPrimaryKeys(collection, this.registry);
|
|
10907
|
-
return keys.length > 0 ? keys : void 0;
|
|
10908
|
-
} catch {
|
|
10909
|
-
return;
|
|
10910
|
-
}
|
|
10911
|
-
}
|
|
10912
10113
|
sendError(clientId, error, subscriptionId, code) {
|
|
10913
10114
|
const message = {
|
|
10914
10115
|
type: "error",
|
|
@@ -11156,7 +10357,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11156
10357
|
}
|
|
11157
10358
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
11158
10359
|
extractIdFromCdcRow(collection, row) {
|
|
11159
|
-
|
|
10360
|
+
try {
|
|
10361
|
+
const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
|
|
10362
|
+
if (composite && composite !== ":::") return composite;
|
|
10363
|
+
} catch {}
|
|
10364
|
+
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
10365
|
+
return "*";
|
|
11160
10366
|
}
|
|
11161
10367
|
dedupKey(path, id, databaseId) {
|
|
11162
10368
|
return `${databaseId ?? ""}::${path}::${id}`;
|
|
@@ -11772,7 +10978,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11772
10978
|
} catch (error) {
|
|
11773
10979
|
logger.error("💥 [WebSocket Server] Error handling message", { error });
|
|
11774
10980
|
if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
|
|
11775
|
-
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" :
|
|
10981
|
+
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error instanceof Error ? error.message : "An unexpected error occurred";
|
|
11776
10982
|
const errorResponse = {
|
|
11777
10983
|
type: "ERROR",
|
|
11778
10984
|
requestId,
|
|
@@ -20616,33 +19822,6 @@ function formatBytes(bytes) {
|
|
|
20616
19822
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
20617
19823
|
}
|
|
20618
19824
|
//#endregion
|
|
20619
|
-
//#region src/collections/buildRegistry.ts
|
|
20620
|
-
/**
|
|
20621
|
-
* Build the collection registry for a driver.
|
|
20622
|
-
*
|
|
20623
|
-
* The order matters and is the reason this is one function rather than a run of
|
|
20624
|
-
* statements in the bootstrapper. Keys are resolved from the drizzle schema, so
|
|
20625
|
-
* anything that inspects them has to run *after* the tables are registered —
|
|
20626
|
-
* and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
|
|
20627
|
-
* collection whose table it cannot look up is one it has nothing to say about.
|
|
20628
|
-
* Warned too early, it would skip every collection and report nothing, which
|
|
20629
|
-
* reads exactly like having nothing to report.
|
|
20630
|
-
*/
|
|
20631
|
-
function buildCollectionRegistry(schema) {
|
|
20632
|
-
const registry = new PostgresCollectionRegistry();
|
|
20633
|
-
if (schema.collections) {
|
|
20634
|
-
registry.registerMultiple(schema.collections);
|
|
20635
|
-
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
20636
|
-
}
|
|
20637
|
-
if (schema.tables) Object.values(schema.tables).forEach((table) => {
|
|
20638
|
-
if (isTable(table)) registry.registerTable(table, getTableName(table));
|
|
20639
|
-
});
|
|
20640
|
-
if (schema.enums) registry.registerEnums(schema.enums);
|
|
20641
|
-
if (schema.relations) registry.registerRelations(schema.relations);
|
|
20642
|
-
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
20643
|
-
return registry;
|
|
20644
|
-
}
|
|
20645
|
-
//#endregion
|
|
20646
19825
|
//#region src/auth/ensure-tables.ts
|
|
20647
19826
|
/**
|
|
20648
19827
|
* Auto-create auth tables if they don't exist.
|
|
@@ -20814,22 +19993,14 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20814
19993
|
$$ LANGUAGE sql STABLE
|
|
20815
19994
|
`);
|
|
20816
19995
|
});
|
|
20817
|
-
|
|
20818
|
-
|
|
20819
|
-
|
|
20820
|
-
|
|
20821
|
-
|
|
20822
|
-
|
|
20823
|
-
|
|
20824
|
-
|
|
20825
|
-
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
20826
|
-
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
20827
|
-
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
20828
|
-
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
20829
|
-
]) await db.execute(sql`
|
|
20830
|
-
ALTER TABLE ${sql.raw(usersTableName)}
|
|
20831
|
-
ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
|
|
20832
|
-
`);
|
|
19996
|
+
await db.execute(sql`
|
|
19997
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
19998
|
+
ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
|
|
19999
|
+
`);
|
|
20000
|
+
await db.execute(sql`
|
|
20001
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
20002
|
+
ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
|
|
20003
|
+
`);
|
|
20833
20004
|
try {
|
|
20834
20005
|
if ((await db.execute(sql`
|
|
20835
20006
|
SELECT EXISTS (
|
|
@@ -20900,34 +20071,6 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20900
20071
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
20901
20072
|
ON ${sql.raw(recoveryCodesTableName)}(user_id)
|
|
20902
20073
|
`);
|
|
20903
|
-
try {
|
|
20904
|
-
const authTablePairs = [
|
|
20905
|
-
[usersSchema, resolvedTable],
|
|
20906
|
-
[authSchema, "user_identities"],
|
|
20907
|
-
[authSchema, "refresh_tokens"],
|
|
20908
|
-
[authSchema, "password_reset_tokens"],
|
|
20909
|
-
[authSchema, "app_config"],
|
|
20910
|
-
[authSchema, "mfa_factors"],
|
|
20911
|
-
[authSchema, "mfa_challenges"],
|
|
20912
|
-
[authSchema, "recovery_codes"]
|
|
20913
|
-
];
|
|
20914
|
-
for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
|
|
20915
|
-
SELECT 1
|
|
20916
|
-
FROM pg_class c
|
|
20917
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
20918
|
-
WHERE n.nspname = ${schemaName}
|
|
20919
|
-
AND c.relname = ${tableName}
|
|
20920
|
-
AND c.relforcerowsecurity
|
|
20921
|
-
`)).rows.length > 0) {
|
|
20922
|
-
await db.execute(sql`
|
|
20923
|
-
ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
|
|
20924
|
-
NO FORCE ROW LEVEL SECURITY
|
|
20925
|
-
`);
|
|
20926
|
-
logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
|
|
20927
|
-
}
|
|
20928
|
-
} catch (rlsReconcileError) {
|
|
20929
|
-
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
20930
|
-
}
|
|
20931
20074
|
logger.info("✅ Auth tables ready");
|
|
20932
20075
|
} catch (error) {
|
|
20933
20076
|
logger.error("❌ Failed to create auth tables", { error });
|
|
@@ -20975,31 +20118,6 @@ var UserService = class {
|
|
|
20975
20118
|
const name = getTableName(this.usersTable);
|
|
20976
20119
|
return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
|
|
20977
20120
|
}
|
|
20978
|
-
/**
|
|
20979
|
-
* Run a privileged auth write with an explicitly cleared RLS context.
|
|
20980
|
-
*
|
|
20981
|
-
* The auth services run on the base/owner connection, which by design
|
|
20982
|
-
* carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
|
|
20983
|
-
* in the default policies applies. That NULL is normally guaranteed by
|
|
20984
|
-
* `set_config(..., is_local = true)` resetting at transaction end — but a
|
|
20985
|
-
* GUC that survives on a pooled connection (or a connection role that
|
|
20986
|
-
* doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
|
|
20987
|
-
* turns the trusted write into an RLS-scoped one and denies it with
|
|
20988
|
-
* SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
|
|
20989
|
-
* single chokepoint, makes the server context deterministic instead of
|
|
20990
|
-
* trusting whatever state the pool hands us. `auth.uid()` reads '' as
|
|
20991
|
-
* NULL via NULLIF, so '' is the server context.
|
|
20992
|
-
*/
|
|
20993
|
-
async withServerContext(fn) {
|
|
20994
|
-
return await this.db.transaction(async (tx) => {
|
|
20995
|
-
await tx.execute(sql`
|
|
20996
|
-
SELECT set_config('app.user_id', '', true),
|
|
20997
|
-
set_config('app.user_roles', '', true),
|
|
20998
|
-
set_config('app.jwt', '', true)
|
|
20999
|
-
`);
|
|
21000
|
-
return await fn(tx);
|
|
21001
|
-
});
|
|
21002
|
-
}
|
|
21003
20121
|
mapRowToUser(row) {
|
|
21004
20122
|
if (!row) return row;
|
|
21005
20123
|
const id = row.id ?? row.uid;
|
|
@@ -21097,7 +20215,7 @@ var UserService = class {
|
|
|
21097
20215
|
}
|
|
21098
20216
|
async createUser(data) {
|
|
21099
20217
|
const payload = this.mapPayload(data);
|
|
21100
|
-
const [row] = await this.
|
|
20218
|
+
const [row] = await this.db.insert(this.usersTable).values(payload).returning();
|
|
21101
20219
|
return this.mapRowToUser(row);
|
|
21102
20220
|
}
|
|
21103
20221
|
async getUserById(id) {
|
|
@@ -21136,12 +20254,12 @@ var UserService = class {
|
|
|
21136
20254
|
}));
|
|
21137
20255
|
}
|
|
21138
20256
|
async linkUserIdentity(userId, provider, providerId, profileData) {
|
|
21139
|
-
await this.
|
|
20257
|
+
await this.db.insert(this.userIdentitiesTable).values({
|
|
21140
20258
|
userId,
|
|
21141
20259
|
provider,
|
|
21142
20260
|
providerId,
|
|
21143
20261
|
profileData: profileData || null
|
|
21144
|
-
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] })
|
|
20262
|
+
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
|
|
21145
20263
|
}
|
|
21146
20264
|
async updateUser(id, data) {
|
|
21147
20265
|
const idCol = getColumn(this.usersTable, "id");
|
|
@@ -21149,13 +20267,13 @@ var UserService = class {
|
|
|
21149
20267
|
const payload = this.mapPayload(data);
|
|
21150
20268
|
const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
21151
20269
|
payload[updatedAtKey] = /* @__PURE__ */ new Date();
|
|
21152
|
-
const [row] = await this.
|
|
20270
|
+
const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
|
|
21153
20271
|
return row ? this.mapRowToUser(row) : null;
|
|
21154
20272
|
}
|
|
21155
20273
|
async deleteUser(id) {
|
|
21156
20274
|
const idCol = getColumn(this.usersTable, "id");
|
|
21157
20275
|
if (!idCol) return;
|
|
21158
|
-
await this.
|
|
20276
|
+
await this.db.delete(this.usersTable).where(eq(idCol, id));
|
|
21159
20277
|
}
|
|
21160
20278
|
async listUsers() {
|
|
21161
20279
|
return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
|
|
@@ -21209,10 +20327,10 @@ var UserService = class {
|
|
|
21209
20327
|
if (!idCol) return;
|
|
21210
20328
|
const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
|
|
21211
20329
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
21212
|
-
await this.
|
|
20330
|
+
await this.db.update(this.usersTable).set({
|
|
21213
20331
|
[passwordHashColKey]: passwordHash,
|
|
21214
20332
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
21215
|
-
}).where(eq(idCol, id))
|
|
20333
|
+
}).where(eq(idCol, id));
|
|
21216
20334
|
}
|
|
21217
20335
|
/**
|
|
21218
20336
|
* Set email verification status
|
|
@@ -21223,11 +20341,11 @@ var UserService = class {
|
|
|
21223
20341
|
const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
|
|
21224
20342
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
21225
20343
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
21226
|
-
await this.
|
|
20344
|
+
await this.db.update(this.usersTable).set({
|
|
21227
20345
|
[emailVerifiedColKey]: verified,
|
|
21228
20346
|
[emailVerificationTokenColKey]: null,
|
|
21229
20347
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
21230
|
-
}).where(eq(idCol, id))
|
|
20348
|
+
}).where(eq(idCol, id));
|
|
21231
20349
|
}
|
|
21232
20350
|
/**
|
|
21233
20351
|
* Set email verification token
|
|
@@ -21238,11 +20356,11 @@ var UserService = class {
|
|
|
21238
20356
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
21239
20357
|
const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
|
|
21240
20358
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
21241
|
-
await this.
|
|
20359
|
+
await this.db.update(this.usersTable).set({
|
|
21242
20360
|
[emailVerificationTokenColKey]: token,
|
|
21243
20361
|
[emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
|
|
21244
20362
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
21245
|
-
}).where(eq(idCol, id))
|
|
20363
|
+
}).where(eq(idCol, id));
|
|
21246
20364
|
}
|
|
21247
20365
|
/**
|
|
21248
20366
|
* Find user by email verification token
|
|
@@ -21287,22 +20405,22 @@ var UserService = class {
|
|
|
21287
20405
|
async setUserRoles(userId, roleIds) {
|
|
21288
20406
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21289
20407
|
const rolesArray = `{${roleIds.join(",")}}`;
|
|
21290
|
-
await this.
|
|
20408
|
+
await this.db.execute(sql`
|
|
21291
20409
|
UPDATE ${sql.raw(usersTableName)}
|
|
21292
20410
|
SET roles = ${rolesArray}::text[], updated_at = NOW()
|
|
21293
20411
|
WHERE id = ${userId}
|
|
21294
|
-
`)
|
|
20412
|
+
`);
|
|
21295
20413
|
}
|
|
21296
20414
|
/**
|
|
21297
20415
|
* Assign a specific role to new user (appends if not present)
|
|
21298
20416
|
*/
|
|
21299
20417
|
async assignDefaultRole(userId, roleId) {
|
|
21300
20418
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21301
|
-
await this.
|
|
20419
|
+
await this.db.execute(sql`
|
|
21302
20420
|
UPDATE ${sql.raw(usersTableName)}
|
|
21303
20421
|
SET roles = array_append(roles, ${roleId}), updated_at = NOW()
|
|
21304
20422
|
WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
|
|
21305
|
-
`)
|
|
20423
|
+
`);
|
|
21306
20424
|
}
|
|
21307
20425
|
/**
|
|
21308
20426
|
* Get user with their roles
|
|
@@ -22686,14 +21804,21 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22686
21804
|
logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
|
|
22687
21805
|
}
|
|
22688
21806
|
const activeCollections = introspectedCollections ?? collections;
|
|
21807
|
+
const registry = new PostgresCollectionRegistry();
|
|
21808
|
+
if (activeCollections) {
|
|
21809
|
+
registry.registerMultiple(activeCollections);
|
|
21810
|
+
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
21811
|
+
}
|
|
22689
21812
|
const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
|
|
22690
|
-
|
|
22691
|
-
|
|
22692
|
-
|
|
22693
|
-
|
|
22694
|
-
|
|
22695
|
-
relations: schemaRelations
|
|
21813
|
+
if (schemaTables) Object.values(schemaTables).forEach((table) => {
|
|
21814
|
+
if (isTable(table)) {
|
|
21815
|
+
const tableName = getTableName(table);
|
|
21816
|
+
registry.registerTable(table, tableName);
|
|
21817
|
+
}
|
|
22696
21818
|
});
|
|
21819
|
+
if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
|
|
21820
|
+
const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
|
|
21821
|
+
if (schemaRelations) registry.registerRelations(schemaRelations);
|
|
22697
21822
|
if (schemaTables) patchPgArrayNullSafety(schemaTables);
|
|
22698
21823
|
const mergedSchema = {
|
|
22699
21824
|
...schemaTables,
|
|
@@ -22772,7 +21897,6 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22772
21897
|
const wantsCdc = cdcMode !== "off";
|
|
22773
21898
|
const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
|
|
22774
21899
|
let cdcEnabled = false;
|
|
22775
|
-
let provisionCdcForTables;
|
|
22776
21900
|
if (wantsCdc && !directUrl) {
|
|
22777
21901
|
const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
|
|
22778
21902
|
if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
|
|
@@ -22789,9 +21913,6 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22789
21913
|
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
|
|
22790
21914
|
await realtimeService.enableCdc(directUrl);
|
|
22791
21915
|
cdcEnabled = true;
|
|
22792
|
-
provisionCdcForTables = async (tables) => {
|
|
22793
|
-
await provisionTriggerCdc(cdcRunSql, tables);
|
|
22794
|
-
};
|
|
22795
21916
|
logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
|
|
22796
21917
|
} catch (err) {
|
|
22797
21918
|
if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
|
|
@@ -22816,14 +21937,13 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22816
21937
|
const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
|
|
22817
21938
|
const missing = [];
|
|
22818
21939
|
for (const col of registeredCollections) {
|
|
22819
|
-
if (col.auth?.enabled) continue;
|
|
22820
21940
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
22821
21941
|
const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
|
|
22822
21942
|
const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
|
|
22823
21943
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
22824
21944
|
if (!dbTables.has(fullCheckName)) missing.push({
|
|
22825
21945
|
slug: col.slug,
|
|
22826
|
-
table:
|
|
21946
|
+
table: checkName
|
|
22827
21947
|
});
|
|
22828
21948
|
}
|
|
22829
21949
|
if (missing.length > 0) {
|
|
@@ -22857,8 +21977,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22857
21977
|
registry,
|
|
22858
21978
|
realtimeService,
|
|
22859
21979
|
driver,
|
|
22860
|
-
poolManager
|
|
22861
|
-
provisionCdcForTables
|
|
21980
|
+
poolManager
|
|
22862
21981
|
}
|
|
22863
21982
|
};
|
|
22864
21983
|
},
|
|
@@ -22870,18 +21989,6 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22870
21989
|
const registry = internals.registry;
|
|
22871
21990
|
const authCollection = authConfig.collection;
|
|
22872
21991
|
await ensureAuthTablesExist(db, authCollection);
|
|
22873
|
-
if (authCollection && internals.provisionCdcForTables) {
|
|
22874
|
-
const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
|
|
22875
|
-
const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
|
|
22876
|
-
if (authTable) try {
|
|
22877
|
-
await internals.provisionCdcForTables([{
|
|
22878
|
-
schema: authSchema,
|
|
22879
|
-
table: authTable
|
|
22880
|
-
}]);
|
|
22881
|
-
} catch (err) {
|
|
22882
|
-
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) });
|
|
22883
|
-
}
|
|
22884
|
-
}
|
|
22885
21992
|
let emailService;
|
|
22886
21993
|
if (authConfig.email) emailService = createEmailService(authConfig.email);
|
|
22887
21994
|
const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
|
|
@@ -22952,6 +22059,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
22952
22059
|
};
|
|
22953
22060
|
}
|
|
22954
22061
|
//#endregion
|
|
22955
|
-
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,
|
|
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 };
|
|
22956
22063
|
|
|
22957
22064
|
//# sourceMappingURL=index.es.js.map
|