@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.26fe4b2
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/README.md +21 -0
- package/dist/PostgresBackendDriver.d.ts +43 -2
- package/dist/PostgresBootstrapper.d.ts +17 -1
- package/dist/auth/services.d.ts +68 -52
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +2060 -762
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
- package/dist/schema/auth-schema.d.ts +24 -24
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/policy-drift.d.ts +30 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +9 -1
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/collection-helpers.d.ts +79 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +76 -2
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +12 -33
- package/src/PostgresBackendDriver.ts +183 -18
- package/src/PostgresBootstrapper.ts +80 -26
- package/src/auth/ensure-tables.ts +170 -28
- package/src/auth/services.ts +181 -150
- package/src/cli.ts +60 -0
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +13 -13
- package/src/schema/doctor.ts +45 -20
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-db.ts +19 -2
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/policy-drift.test.ts +106 -1
- package/src/security/policy-drift.ts +56 -0
- package/src/security/rls-enforcement.ts +11 -5
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +62 -9
- package/src/services/RelationService.ts +153 -94
- package/src/services/channel-history.ts +343 -0
- package/src/services/collection-helpers.ts +164 -47
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +238 -29
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- package/src/websocket.ts +34 -12
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/src/schema/auth-default-policies.ts +0 -132
package/dist/index.es.js
CHANGED
|
@@ -6,12 +6,12 @@ import { drizzle } from "drizzle-orm/node-postgres";
|
|
|
6
6
|
import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
|
|
7
7
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
8
8
|
import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
9
|
-
import { createHash, randomUUID } from "crypto";
|
|
10
9
|
import fs, { promises } from "fs";
|
|
11
10
|
import path from "path";
|
|
12
11
|
import chokidar from "chokidar";
|
|
13
12
|
import { WebSocket, WebSocketServer } from "ws";
|
|
14
13
|
import { EventEmitter } from "events";
|
|
14
|
+
import { randomUUID } from "crypto";
|
|
15
15
|
import { inspect } from "util";
|
|
16
16
|
import os from "os";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -71,16 +71,51 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
71
71
|
var connection_exports = /* @__PURE__ */ __exportAll({
|
|
72
72
|
createDirectDatabaseConnection: () => createDirectDatabaseConnection,
|
|
73
73
|
createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
|
|
74
|
-
createReadReplicaConnection: () => createReadReplicaConnection
|
|
74
|
+
createReadReplicaConnection: () => createReadReplicaConnection,
|
|
75
|
+
guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
|
|
75
76
|
});
|
|
76
77
|
var DEFAULT_POOL = {
|
|
77
78
|
max: 20,
|
|
78
79
|
idleTimeoutMillis: 3e4,
|
|
79
80
|
connectionTimeoutMillis: 1e4,
|
|
80
|
-
queryTimeout:
|
|
81
|
+
queryTimeout: 6e4,
|
|
81
82
|
statementTimeout: 3e4,
|
|
82
83
|
keepAlive: true
|
|
83
84
|
};
|
|
85
|
+
/** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
|
|
86
|
+
var TX_IDLE = "I";
|
|
87
|
+
/**
|
|
88
|
+
* Destroy pool clients that are released while still inside a transaction.
|
|
89
|
+
*
|
|
90
|
+
* pg-pool returns a client to the idle list whenever `release()` is called
|
|
91
|
+
* without an error — even if the connection is still mid-transaction (status
|
|
92
|
+
* `T`/`E`). That happens in practice: drizzle's pool transaction releases in
|
|
93
|
+
* a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
|
|
94
|
+
* (e.g. it was queued behind a statement that hit the client-side
|
|
95
|
+
* query_timeout), the client goes back dirty. The next checkout then runs
|
|
96
|
+
* its statements inside the zombie transaction — with the previous request's
|
|
97
|
+
* `app.*` RLS GUCs still applied, which turns unrelated queries into
|
|
98
|
+
* RLS-scoped ones (observed in production as registration failing with
|
|
99
|
+
* SQLSTATE 42501 under a leaked anonymous context).
|
|
100
|
+
*
|
|
101
|
+
* pg-pool emits `release` before it consults its private `_expired` set, so
|
|
102
|
+
* marking the client expired here makes `_release()` destroy it instead of
|
|
103
|
+
* pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
|
|
104
|
+
* private APIs — feature-detect and fall back to loud logging so an upstream
|
|
105
|
+
* change degrades to observability, never to silent corruption.
|
|
106
|
+
*/
|
|
107
|
+
function guardPoolAgainstDirtyRelease(pool, label) {
|
|
108
|
+
pool.on("release", (err, client) => {
|
|
109
|
+
if (err) return;
|
|
110
|
+
const txStatus = client?._txStatus;
|
|
111
|
+
if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
|
|
112
|
+
const expired = pool._expired;
|
|
113
|
+
if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
|
|
114
|
+
expired.add(client);
|
|
115
|
+
logger.error(`[${label}] Client released back to the pool while still in a transaction (status '${txStatus}') — destroying it so the open transaction and its session state (RLS GUCs) cannot leak into the next request.`);
|
|
116
|
+
} else logger.error(`[${label}] Client released mid-transaction (status '${txStatus}') but the pool's internal expiry set is unavailable (pg-pool internals changed?). The connection may leak its open transaction into subsequent requests.`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
84
119
|
/**
|
|
85
120
|
* Create a Drizzle-backed Postgres connection with a production-grade
|
|
86
121
|
* connection pool.
|
|
@@ -112,6 +147,7 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
|
|
|
112
147
|
logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
|
|
113
148
|
if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
|
|
114
149
|
});
|
|
150
|
+
guardPoolAgainstDirtyRelease(pool, "pg-pool");
|
|
115
151
|
return {
|
|
116
152
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
117
153
|
pool,
|
|
@@ -144,6 +180,7 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
|
|
|
144
180
|
pool.on("error", (err) => {
|
|
145
181
|
logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
|
|
146
182
|
});
|
|
183
|
+
guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
|
|
147
184
|
return {
|
|
148
185
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
149
186
|
pool,
|
|
@@ -173,6 +210,7 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
|
|
|
173
210
|
pool.on("error", (err) => {
|
|
174
211
|
logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
|
|
175
212
|
});
|
|
213
|
+
guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
|
|
176
214
|
return {
|
|
177
215
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
178
216
|
pool,
|
|
@@ -315,7 +353,7 @@ function getDeclaredSubcollections(collection) {
|
|
|
315
353
|
/**
|
|
316
354
|
* The id a request without a logged-in user reports as `auth.uid()`.
|
|
317
355
|
*
|
|
318
|
-
* A user-context request always sets `app.
|
|
356
|
+
* A user-context request always sets `app.uid`: blank would read back as
|
|
319
357
|
* `NULL`, and `NULL` is how the trusted server context is recognised, so an
|
|
320
358
|
* anonymous visitor would be promoted to server privileges. The driver
|
|
321
359
|
* therefore substitutes this sentinel at the single chokepoint where the GUC
|
|
@@ -1502,6 +1540,140 @@ function removeFunctions(o) {
|
|
|
1502
1540
|
return o;
|
|
1503
1541
|
}
|
|
1504
1542
|
//#endregion
|
|
1543
|
+
//#region ../utils/src/sha1.ts
|
|
1544
|
+
/**
|
|
1545
|
+
* Minimal SHA-1 implementation that runs in both Node and the browser.
|
|
1546
|
+
*
|
|
1547
|
+
* This exists because generated Postgres policy names embed a SHA-1 digest of
|
|
1548
|
+
* the security rule. The DDL generator runs on the server (where `node:crypto`
|
|
1549
|
+
* is available) but the Studio has to derive the same names in the browser to
|
|
1550
|
+
* tell a policy it generated apart from one it did not. `node:crypto` cannot be
|
|
1551
|
+
* bundled for the browser, so the shared derivation needs a portable digest.
|
|
1552
|
+
*
|
|
1553
|
+
* SHA-1 is used purely to name things deterministically — never for security.
|
|
1554
|
+
* The output is byte-identical to `createHash("sha1").update(str).digest("hex")`,
|
|
1555
|
+
* which `sha1.test.ts` pins against `node:crypto` directly.
|
|
1556
|
+
*/
|
|
1557
|
+
/** Rotate a 32-bit word left by `n` bits. */
|
|
1558
|
+
function rotl(value, n) {
|
|
1559
|
+
return value << n | value >>> 32 - n;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* SHA-1 digest of a string, hex-encoded.
|
|
1563
|
+
*
|
|
1564
|
+
* The input is encoded as UTF-8, matching Node's default handling of strings
|
|
1565
|
+
* passed to `hash.update(str)`.
|
|
1566
|
+
*/
|
|
1567
|
+
function sha1Hex(input) {
|
|
1568
|
+
const bytes = Array.from(new TextEncoder().encode(input));
|
|
1569
|
+
const bitLength = bytes.length * 8;
|
|
1570
|
+
bytes.push(128);
|
|
1571
|
+
while (bytes.length % 64 !== 56) bytes.push(0);
|
|
1572
|
+
const hi = Math.floor(bitLength / 4294967296);
|
|
1573
|
+
const lo = bitLength >>> 0;
|
|
1574
|
+
bytes.push(hi >>> 24 & 255, hi >>> 16 & 255, hi >>> 8 & 255, hi & 255);
|
|
1575
|
+
bytes.push(lo >>> 24 & 255, lo >>> 16 & 255, lo >>> 8 & 255, lo & 255);
|
|
1576
|
+
let h0 = 1732584193;
|
|
1577
|
+
let h1 = 4023233417;
|
|
1578
|
+
let h2 = 2562383102;
|
|
1579
|
+
let h3 = 271733878;
|
|
1580
|
+
let h4 = 3285377520;
|
|
1581
|
+
const w = new Array(80);
|
|
1582
|
+
for (let offset = 0; offset < bytes.length; offset += 64) {
|
|
1583
|
+
for (let i = 0; i < 16; i++) {
|
|
1584
|
+
const j = offset + i * 4;
|
|
1585
|
+
w[i] = bytes[j] << 24 | bytes[j + 1] << 16 | bytes[j + 2] << 8 | bytes[j + 3] | 0;
|
|
1586
|
+
}
|
|
1587
|
+
for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
|
|
1588
|
+
let a = h0;
|
|
1589
|
+
let b = h1;
|
|
1590
|
+
let c = h2;
|
|
1591
|
+
let d = h3;
|
|
1592
|
+
let e = h4;
|
|
1593
|
+
for (let i = 0; i < 80; i++) {
|
|
1594
|
+
let f;
|
|
1595
|
+
let k;
|
|
1596
|
+
if (i < 20) {
|
|
1597
|
+
f = b & c | ~b & d;
|
|
1598
|
+
k = 1518500249;
|
|
1599
|
+
} else if (i < 40) {
|
|
1600
|
+
f = b ^ c ^ d;
|
|
1601
|
+
k = 1859775393;
|
|
1602
|
+
} else if (i < 60) {
|
|
1603
|
+
f = b & c | b & d | c & d;
|
|
1604
|
+
k = 2400959708;
|
|
1605
|
+
} else {
|
|
1606
|
+
f = b ^ c ^ d;
|
|
1607
|
+
k = 3395469782;
|
|
1608
|
+
}
|
|
1609
|
+
const temp = rotl(a, 5) + f + e + k + w[i] | 0;
|
|
1610
|
+
e = d;
|
|
1611
|
+
d = c;
|
|
1612
|
+
c = rotl(b, 30);
|
|
1613
|
+
b = a;
|
|
1614
|
+
a = temp;
|
|
1615
|
+
}
|
|
1616
|
+
h0 = h0 + a | 0;
|
|
1617
|
+
h1 = h1 + b | 0;
|
|
1618
|
+
h2 = h2 + c | 0;
|
|
1619
|
+
h3 = h3 + d | 0;
|
|
1620
|
+
h4 = h4 + e | 0;
|
|
1621
|
+
}
|
|
1622
|
+
return [
|
|
1623
|
+
h0,
|
|
1624
|
+
h1,
|
|
1625
|
+
h2,
|
|
1626
|
+
h3,
|
|
1627
|
+
h4
|
|
1628
|
+
].map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
//#region ../utils/src/policy-names.ts
|
|
1632
|
+
/**
|
|
1633
|
+
* Naming of the Postgres policies generated from a collection's security rules.
|
|
1634
|
+
*
|
|
1635
|
+
* A rule without an explicit `name` is compiled to `<table>_<op>_<hash>`, where
|
|
1636
|
+
* the hash covers the rule's semantics. The Studio needs the same names to tell
|
|
1637
|
+
* "this policy came from your code" apart from "someone wrote this in SQL" —
|
|
1638
|
+
* without them it treats generated policies as foreign and offers to import
|
|
1639
|
+
* them back into the codebase they came from.
|
|
1640
|
+
*
|
|
1641
|
+
* This is the single definition of that naming. The DDL and Drizzle generators
|
|
1642
|
+
* both derive names from here, so a change cannot silently rename every policy
|
|
1643
|
+
* in every deployed database while the UI keeps matching the old ones.
|
|
1644
|
+
*/
|
|
1645
|
+
/** Stable digest of the parts of a rule that determine what the policy does. */
|
|
1646
|
+
function getPolicyNameHash(rule) {
|
|
1647
|
+
return sha1Hex(JSON.stringify({
|
|
1648
|
+
a: rule.access,
|
|
1649
|
+
m: rule.mode,
|
|
1650
|
+
op: rule.operation,
|
|
1651
|
+
ops: rule.operations?.slice().sort(),
|
|
1652
|
+
own: rule.ownerField,
|
|
1653
|
+
rol: rule.roles?.slice().sort(),
|
|
1654
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
1655
|
+
u: rule.using,
|
|
1656
|
+
w: rule.withCheck,
|
|
1657
|
+
c: rule.condition,
|
|
1658
|
+
ch: rule.check
|
|
1659
|
+
})).substring(0, 7);
|
|
1660
|
+
}
|
|
1661
|
+
/** The operations a rule expands to — `operations` wins over `operation`. */
|
|
1662
|
+
function getPolicyOperations(rule) {
|
|
1663
|
+
return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Every Postgres policy name a single rule compiles to — one per operation.
|
|
1667
|
+
*
|
|
1668
|
+
* @param rule The security rule as written in the collection config.
|
|
1669
|
+
* @param tableName The rule's table (see `getTableName` in `@rebasepro/common`).
|
|
1670
|
+
*/
|
|
1671
|
+
function getPolicyNamesForRule(rule, tableName) {
|
|
1672
|
+
const ops = getPolicyOperations(rule);
|
|
1673
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
1674
|
+
return ops.map((op, opIdx) => rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`);
|
|
1675
|
+
}
|
|
1676
|
+
//#endregion
|
|
1505
1677
|
//#region ../utils/src/names.ts
|
|
1506
1678
|
/**
|
|
1507
1679
|
* Generates a foreign key column name from a given string, typically a collection slug or name.
|
|
@@ -1626,6 +1798,109 @@ function createRelationRefWithData(id, path, data) {
|
|
|
1626
1798
|
data
|
|
1627
1799
|
};
|
|
1628
1800
|
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Derive a row's address from its key columns.
|
|
1803
|
+
*
|
|
1804
|
+
* Single key → the value as a string. Composite → each part joined by
|
|
1805
|
+
* {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what
|
|
1806
|
+
* {@link parseIdValues} expects to invert.
|
|
1807
|
+
*/
|
|
1808
|
+
function buildCompositeId(values, primaryKeys) {
|
|
1809
|
+
if (primaryKeys.length === 0) return "";
|
|
1810
|
+
if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
|
|
1811
|
+
return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Invert {@link buildCompositeId}: turn an address back into key columns, each
|
|
1815
|
+
* coerced to the type its column actually round-trips as.
|
|
1816
|
+
*
|
|
1817
|
+
* This is the boundary where a URL segment becomes a query parameter, so a
|
|
1818
|
+
* malformed address must throw rather than silently produce a query that
|
|
1819
|
+
* matches the wrong row (or none).
|
|
1820
|
+
*/
|
|
1821
|
+
function parseIdValues(idValue, primaryKeys) {
|
|
1822
|
+
const result = {};
|
|
1823
|
+
if (primaryKeys.length === 0) return result;
|
|
1824
|
+
if (primaryKeys.length === 1) {
|
|
1825
|
+
const pk = primaryKeys[0];
|
|
1826
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
1827
|
+
const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
|
|
1828
|
+
if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
1829
|
+
result[pk.fieldName] = parsed;
|
|
1830
|
+
} else result[pk.fieldName] = String(idValue);
|
|
1831
|
+
return result;
|
|
1832
|
+
}
|
|
1833
|
+
const parts = String(idValue).split(":::");
|
|
1834
|
+
if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
|
|
1835
|
+
for (let i = 0; i < primaryKeys.length; i++) {
|
|
1836
|
+
const pk = primaryKeys[i];
|
|
1837
|
+
const val = parts[i];
|
|
1838
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
1839
|
+
const parsed = parseInt(val, 10);
|
|
1840
|
+
if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
|
|
1841
|
+
result[pk.fieldName] = parsed;
|
|
1842
|
+
} else result[pk.fieldName] = val;
|
|
1843
|
+
}
|
|
1844
|
+
return result;
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* The primary keys of a collection, as declared by its properties.
|
|
1848
|
+
*
|
|
1849
|
+
* This is the only tier both sides can read, because it is the only one written
|
|
1850
|
+
* in the config: the postgres driver can also infer keys from the Drizzle
|
|
1851
|
+
* schema, which the browser never sees and is never sent — the admin compiles
|
|
1852
|
+
* the collection files into its own bundle rather than being served them. A key
|
|
1853
|
+
* that lives only in the Drizzle schema is therefore invisible here, and the
|
|
1854
|
+
* server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
|
|
1855
|
+
* to add.
|
|
1856
|
+
*
|
|
1857
|
+
* Returns an empty array when a collection declares none, which callers must
|
|
1858
|
+
* treat as "not addressable" rather than defaulting to `id`: guessing a key
|
|
1859
|
+
* that is not the real one produces confidently wrong addresses.
|
|
1860
|
+
*/
|
|
1861
|
+
function getDeclaredPrimaryKeys(collection) {
|
|
1862
|
+
const properties = collection.properties;
|
|
1863
|
+
if (!properties) return [];
|
|
1864
|
+
const keys = [];
|
|
1865
|
+
for (const [fieldName, propRaw] of Object.entries(properties)) {
|
|
1866
|
+
const prop = propRaw;
|
|
1867
|
+
if (!prop || typeof prop !== "object") continue;
|
|
1868
|
+
if (!("isId" in prop) || !prop.isId) continue;
|
|
1869
|
+
keys.push({
|
|
1870
|
+
fieldName,
|
|
1871
|
+
type: prop.type === "number" ? "number" : "string",
|
|
1872
|
+
isUUID: prop.isId === "uuid"
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
return keys;
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* The keys to address a collection's rows with, resolved the way the driver
|
|
1879
|
+
* resolves them — minus the tier the browser cannot reach.
|
|
1880
|
+
*
|
|
1881
|
+
* The postgres driver tries, in order: properties marked `isId`; the primary
|
|
1882
|
+
* keys of the Drizzle schema; and finally a column literally named `id`. Only
|
|
1883
|
+
* the first and last are visible in a `CollectionConfig`, which is what both
|
|
1884
|
+
* sides share.
|
|
1885
|
+
*
|
|
1886
|
+
* So the two agree except on a collection that declares no `isId` and whose key
|
|
1887
|
+
* is known only to Drizzle. There, the driver reads the real key, and this
|
|
1888
|
+
* either resolves nothing (reported to the console by the caller) or — if the
|
|
1889
|
+
* table happens to have an unrelated `id` property — resolves `id`, which is
|
|
1890
|
+
* the wrong key and cannot be detected from here: the addresses look right and
|
|
1891
|
+
* route wrong. Only the config can settle it, so the server names both cases
|
|
1892
|
+
* at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
|
|
1893
|
+
*/
|
|
1894
|
+
function resolvePrimaryKeys(collection) {
|
|
1895
|
+
const declared = getDeclaredPrimaryKeys(collection);
|
|
1896
|
+
if (declared.length > 0) return declared;
|
|
1897
|
+
const idProp = collection.properties?.id;
|
|
1898
|
+
if (idProp && typeof idProp === "object") return [{
|
|
1899
|
+
fieldName: "id",
|
|
1900
|
+
type: idProp.type === "number" ? "number" : "string"
|
|
1901
|
+
}];
|
|
1902
|
+
return [];
|
|
1903
|
+
}
|
|
1629
1904
|
//#endregion
|
|
1630
1905
|
//#region ../common/src/util/enums.ts
|
|
1631
1906
|
function enumToObjectEntries(enumValues) {
|
|
@@ -1859,7 +2134,7 @@ function getSubcollections(collection) {
|
|
|
1859
2134
|
* It handles:
|
|
1860
2135
|
* - `field = 'literal'`
|
|
1861
2136
|
* - `field != 'literal'`
|
|
1862
|
-
* - `field = current_setting('app.
|
|
2137
|
+
* - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
|
|
1863
2138
|
* - `A AND B`, `A OR B` — only where the keyword is at the top level
|
|
1864
2139
|
* - `true`
|
|
1865
2140
|
* - `IN (...)` (as optimistic true)
|
|
@@ -2060,7 +2335,7 @@ function findAnonymousGrants(expr) {
|
|
|
2060
2335
|
return found;
|
|
2061
2336
|
}
|
|
2062
2337
|
function parseOperand(str) {
|
|
2063
|
-
if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
|
|
2338
|
+
if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
|
|
2064
2339
|
const stringMatch = str.match(/^'(.+)'$/);
|
|
2065
2340
|
if (stringMatch) return policy.literal(stringMatch[1]);
|
|
2066
2341
|
if (/^\w+$/.test(str)) return policy.field(str);
|
|
@@ -2284,6 +2559,307 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
2284
2559
|
};
|
|
2285
2560
|
return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
|
|
2286
2561
|
};
|
|
2562
|
+
//#endregion
|
|
2563
|
+
//#region ../common/src/util/auth-default-policies.ts
|
|
2564
|
+
/**
|
|
2565
|
+
* Default RLS policies injected by the schema generator.
|
|
2566
|
+
*
|
|
2567
|
+
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
2568
|
+
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
2569
|
+
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
2570
|
+
* authorization model. The server context (auth flows, migrations,
|
|
2571
|
+
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
2572
|
+
*
|
|
2573
|
+
* Because RLS default-denies, every collection is **locked by default**: with
|
|
2574
|
+
* no rules, only the server context and admins can touch it. The generator
|
|
2575
|
+
* injects that safe baseline:
|
|
2576
|
+
*
|
|
2577
|
+
* **For every collection**
|
|
2578
|
+
* 1. A permissive **server-or-admin SELECT** grant.
|
|
2579
|
+
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
2580
|
+
*
|
|
2581
|
+
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
2582
|
+
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
2583
|
+
* rows").
|
|
2584
|
+
*
|
|
2585
|
+
* **For auth collections additionally**
|
|
2586
|
+
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
2587
|
+
* their own row (profile, session bootstrap) without every app re-declaring
|
|
2588
|
+
* it.
|
|
2589
|
+
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
2590
|
+
* every other policy, so a write is rejected unless the caller is an admin
|
|
2591
|
+
* (or the server context) — even if the author also wrote a permissive rule
|
|
2592
|
+
* such as "a user may edit their own row". Without this, a permissive owner
|
|
2593
|
+
* rule would let a user change their own `roles`.
|
|
2594
|
+
*
|
|
2595
|
+
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
2596
|
+
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
2597
|
+
* GUC — which also lets the owner connection satisfy these policies even under
|
|
2598
|
+
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
2599
|
+
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
2600
|
+
*
|
|
2601
|
+
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
2602
|
+
* the collection's RLS.
|
|
2603
|
+
*/
|
|
2604
|
+
var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2605
|
+
/** Write operations that must be admin-gated by default on auth collections. */
|
|
2606
|
+
var DEFAULT_GUARDED_OPS = [
|
|
2607
|
+
"insert",
|
|
2608
|
+
"update",
|
|
2609
|
+
"delete"
|
|
2610
|
+
];
|
|
2611
|
+
/** Whether a collection is flagged as an authentication collection. */
|
|
2612
|
+
function isAuthCollection(collection) {
|
|
2613
|
+
const auth = collection.auth;
|
|
2614
|
+
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
2615
|
+
}
|
|
2616
|
+
/** The property marked as the row id (falls back to `id`). */
|
|
2617
|
+
function getIdPropertyName$1(collection) {
|
|
2618
|
+
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2619
|
+
return "id";
|
|
2620
|
+
}
|
|
2621
|
+
/**
|
|
2622
|
+
* Returns the security rules that should be applied to a collection: the
|
|
2623
|
+
* author's explicit `securityRules` plus the framework defaults described in
|
|
2624
|
+
* the module doc (baseline server/admin read for all collections; self-read
|
|
2625
|
+
* and the admin write gate for auth collections).
|
|
2626
|
+
*
|
|
2627
|
+
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
2628
|
+
*/
|
|
2629
|
+
function getEffectiveSecurityRules(collection) {
|
|
2630
|
+
const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
|
|
2631
|
+
if (collection.disableDefaultPolicies) return explicit;
|
|
2632
|
+
const tableName = getTableName$1(collection);
|
|
2633
|
+
const injected = [];
|
|
2634
|
+
injected.push({
|
|
2635
|
+
name: `${tableName}_default_admin_read`,
|
|
2636
|
+
operations: ["select"],
|
|
2637
|
+
condition: SERVER_OR_ADMIN_EXPR$1
|
|
2638
|
+
});
|
|
2639
|
+
injected.push({
|
|
2640
|
+
name: `${tableName}_default_admin_write`,
|
|
2641
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
2642
|
+
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2643
|
+
check: SERVER_OR_ADMIN_EXPR$1
|
|
2644
|
+
});
|
|
2645
|
+
if (isAuthCollection(collection)) {
|
|
2646
|
+
injected.push({
|
|
2647
|
+
name: `${tableName}_default_self_read`,
|
|
2648
|
+
operations: ["select"],
|
|
2649
|
+
condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
|
|
2650
|
+
});
|
|
2651
|
+
injected.push({
|
|
2652
|
+
name: `${tableName}_require_admin_write`,
|
|
2653
|
+
mode: "restrictive",
|
|
2654
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
2655
|
+
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2656
|
+
check: SERVER_OR_ADMIN_EXPR$1
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
return [...explicit, ...injected];
|
|
2660
|
+
}
|
|
2661
|
+
//#endregion
|
|
2662
|
+
//#region ../common/src/util/junction-policies.ts
|
|
2663
|
+
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2664
|
+
/**
|
|
2665
|
+
* Walk every collection's resolved relations and aggregate the junction tables
|
|
2666
|
+
* they declare. Two collections may declare the same junction from opposite
|
|
2667
|
+
* sides (posts→tags and tags→posts through `posts_tags`); both become
|
|
2668
|
+
* `declaringSides` of one spec, so derived write grants consider both.
|
|
2669
|
+
*/
|
|
2670
|
+
function resolveJunctionSpecs(collections) {
|
|
2671
|
+
const specs = /* @__PURE__ */ new Map();
|
|
2672
|
+
for (const collection of collections) {
|
|
2673
|
+
const resolved = resolveCollectionRelations(collection);
|
|
2674
|
+
for (const relation of Object.values(resolved)) {
|
|
2675
|
+
if (!relation.through) continue;
|
|
2676
|
+
const targetCollection = typeof relation.target === "function" ? relation.target() : void 0;
|
|
2677
|
+
if (!targetCollection) continue;
|
|
2678
|
+
const rawName = relation.through.table;
|
|
2679
|
+
const table = rawName.includes(".") ? rawName.split(".").pop() : rawName;
|
|
2680
|
+
const schema = "public";
|
|
2681
|
+
const source = {
|
|
2682
|
+
collection,
|
|
2683
|
+
junctionColumn: relation.through.sourceColumn,
|
|
2684
|
+
relation
|
|
2685
|
+
};
|
|
2686
|
+
const target = {
|
|
2687
|
+
collection: targetCollection,
|
|
2688
|
+
junctionColumn: relation.through.targetColumn
|
|
2689
|
+
};
|
|
2690
|
+
const existing = specs.get(table);
|
|
2691
|
+
if (!existing) specs.set(table, {
|
|
2692
|
+
table,
|
|
2693
|
+
schema,
|
|
2694
|
+
endpoints: [source, target],
|
|
2695
|
+
declaringSides: [source]
|
|
2696
|
+
});
|
|
2697
|
+
else if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
return specs;
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* A synthetic CollectionConfig standing in for the junction during policy
|
|
2704
|
+
* compilation and naming. Its two FK columns carry explicit `columnName`s so
|
|
2705
|
+
* `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
|
|
2706
|
+
* whatever their casing.
|
|
2707
|
+
*/
|
|
2708
|
+
function getJunctionCollectionConfig(spec) {
|
|
2709
|
+
const properties = {};
|
|
2710
|
+
for (const endpoint of spec.endpoints) properties[endpoint.junctionColumn] = {
|
|
2711
|
+
type: "string",
|
|
2712
|
+
columnName: endpoint.junctionColumn
|
|
2713
|
+
};
|
|
2714
|
+
return {
|
|
2715
|
+
slug: spec.table,
|
|
2716
|
+
name: spec.table,
|
|
2717
|
+
table: spec.table,
|
|
2718
|
+
schema: spec.schema,
|
|
2719
|
+
properties
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
/** The property marked as the row id (falls back to `id`). */
|
|
2723
|
+
function getIdPropertyName(collection) {
|
|
2724
|
+
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2725
|
+
return "id";
|
|
2726
|
+
}
|
|
2727
|
+
/** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */
|
|
2728
|
+
function existsEndpoint(endpoint, extra) {
|
|
2729
|
+
const correlation = policy.compare(policy.field(getIdPropertyName(endpoint.collection)), "eq", policy.outerField(endpoint.junctionColumn));
|
|
2730
|
+
return policy.existsIn({
|
|
2731
|
+
collection: endpoint.collection.slug,
|
|
2732
|
+
where: extra ? policy.and(correlation, extra) : correlation
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Whether a parent-rule expression keeps its meaning when moved inside the
|
|
2737
|
+
* junction's `EXISTS` subquery — and the re-scoped copy if it does.
|
|
2738
|
+
*
|
|
2739
|
+
* Returns `null` when the rule cannot be embedded faithfully: `raw` SQL
|
|
2740
|
+
* anywhere (its `{column}` placeholders would bind to the junction), or an
|
|
2741
|
+
* `outerField` inside a nested `existsIn` (it would bind to the junction while
|
|
2742
|
+
* the author meant the parent, and no operand can express "the middle scope").
|
|
2743
|
+
* Top-level `outerField`s are rewritten to `field`, which is what they meant.
|
|
2744
|
+
*/
|
|
2745
|
+
function embedParentExpression(expr, depth = 0) {
|
|
2746
|
+
switch (expr.kind) {
|
|
2747
|
+
case "raw": return null;
|
|
2748
|
+
case "and":
|
|
2749
|
+
case "or": {
|
|
2750
|
+
const parts = [];
|
|
2751
|
+
for (const child of expr.operands) {
|
|
2752
|
+
const embedded = embedParentExpression(child, depth);
|
|
2753
|
+
if (!embedded) return null;
|
|
2754
|
+
parts.push(embedded);
|
|
2755
|
+
}
|
|
2756
|
+
return expr.kind === "and" ? policy.and(...parts) : policy.or(...parts);
|
|
2757
|
+
}
|
|
2758
|
+
case "not": {
|
|
2759
|
+
const embedded = embedParentExpression(expr.operand, depth);
|
|
2760
|
+
return embedded ? policy.not(embedded) : null;
|
|
2761
|
+
}
|
|
2762
|
+
case "existsIn": {
|
|
2763
|
+
const where = embedParentExpression(expr.where, depth + 1);
|
|
2764
|
+
return where ? policy.existsIn({
|
|
2765
|
+
collection: expr.collection,
|
|
2766
|
+
where
|
|
2767
|
+
}) : null;
|
|
2768
|
+
}
|
|
2769
|
+
case "compare": {
|
|
2770
|
+
const left = embedOperand(expr.left, depth);
|
|
2771
|
+
const right = embedOperand(expr.right, depth);
|
|
2772
|
+
if (!left || !right) return null;
|
|
2773
|
+
return {
|
|
2774
|
+
...expr,
|
|
2775
|
+
left,
|
|
2776
|
+
right
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
default: return expr;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
/** Re-scope an operand, or return `null` if its binding cannot be preserved. */
|
|
2783
|
+
function embedOperand(operand, depth) {
|
|
2784
|
+
if (operand.kind === "outerField") {
|
|
2785
|
+
if (depth === 0) return policy.field(operand.name);
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
return operand;
|
|
2789
|
+
}
|
|
2790
|
+
/** Does the rule cover the `update` operation? */
|
|
2791
|
+
function coversUpdate(rule) {
|
|
2792
|
+
return getPolicyOperations(rule).some((op) => op === "update" || op === "all");
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* The full derived policy set for a junction table: the locked server/admin
|
|
2796
|
+
* baseline, the endpoint-visibility read grant, inherited write grants, and
|
|
2797
|
+
* inherited restrictive gates. Returns `[]` when every declaring collection set
|
|
2798
|
+
* `disableDefaultPolicies` — the junction is then the author's to police, and
|
|
2799
|
+
* stays locked (RLS is still enabled) until they write policies for it.
|
|
2800
|
+
*/
|
|
2801
|
+
function getJunctionSecurityRules(spec) {
|
|
2802
|
+
if (spec.declaringSides.every((side) => side.collection.disableDefaultPolicies)) return [];
|
|
2803
|
+
const rules = [];
|
|
2804
|
+
rules.push({
|
|
2805
|
+
name: `${spec.table}_default_admin_read`,
|
|
2806
|
+
operations: ["select"],
|
|
2807
|
+
condition: SERVER_OR_ADMIN_EXPR
|
|
2808
|
+
});
|
|
2809
|
+
rules.push({
|
|
2810
|
+
name: `${spec.table}_default_admin_write`,
|
|
2811
|
+
operations: [
|
|
2812
|
+
"insert",
|
|
2813
|
+
"update",
|
|
2814
|
+
"delete"
|
|
2815
|
+
],
|
|
2816
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
2817
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
2818
|
+
});
|
|
2819
|
+
rules.push({
|
|
2820
|
+
name: `${spec.table}_default_edge_read`,
|
|
2821
|
+
operations: ["select"],
|
|
2822
|
+
condition: policy.and(existsEndpoint(spec.endpoints[0]), existsEndpoint(spec.endpoints[1]))
|
|
2823
|
+
});
|
|
2824
|
+
const writeGrants = [];
|
|
2825
|
+
for (const side of spec.declaringSides) {
|
|
2826
|
+
const updateRules = ((isPostgresCollectionConfig(side.collection) ? side.collection.securityRules : void 0) ?? []).filter(coversUpdate);
|
|
2827
|
+
const permissive = updateRules.filter((r) => r.mode !== "restrictive");
|
|
2828
|
+
const restrictive = updateRules.filter((r) => r.mode === "restrictive");
|
|
2829
|
+
const embeddedGates = [];
|
|
2830
|
+
let gatesEmbeddable = true;
|
|
2831
|
+
for (const gate of restrictive) {
|
|
2832
|
+
const using = securityRuleToConditions(gate).usingExpr;
|
|
2833
|
+
const embedded = using ? embedParentExpression(using) : null;
|
|
2834
|
+
if (!embedded) {
|
|
2835
|
+
gatesEmbeddable = false;
|
|
2836
|
+
break;
|
|
2837
|
+
}
|
|
2838
|
+
embeddedGates.push(embedded);
|
|
2839
|
+
}
|
|
2840
|
+
if (!gatesEmbeddable) continue;
|
|
2841
|
+
const grants = [];
|
|
2842
|
+
for (const rule of permissive) {
|
|
2843
|
+
const using = securityRuleToConditions(rule).usingExpr;
|
|
2844
|
+
const embedded = using ? embedParentExpression(using) : null;
|
|
2845
|
+
if (embedded) grants.push(embedded);
|
|
2846
|
+
}
|
|
2847
|
+
if (grants.length === 0) continue;
|
|
2848
|
+
const condition = embeddedGates.length > 0 ? policy.and(policy.or(...grants), ...embeddedGates) : policy.or(...grants);
|
|
2849
|
+
writeGrants.push(existsEndpoint(side, condition));
|
|
2850
|
+
}
|
|
2851
|
+
if (writeGrants.length > 0) rules.push({
|
|
2852
|
+
name: `${spec.table}_default_edge_write`,
|
|
2853
|
+
operations: [
|
|
2854
|
+
"insert",
|
|
2855
|
+
"update",
|
|
2856
|
+
"delete"
|
|
2857
|
+
],
|
|
2858
|
+
condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),
|
|
2859
|
+
check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)
|
|
2860
|
+
});
|
|
2861
|
+
return rules;
|
|
2862
|
+
}
|
|
2287
2863
|
(/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
2288
2864
|
(function(root, factory) {
|
|
2289
2865
|
if (typeof define === "function" && define.amd) define(factory);
|
|
@@ -3553,18 +4129,45 @@ function deserializeFilter(query) {
|
|
|
3553
4129
|
}
|
|
3554
4130
|
//#endregion
|
|
3555
4131
|
//#region ../common/src/data/buildRebaseData.ts
|
|
4132
|
+
function createPrimaryKeyResolver(options) {
|
|
4133
|
+
const cache = /* @__PURE__ */ new Map();
|
|
4134
|
+
const warned = /* @__PURE__ */ new Set();
|
|
4135
|
+
return function primaryKeysFor(slug) {
|
|
4136
|
+
const cached = cache.get(slug);
|
|
4137
|
+
if (cached) return cached;
|
|
4138
|
+
const collection = options?.resolveCollection?.(slug);
|
|
4139
|
+
if (!collection) return [];
|
|
4140
|
+
const keys = resolvePrimaryKeys(collection);
|
|
4141
|
+
if (keys.length > 0) {
|
|
4142
|
+
cache.set(slug, keys);
|
|
4143
|
+
return keys;
|
|
4144
|
+
}
|
|
4145
|
+
if (!warned.has(slug)) {
|
|
4146
|
+
warned.add(slug);
|
|
4147
|
+
console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
|
|
4148
|
+
}
|
|
4149
|
+
return keys;
|
|
4150
|
+
};
|
|
4151
|
+
}
|
|
3556
4152
|
/**
|
|
3557
|
-
*
|
|
3558
|
-
*
|
|
4153
|
+
* Give a flat row the Entity view-model the admin renders.
|
|
4154
|
+
*
|
|
4155
|
+
* The address is *derived here* — it is not a column, and the row it came from
|
|
4156
|
+
* does not contain one. Rows carry exactly what the table has, with the types
|
|
4157
|
+
* Postgres returned; the id is this layer's invention, and this is the only
|
|
4158
|
+
* place it is minted.
|
|
4159
|
+
*
|
|
4160
|
+
* `primaryKeys` empty falls back to a literal `id` on the row: drivers other
|
|
4161
|
+
* than postgres still serve rows with one, and this keeps them working.
|
|
3559
4162
|
*/
|
|
3560
|
-
function rowToEntity(row, slug) {
|
|
4163
|
+
function rowToEntity(row, slug, primaryKeys = []) {
|
|
3561
4164
|
return {
|
|
3562
|
-
id: row.id,
|
|
4165
|
+
id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
|
|
3563
4166
|
path: slug,
|
|
3564
4167
|
values: row
|
|
3565
4168
|
};
|
|
3566
4169
|
}
|
|
3567
|
-
function createDriverAccessor(driver, slug) {
|
|
4170
|
+
function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
3568
4171
|
const accessor = {
|
|
3569
4172
|
async find(params) {
|
|
3570
4173
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
@@ -3597,7 +4200,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
3597
4200
|
hasMore = offset + rows.length < total;
|
|
3598
4201
|
}
|
|
3599
4202
|
return {
|
|
3600
|
-
data: rows.map((row) => rowToEntity(row, slug)),
|
|
4203
|
+
data: rows.map((row) => rowToEntity(row, slug, getPks())),
|
|
3601
4204
|
meta: {
|
|
3602
4205
|
total,
|
|
3603
4206
|
limit,
|
|
@@ -3611,7 +4214,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
3611
4214
|
path: slug,
|
|
3612
4215
|
id
|
|
3613
4216
|
});
|
|
3614
|
-
return row ? rowToEntity(row, slug) : void 0;
|
|
4217
|
+
return row ? rowToEntity(row, slug, getPks()) : void 0;
|
|
3615
4218
|
},
|
|
3616
4219
|
async create(data, id) {
|
|
3617
4220
|
return rowToEntity(await driver.save({
|
|
@@ -3619,15 +4222,22 @@ function createDriverAccessor(driver, slug) {
|
|
|
3619
4222
|
values: data,
|
|
3620
4223
|
id,
|
|
3621
4224
|
status: "new"
|
|
3622
|
-
}), slug);
|
|
4225
|
+
}), slug, getPks());
|
|
3623
4226
|
},
|
|
4227
|
+
createMany: driver.saveMany ? async (data, options) => {
|
|
4228
|
+
return (await driver.saveMany({
|
|
4229
|
+
path: slug,
|
|
4230
|
+
rows: data,
|
|
4231
|
+
upsert: options?.upsert
|
|
4232
|
+
})).map((row) => rowToEntity(row, slug, getPks()));
|
|
4233
|
+
} : void 0,
|
|
3624
4234
|
async update(id, data) {
|
|
3625
4235
|
return rowToEntity(await driver.save({
|
|
3626
4236
|
path: slug,
|
|
3627
4237
|
values: data,
|
|
3628
4238
|
id,
|
|
3629
4239
|
status: "existing"
|
|
3630
|
-
}), slug);
|
|
4240
|
+
}), slug, getPks());
|
|
3631
4241
|
},
|
|
3632
4242
|
async delete(id) {
|
|
3633
4243
|
return driver.delete({ row: {
|
|
@@ -3656,7 +4266,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
3656
4266
|
searchString: params?.searchString,
|
|
3657
4267
|
onUpdate: (entities) => {
|
|
3658
4268
|
onUpdate({
|
|
3659
|
-
data: entities.map((row) => rowToEntity(row, slug)),
|
|
4269
|
+
data: entities.map((row) => rowToEntity(row, slug, getPks())),
|
|
3660
4270
|
meta: {
|
|
3661
4271
|
total: entities.length,
|
|
3662
4272
|
limit,
|
|
@@ -3672,7 +4282,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
3672
4282
|
return driver.listenOne({
|
|
3673
4283
|
path: slug,
|
|
3674
4284
|
id,
|
|
3675
|
-
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
|
|
4285
|
+
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
|
|
3676
4286
|
onError
|
|
3677
4287
|
});
|
|
3678
4288
|
} : void 0,
|
|
@@ -3711,12 +4321,13 @@ function createDriverAccessor(driver, slug) {
|
|
|
3711
4321
|
* await data.products.create({ name: "Camera", price: 299 });
|
|
3712
4322
|
* const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
|
|
3713
4323
|
*/
|
|
3714
|
-
function buildRebaseData(driver) {
|
|
4324
|
+
function buildRebaseData(driver, options) {
|
|
3715
4325
|
const cache = /* @__PURE__ */ new Map();
|
|
4326
|
+
const primaryKeysFor = createPrimaryKeyResolver(options);
|
|
3716
4327
|
function getAccessor(slug) {
|
|
3717
4328
|
let accessor = cache.get(slug);
|
|
3718
4329
|
if (!accessor) {
|
|
3719
|
-
accessor = createDriverAccessor(driver, slug);
|
|
4330
|
+
accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
|
|
3720
4331
|
cache.set(slug, accessor);
|
|
3721
4332
|
}
|
|
3722
4333
|
return accessor;
|
|
@@ -3729,8 +4340,9 @@ function buildRebaseData(driver) {
|
|
|
3729
4340
|
} });
|
|
3730
4341
|
}
|
|
3731
4342
|
/**
|
|
3732
|
-
* Unwrap a Entity into
|
|
3733
|
-
*
|
|
4343
|
+
* Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
|
|
4344
|
+
* the row untouched under `.values` and derives `.id` alongside it, so dropping
|
|
4345
|
+
* the wrapper is the whole operation — the address was never part of the row.
|
|
3734
4346
|
*/
|
|
3735
4347
|
function entityToRow(entity) {
|
|
3736
4348
|
return entity.values;
|
|
@@ -3817,6 +4429,12 @@ function toSdkCollectionClient(snap) {
|
|
|
3817
4429
|
async create(data, id) {
|
|
3818
4430
|
return entityToRow(await snap.create(data, id));
|
|
3819
4431
|
},
|
|
4432
|
+
async createMany(data, options) {
|
|
4433
|
+
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
4434
|
+
if (data.length === 0) return [];
|
|
4435
|
+
if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
|
|
4436
|
+
return (await snap.createMany(data, options)).map(entityToRow);
|
|
4437
|
+
},
|
|
3820
4438
|
async update(id, data) {
|
|
3821
4439
|
return entityToRow(await snap.update(id, data));
|
|
3822
4440
|
},
|
|
@@ -3969,8 +4587,26 @@ function getTableForCollection(collection, registry) {
|
|
|
3969
4587
|
if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
|
|
3970
4588
|
return table;
|
|
3971
4589
|
}
|
|
4590
|
+
/**
|
|
4591
|
+
* The key columns a collection's rows are addressed by.
|
|
4592
|
+
*
|
|
4593
|
+
* Three tiers, in order: properties marked `isId`, the primary keys of the
|
|
4594
|
+
* drizzle schema, and finally a column literally named `id`. Only the first is
|
|
4595
|
+
* visible to the browser, which is why a key known only to drizzle is reported
|
|
4596
|
+
* at boot — see {@link warnOnKeysTheAdminCannotResolve}.
|
|
4597
|
+
*
|
|
4598
|
+
* Returns `[]` when nothing resolves, rather than throwing. It used to open by
|
|
4599
|
+
* resolving the table, which throws when there is none — so the `isId` tier,
|
|
4600
|
+
* which needs no table at all, was unreachable for exactly the collections
|
|
4601
|
+
* most likely to have no table registered. Every caller that wanted "no keys"
|
|
4602
|
+
* to mean "no keys" had to spell that out in a try/catch.
|
|
4603
|
+
*
|
|
4604
|
+
* Callers that cannot proceed without a key must say so themselves, naming the
|
|
4605
|
+
* collection: an empty array here means "this collection has no address", which
|
|
4606
|
+
* is a different answer in a notification (broadcast a wildcard) than in a save
|
|
4607
|
+
* (fail).
|
|
4608
|
+
*/
|
|
3972
4609
|
function getPrimaryKeys(collection, registry) {
|
|
3973
|
-
const table = getTableForCollection(collection, registry);
|
|
3974
4610
|
if (collection.properties) {
|
|
3975
4611
|
const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
|
|
3976
4612
|
fieldName: key,
|
|
@@ -3979,6 +4615,8 @@ function getPrimaryKeys(collection, registry) {
|
|
|
3979
4615
|
}));
|
|
3980
4616
|
if (idProps.length > 0) return idProps;
|
|
3981
4617
|
}
|
|
4618
|
+
const table = registry.getTable(getTableName$1(collection));
|
|
4619
|
+
if (!table) return [];
|
|
3982
4620
|
const keys = [];
|
|
3983
4621
|
for (const [key, colRaw] of Object.entries(table)) {
|
|
3984
4622
|
const col = colRaw;
|
|
@@ -4006,35 +4644,89 @@ function getPrimaryKeys(collection, registry) {
|
|
|
4006
4644
|
}
|
|
4007
4645
|
return keys;
|
|
4008
4646
|
}
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4647
|
+
/**
|
|
4648
|
+
* The key columns, for callers that cannot do their job without one.
|
|
4649
|
+
*
|
|
4650
|
+
* {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
|
|
4651
|
+
* collection with no address. Most of this driver, though, is building a WHERE
|
|
4652
|
+
* clause and has no meaning without a key — for those, an empty array is not an
|
|
4653
|
+
* answer, and indexing `[0]` into it produces `Cannot read properties of
|
|
4654
|
+
* undefined` three frames from where the real problem is. This says what is
|
|
4655
|
+
* wrong and which collection it is wrong about.
|
|
4656
|
+
*/
|
|
4657
|
+
function requirePrimaryKeys(collection, registry) {
|
|
4658
|
+
const keys = getPrimaryKeys(collection, registry);
|
|
4659
|
+
if (keys.length === 0) throw new Error(`Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`);
|
|
4660
|
+
return keys;
|
|
4661
|
+
}
|
|
4662
|
+
/**
|
|
4663
|
+
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
4664
|
+
* instead.
|
|
4665
|
+
*
|
|
4666
|
+
* The two sides resolve keys from different evidence. This driver reads, in
|
|
4667
|
+
* order: properties marked `isId`, the primary keys of the Drizzle schema, then
|
|
4668
|
+
* a column literally named `id`. The admin shares the `CollectionConfig` — it
|
|
4669
|
+
* compiles the same collection files into its bundle — but never the Drizzle
|
|
4670
|
+
* schema, so the middle tier is invisible to it.
|
|
4671
|
+
*
|
|
4672
|
+
* Nothing can normalize this at runtime: the server does not serve the admin
|
|
4673
|
+
* its collections, so a key resolved here cannot be handed over there. The
|
|
4674
|
+
* config files are the only thing both sides read, so the fix is an edit to
|
|
4675
|
+
* them, and the most this can do is say exactly which edit.
|
|
4676
|
+
*
|
|
4677
|
+
* Two shapes, and the second is the dangerous one:
|
|
4678
|
+
*
|
|
4679
|
+
* - No `isId`, no `id` property → the admin resolves no address, warns in the
|
|
4680
|
+
* console, and rows cannot be opened or linked.
|
|
4681
|
+
* - No `isId`, but an `id` property that is *not* the key → the admin addresses
|
|
4682
|
+
* rows by `id` while this driver reads the address as the real key. Nothing
|
|
4683
|
+
* errors: the addresses look right and route wrong.
|
|
4684
|
+
*/
|
|
4685
|
+
function findUnresolvableKeyCollections(collections, registry) {
|
|
4686
|
+
const findings = [];
|
|
4687
|
+
for (const collection of collections) {
|
|
4688
|
+
if (getDeclaredPrimaryKeys(collection).length > 0) continue;
|
|
4689
|
+
const keys = getPrimaryKeys(collection, registry);
|
|
4690
|
+
if (keys.length === 0) continue;
|
|
4691
|
+
if (keys.length === 1 && keys[0].fieldName === "id") continue;
|
|
4692
|
+
findings.push({
|
|
4693
|
+
collection,
|
|
4694
|
+
keys,
|
|
4695
|
+
shadowedByIdProperty: Boolean(collection.properties?.id)
|
|
4696
|
+
});
|
|
4031
4697
|
}
|
|
4032
|
-
return
|
|
4698
|
+
return findings;
|
|
4033
4699
|
}
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4700
|
+
/**
|
|
4701
|
+
* Report the collections from {@link findUnresolvableKeyCollections} at boot,
|
|
4702
|
+
* with the edit that fixes each one.
|
|
4703
|
+
*
|
|
4704
|
+
* Grouped by failure, not by collection: the shadowed case is a routing bug and
|
|
4705
|
+
* the silent case is a missing feature, and they deserve different urgency.
|
|
4706
|
+
*/
|
|
4707
|
+
function warnOnKeysTheAdminCannotResolve(collections, registry) {
|
|
4708
|
+
const findings = findUnresolvableKeyCollections(collections, registry);
|
|
4709
|
+
if (findings.length === 0) return;
|
|
4710
|
+
const edit = (f) => `${f.collection.slug}: mark ${f.keys.map((k) => `\`${k.fieldName}\``).join(" and ")} with \`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
|
|
4711
|
+
const shadowed = findings.filter((f) => f.shadowedByIdProperty);
|
|
4712
|
+
const silent = findings.filter((f) => !f.shadowedByIdProperty);
|
|
4713
|
+
if (shadowed.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema — but they do have a property called `id`. The admin has no way to know `id` is not the key, so it will address rows by it while this server reads the address as the real key: the links look right and route wrong. Nothing will error.\n\n" + shadowed.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
|
|
4714
|
+
if (silent.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema, which the admin never sees. It will resolve no address for their rows, so detail links, caching and relations will not work for them:\n\n" + silent.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
|
|
4715
|
+
}
|
|
4716
|
+
/**
|
|
4717
|
+
* The address of a row: derived from the collection's primary keys, because a
|
|
4718
|
+
* row does not carry one — it is exactly its columns.
|
|
4719
|
+
*
|
|
4720
|
+
* Falls back to a literal `id` column, for a row that reached us from somewhere
|
|
4721
|
+
* other than this driver. Returns `""` when there is no key and no `id` —
|
|
4722
|
+
* callers decide what that means, since "unaddressable" is a different answer
|
|
4723
|
+
* in a notification (broadcast a wildcard) than in a save (fail).
|
|
4724
|
+
*/
|
|
4725
|
+
function deriveRowAddress(row, collection, registry) {
|
|
4726
|
+
const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
|
|
4727
|
+
if (composite && composite.split(":::").some((part) => part !== "")) return composite;
|
|
4728
|
+
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
4729
|
+
return "";
|
|
4038
4730
|
}
|
|
4039
4731
|
//#endregion
|
|
4040
4732
|
//#region src/utils/drizzle-conditions.ts
|
|
@@ -4574,6 +5266,7 @@ var DrizzleConditionBuilder = class {
|
|
|
4574
5266
|
static buildVectorSearchConditions(table, vectorSearch) {
|
|
4575
5267
|
const column = table[vectorSearch.property];
|
|
4576
5268
|
if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
5269
|
+
if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
4577
5270
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
4578
5271
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
4579
5272
|
let operator;
|
|
@@ -4657,12 +5350,10 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
4657
5350
|
continue;
|
|
4658
5351
|
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
4659
5352
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
4660
|
-
const pks = getPrimaryKeys(collection, registry);
|
|
4661
5353
|
inverseRelationUpdates.push({
|
|
4662
5354
|
relationKey: key,
|
|
4663
5355
|
relation,
|
|
4664
|
-
newValue: serializedValue
|
|
4665
|
-
currentId: row.id || buildCompositeId(row, pks)
|
|
5356
|
+
newValue: serializedValue
|
|
4666
5357
|
});
|
|
4667
5358
|
continue;
|
|
4668
5359
|
} else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
@@ -4672,15 +5363,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
4672
5363
|
relation,
|
|
4673
5364
|
newTargetId: serializedValue
|
|
4674
5365
|
});
|
|
4675
|
-
else {
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
newValue: serializedValue,
|
|
4681
|
-
currentId: row.id || buildCompositeId(row, pks)
|
|
4682
|
-
});
|
|
4683
|
-
}
|
|
5366
|
+
else inverseRelationUpdates.push({
|
|
5367
|
+
relationKey: key,
|
|
5368
|
+
relation,
|
|
5369
|
+
newValue: serializedValue
|
|
5370
|
+
});
|
|
4684
5371
|
continue;
|
|
4685
5372
|
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
4686
5373
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
@@ -5080,6 +5767,63 @@ var RelationService = class {
|
|
|
5080
5767
|
this.registry = registry;
|
|
5081
5768
|
}
|
|
5082
5769
|
/**
|
|
5770
|
+
* One target row, as the {@link RelatedRow} everything here returns.
|
|
5771
|
+
*
|
|
5772
|
+
* Eight sites built this by hand, which is how the address came to be the
|
|
5773
|
+
* target's first key column in all eight — one edit, eight places to miss.
|
|
5774
|
+
*
|
|
5775
|
+
* `resolveNested` is the one thing they did not agree on, and the
|
|
5776
|
+
* disagreement was invisible: the single-parent fetches pass `db` and
|
|
5777
|
+
* `registry` to `parseDataFromServer`, so the target's *own* relations get
|
|
5778
|
+
* resolved too, while the batch paths deliberately do not — a query per
|
|
5779
|
+
* target row is the N+1 the batching exists to avoid. Naming the parameter
|
|
5780
|
+
* makes that a decision rather than a difference between two call sites
|
|
5781
|
+
* nobody was comparing.
|
|
5782
|
+
*/
|
|
5783
|
+
async toRelatedRow(targetRow, targetCollection, targetPks, options) {
|
|
5784
|
+
const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
|
|
5785
|
+
return {
|
|
5786
|
+
id: buildCompositeId(targetRow, targetPks),
|
|
5787
|
+
path: targetCollection.slug,
|
|
5788
|
+
values
|
|
5789
|
+
};
|
|
5790
|
+
}
|
|
5791
|
+
/**
|
|
5792
|
+
* A WHERE matching any of `parentIds`, by the whole key.
|
|
5793
|
+
*
|
|
5794
|
+
* A single key is an `IN (…)`. A composite one cannot be: matching
|
|
5795
|
+
* `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
|
|
5796
|
+
* share their first column each receive the other's relations. It becomes
|
|
5797
|
+
* an OR of ANDs — one exact address per parent — which Postgres indexes the
|
|
5798
|
+
* same way it would a multi-column key lookup.
|
|
5799
|
+
*/
|
|
5800
|
+
parentKeyCondition(parentTable, parentPks, parentIds) {
|
|
5801
|
+
const columnFor = (fieldName) => {
|
|
5802
|
+
const col = parentTable[fieldName];
|
|
5803
|
+
if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
|
|
5804
|
+
return col;
|
|
5805
|
+
};
|
|
5806
|
+
if (parentPks.length === 1) {
|
|
5807
|
+
const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
|
|
5808
|
+
return inArray(columnFor(parentPks[0].fieldName), values);
|
|
5809
|
+
}
|
|
5810
|
+
return or(...parentIds.map((id) => {
|
|
5811
|
+
const values = parseIdValues(id, parentPks);
|
|
5812
|
+
return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
|
|
5813
|
+
}));
|
|
5814
|
+
}
|
|
5815
|
+
/**
|
|
5816
|
+
* Reject a relation that cannot express a composite-keyed parent.
|
|
5817
|
+
*
|
|
5818
|
+
* `localKey` and `foreignKeyOnTarget` are single column names: one column
|
|
5819
|
+
* cannot reference a two-column key, so such a relation has no correct
|
|
5820
|
+
* reading. Left alone it would silently match on the first key column and
|
|
5821
|
+
* hand a tenant's rows to its neighbour — say so instead.
|
|
5822
|
+
*/
|
|
5823
|
+
assertSingleKeyAddressable(parentCollection, parentPks, via) {
|
|
5824
|
+
if (parentPks.length > 1) throw new Error(`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but '${parentCollection.slug}' is keyed on ${parentPks.map((k) => `'${k.fieldName}'`).join(" + ")}. One column cannot reference a composite key — express this relation with \`joinPath\`, whose \`on.from\`/\`on.to\` take every key column.`);
|
|
5825
|
+
}
|
|
5826
|
+
/**
|
|
5083
5827
|
* Fetch rows related to a parent row through a specific relation
|
|
5084
5828
|
*/
|
|
5085
5829
|
async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
|
|
@@ -5098,9 +5842,9 @@ var RelationService = class {
|
|
|
5098
5842
|
async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
|
|
5099
5843
|
const targetCollection = relation.target();
|
|
5100
5844
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5101
|
-
const idInfo =
|
|
5845
|
+
const idInfo = requirePrimaryKeys(targetCollection, this.registry);
|
|
5102
5846
|
const idField = targetTable[idInfo[0].fieldName];
|
|
5103
|
-
const parentPks =
|
|
5847
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5104
5848
|
const parentIdInfo = parentPks[0];
|
|
5105
5849
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5106
5850
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5124,7 +5868,7 @@ var RelationService = class {
|
|
|
5124
5868
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5125
5869
|
currentTable = joinTable;
|
|
5126
5870
|
}
|
|
5127
|
-
const parentIdField = parentTable[
|
|
5871
|
+
const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
|
|
5128
5872
|
query = query.where(eq(parentIdField, parsedParentId));
|
|
5129
5873
|
if (options.limit) query = query.limit(options.limit);
|
|
5130
5874
|
const results = await query;
|
|
@@ -5132,13 +5876,7 @@ var RelationService = class {
|
|
|
5132
5876
|
const rows = [];
|
|
5133
5877
|
for (const row of results) {
|
|
5134
5878
|
const targetRow = row[targetTableName] || row;
|
|
5135
|
-
|
|
5136
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
5137
|
-
rows.push({
|
|
5138
|
-
id: id?.toString() || "",
|
|
5139
|
-
path: targetCollection.slug,
|
|
5140
|
-
values: parsedValues
|
|
5141
|
-
});
|
|
5879
|
+
rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
|
|
5142
5880
|
}
|
|
5143
5881
|
return rows;
|
|
5144
5882
|
}
|
|
@@ -5157,13 +5895,7 @@ var RelationService = class {
|
|
|
5157
5895
|
const rows = [];
|
|
5158
5896
|
for (const row of results) {
|
|
5159
5897
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
5160
|
-
|
|
5161
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
5162
|
-
rows.push({
|
|
5163
|
-
id: id?.toString() || "",
|
|
5164
|
-
path: targetCollection.slug,
|
|
5165
|
-
values: parsedValues
|
|
5166
|
-
});
|
|
5898
|
+
rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
|
|
5167
5899
|
}
|
|
5168
5900
|
return rows;
|
|
5169
5901
|
}
|
|
@@ -5180,8 +5912,8 @@ var RelationService = class {
|
|
|
5180
5912
|
}
|
|
5181
5913
|
const targetCollection = relation.target();
|
|
5182
5914
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5183
|
-
const targetIdField = targetTable[
|
|
5184
|
-
const parentPks =
|
|
5915
|
+
const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
|
|
5916
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5185
5917
|
const parentIdInfo = parentPks[0];
|
|
5186
5918
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5187
5919
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5200,9 +5932,10 @@ var RelationService = class {
|
|
|
5200
5932
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5201
5933
|
const targetCollection = relation.target();
|
|
5202
5934
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5203
|
-
const
|
|
5935
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5936
|
+
const targetIdInfo = targetPks[0];
|
|
5204
5937
|
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
5205
|
-
const parentPks =
|
|
5938
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5206
5939
|
const parentIdInfo = parentPks[0];
|
|
5207
5940
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
5208
5941
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -5226,25 +5959,19 @@ var RelationService = class {
|
|
|
5226
5959
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5227
5960
|
currentTable = joinTable;
|
|
5228
5961
|
}
|
|
5229
|
-
|
|
5230
|
-
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
5962
|
+
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
5231
5963
|
const results = await query;
|
|
5232
5964
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
5233
5965
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5234
5966
|
for (const row of results) {
|
|
5235
5967
|
const parentRow = row[getTableName$1(parentCollection)] || row;
|
|
5236
5968
|
const targetRow = row[targetTableName] || row;
|
|
5237
|
-
|
|
5238
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5239
|
-
resultMap.set(String(parentId), {
|
|
5240
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5241
|
-
path: targetCollection.slug,
|
|
5242
|
-
values: parsedValues
|
|
5243
|
-
});
|
|
5969
|
+
resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5244
5970
|
}
|
|
5245
5971
|
return resultMap;
|
|
5246
5972
|
}
|
|
5247
5973
|
if (relation.direction === "owning" && relation.localKey) {
|
|
5974
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
5248
5975
|
const localKeyCol = parentTable[relation.localKey];
|
|
5249
5976
|
if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
5250
5977
|
const fkRows = await this.db.select({
|
|
@@ -5273,17 +6000,11 @@ var RelationService = class {
|
|
|
5273
6000
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5274
6001
|
for (const [parentIdStr, fkValue] of parentToFk) {
|
|
5275
6002
|
const targetRow = targetById.get(String(fkValue));
|
|
5276
|
-
if (targetRow)
|
|
5277
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5278
|
-
resultMap.set(parentIdStr, {
|
|
5279
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5280
|
-
path: targetCollection.slug,
|
|
5281
|
-
values: parsedValues
|
|
5282
|
-
});
|
|
5283
|
-
}
|
|
6003
|
+
if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5284
6004
|
}
|
|
5285
6005
|
return resultMap;
|
|
5286
6006
|
}
|
|
6007
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
|
|
5287
6008
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
5288
6009
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
5289
6010
|
const results = await query;
|
|
@@ -5294,14 +6015,7 @@ var RelationService = class {
|
|
|
5294
6015
|
let parentId;
|
|
5295
6016
|
if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
5296
6017
|
else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
5297
|
-
if (parentId !== void 0 && parentIdSet.has(String(parentId)))
|
|
5298
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5299
|
-
resultMap.set(String(parentId), {
|
|
5300
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5301
|
-
path: targetCollection.slug,
|
|
5302
|
-
values: parsedValues
|
|
5303
|
-
});
|
|
5304
|
-
}
|
|
6018
|
+
if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5305
6019
|
}
|
|
5306
6020
|
return resultMap;
|
|
5307
6021
|
}
|
|
@@ -5315,9 +6029,9 @@ var RelationService = class {
|
|
|
5315
6029
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5316
6030
|
const targetCollection = relation.target();
|
|
5317
6031
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5318
|
-
const
|
|
5319
|
-
const targetIdField = targetTable[
|
|
5320
|
-
const parentPks =
|
|
6032
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
6033
|
+
const targetIdField = targetTable[targetPks[0].fieldName];
|
|
6034
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5321
6035
|
const parentIdInfo = parentPks[0];
|
|
5322
6036
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
5323
6037
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -5339,27 +6053,22 @@ var RelationService = class {
|
|
|
5339
6053
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5340
6054
|
currentTable = joinTable;
|
|
5341
6055
|
}
|
|
5342
|
-
|
|
5343
|
-
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
6056
|
+
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
5344
6057
|
const results = await query;
|
|
5345
6058
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
5346
6059
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5347
6060
|
for (const row of results) {
|
|
5348
6061
|
const parentRow = row[getTableName$1(parentCollection)] || row;
|
|
5349
6062
|
const targetRow = row[targetTableName] || row;
|
|
5350
|
-
const parentId =
|
|
5351
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
6063
|
+
const parentId = buildCompositeId(parentRow, parentPks);
|
|
5352
6064
|
const arr = resultMap.get(parentId) || [];
|
|
5353
|
-
arr.push(
|
|
5354
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5355
|
-
path: targetCollection.slug,
|
|
5356
|
-
values: parsedValues
|
|
5357
|
-
});
|
|
6065
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5358
6066
|
resultMap.set(parentId, arr);
|
|
5359
6067
|
}
|
|
5360
6068
|
return resultMap;
|
|
5361
6069
|
}
|
|
5362
6070
|
if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
|
|
6071
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
5363
6072
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
5364
6073
|
if (!junctionTable) {
|
|
5365
6074
|
logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
@@ -5378,17 +6087,13 @@ var RelationService = class {
|
|
|
5378
6087
|
const junctionData = row[relation.through.table] || row;
|
|
5379
6088
|
const targetData = row[targetTableName] || row;
|
|
5380
6089
|
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
5381
|
-
const parsedValues = await parseDataFromServer(targetData, targetCollection);
|
|
5382
6090
|
const arr = resultMap.get(parentId) || [];
|
|
5383
|
-
arr.push(
|
|
5384
|
-
id: String(targetData[targetIdInfo.fieldName]),
|
|
5385
|
-
path: targetCollection.slug,
|
|
5386
|
-
values: parsedValues
|
|
5387
|
-
});
|
|
6091
|
+
arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
|
|
5388
6092
|
resultMap.set(parentId, arr);
|
|
5389
6093
|
}
|
|
5390
6094
|
return resultMap;
|
|
5391
6095
|
}
|
|
6096
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
|
|
5392
6097
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
5393
6098
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
5394
6099
|
const results = await query;
|
|
@@ -5401,14 +6106,9 @@ var RelationService = class {
|
|
|
5401
6106
|
else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
5402
6107
|
else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
5403
6108
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
|
|
5404
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5405
6109
|
const key = String(parentId);
|
|
5406
6110
|
const arr = resultMap.get(key) || [];
|
|
5407
|
-
arr.push(
|
|
5408
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5409
|
-
path: targetCollection.slug,
|
|
5410
|
-
values: parsedValues
|
|
5411
|
-
});
|
|
6111
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5412
6112
|
resultMap.set(key, arr);
|
|
5413
6113
|
}
|
|
5414
6114
|
}
|
|
@@ -5456,12 +6156,12 @@ var RelationService = class {
|
|
|
5456
6156
|
logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
|
|
5457
6157
|
continue;
|
|
5458
6158
|
}
|
|
5459
|
-
const parentPks =
|
|
6159
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5460
6160
|
const parentIdInfo = parentPks[0];
|
|
5461
6161
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5462
6162
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
5463
6163
|
if (targetEntityIds.length > 0) {
|
|
5464
|
-
const targetPks =
|
|
6164
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5465
6165
|
const targetIdInfo = targetPks[0];
|
|
5466
6166
|
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5467
6167
|
[sourceJunctionColumn.name]: parsedParentId,
|
|
@@ -5481,12 +6181,12 @@ var RelationService = class {
|
|
|
5481
6181
|
logger.warn(`Junction columns not found for relation '${key}'`);
|
|
5482
6182
|
continue;
|
|
5483
6183
|
}
|
|
5484
|
-
const parentPks =
|
|
6184
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5485
6185
|
const parentIdInfo = parentPks[0];
|
|
5486
6186
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5487
6187
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
5488
6188
|
if (targetEntityIds.length > 0) {
|
|
5489
|
-
const targetPks =
|
|
6189
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5490
6190
|
const targetIdInfo = targetPks[0];
|
|
5491
6191
|
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5492
6192
|
[sourceJunctionColumn.name]: parsedParentId,
|
|
@@ -5497,7 +6197,7 @@ var RelationService = class {
|
|
|
5497
6197
|
} else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
|
|
5498
6198
|
else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
5499
6199
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5500
|
-
const targetPks =
|
|
6200
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5501
6201
|
const targetIdInfo = targetPks[0];
|
|
5502
6202
|
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
5503
6203
|
const fkCol = targetTable[relation.foreignKeyOnTarget];
|
|
@@ -5505,7 +6205,7 @@ var RelationService = class {
|
|
|
5505
6205
|
logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
5506
6206
|
continue;
|
|
5507
6207
|
}
|
|
5508
|
-
const parentPks =
|
|
6208
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5509
6209
|
const parentIdInfo = parentPks[0];
|
|
5510
6210
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5511
6211
|
if (targetEntityIds.length > 0) {
|
|
@@ -5525,9 +6225,9 @@ var RelationService = class {
|
|
|
5525
6225
|
try {
|
|
5526
6226
|
const targetCollection = relation.target();
|
|
5527
6227
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5528
|
-
const targetPks =
|
|
6228
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5529
6229
|
const targetIdInfo = targetPks[0];
|
|
5530
|
-
const sourcePks =
|
|
6230
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5531
6231
|
const sourceIdInfo = sourcePks[0];
|
|
5532
6232
|
if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
5533
6233
|
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
@@ -5608,12 +6308,12 @@ var RelationService = class {
|
|
|
5608
6308
|
logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
|
|
5609
6309
|
return;
|
|
5610
6310
|
}
|
|
5611
|
-
const sourcePks =
|
|
6311
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5612
6312
|
const sourceIdInfo = sourcePks[0];
|
|
5613
6313
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
5614
6314
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
5615
6315
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
5616
|
-
const targetPks =
|
|
6316
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5617
6317
|
const targetIdInfo = targetPks[0];
|
|
5618
6318
|
const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5619
6319
|
[sourceJunctionColumn.name]: parsedSourceId,
|
|
@@ -5621,7 +6321,7 @@ var RelationService = class {
|
|
|
5621
6321
|
}));
|
|
5622
6322
|
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
5623
6323
|
} else if (newValue && !Array.isArray(newValue)) {
|
|
5624
|
-
const targetPks =
|
|
6324
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5625
6325
|
const targetIdInfo = targetPks[0];
|
|
5626
6326
|
const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
|
|
5627
6327
|
const newLink = {
|
|
@@ -5652,12 +6352,12 @@ var RelationService = class {
|
|
|
5652
6352
|
logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
|
|
5653
6353
|
return;
|
|
5654
6354
|
}
|
|
5655
|
-
const sourcePks =
|
|
6355
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5656
6356
|
const sourceIdInfo = sourcePks[0];
|
|
5657
6357
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
5658
6358
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
5659
6359
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
5660
|
-
const targetPks =
|
|
6360
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5661
6361
|
const targetIdInfo = targetPks[0];
|
|
5662
6362
|
const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5663
6363
|
[sourceJunctionColumn.name]: parsedSourceId,
|
|
@@ -5678,12 +6378,12 @@ var RelationService = class {
|
|
|
5678
6378
|
const { relation, newTargetId } = upd;
|
|
5679
6379
|
const targetCollection = relation.target();
|
|
5680
6380
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5681
|
-
const targetPks =
|
|
6381
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5682
6382
|
const targetIdInfo = targetPks[0];
|
|
5683
6383
|
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
5684
6384
|
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
5685
6385
|
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
5686
|
-
const parentPks =
|
|
6386
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5687
6387
|
const parentIdInfo = parentPks[0];
|
|
5688
6388
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5689
6389
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
@@ -5754,7 +6454,7 @@ var RelationService = class {
|
|
|
5754
6454
|
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
5755
6455
|
return;
|
|
5756
6456
|
}
|
|
5757
|
-
const targetPks =
|
|
6457
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5758
6458
|
const targetIdInfo = targetPks[0];
|
|
5759
6459
|
const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
|
|
5760
6460
|
const junctionData = {
|
|
@@ -5770,6 +6470,150 @@ var RelationService = class {
|
|
|
5770
6470
|
}
|
|
5771
6471
|
};
|
|
5772
6472
|
//#endregion
|
|
6473
|
+
//#region src/services/row-pipeline.ts
|
|
6474
|
+
/**
|
|
6475
|
+
* Whether a many-relation reaches its target through a junction table.
|
|
6476
|
+
*
|
|
6477
|
+
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
6478
|
+
* the query has to nest one level deeper for a junction, and the row walk has
|
|
6479
|
+
* to unwrap that same level back out.
|
|
6480
|
+
*/
|
|
6481
|
+
function isJunctionRelation(relation) {
|
|
6482
|
+
if (relation.through) return true;
|
|
6483
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
6484
|
+
return false;
|
|
6485
|
+
}
|
|
6486
|
+
/**
|
|
6487
|
+
* Reach the target row inside a junction row.
|
|
6488
|
+
*
|
|
6489
|
+
* A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
|
|
6490
|
+
* the foreign keys, and the target nested under one of them. The target is the
|
|
6491
|
+
* only object among them, so that is how it is found. A junction row that has
|
|
6492
|
+
* not been nested (no `with` on the join) has no object and is returned as-is.
|
|
6493
|
+
*/
|
|
6494
|
+
function unwrapJunctionRow(item) {
|
|
6495
|
+
const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
|
|
6496
|
+
return nestedKey ? item[nestedKey] : item;
|
|
6497
|
+
}
|
|
6498
|
+
/**
|
|
6499
|
+
* Give back the number a `number` property was declared to be.
|
|
6500
|
+
*
|
|
6501
|
+
* Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
|
|
6502
|
+
* numeric can hold more precision than a double. But the collection declared
|
|
6503
|
+
* this property `number` and the OpenAPI spec this server publishes says
|
|
6504
|
+
* `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
|
|
6505
|
+
* asymmetrically, because a create answers with the number and the read that
|
|
6506
|
+
* follows answers with the string. Any client that multiplies a price works
|
|
6507
|
+
* until the first refresh.
|
|
6508
|
+
*
|
|
6509
|
+
* Declaring a property `number` already accepts double precision — that is what
|
|
6510
|
+
* the admin has always parsed it to. Columns REST serves that no property
|
|
6511
|
+
* declares are left exactly as the database returned them.
|
|
6512
|
+
*/
|
|
6513
|
+
function coerceDeclaredNumber(value, property) {
|
|
6514
|
+
if (property?.type !== "number" || typeof value !== "string") return value;
|
|
6515
|
+
const parsed = parseFloat(value);
|
|
6516
|
+
return isNaN(parsed) ? null : parsed;
|
|
6517
|
+
}
|
|
6518
|
+
/** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
|
|
6519
|
+
function coerceDeclaredNumbers(row, collection) {
|
|
6520
|
+
const properties = collection.properties;
|
|
6521
|
+
if (!properties) return row;
|
|
6522
|
+
const out = {};
|
|
6523
|
+
for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
|
|
6524
|
+
return out;
|
|
6525
|
+
}
|
|
6526
|
+
/** Render one target row in the requested style. */
|
|
6527
|
+
/**
|
|
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
|
|
5773
6617
|
//#region src/services/FetchService.ts
|
|
5774
6618
|
/**
|
|
5775
6619
|
* Service for handling all row read operations.
|
|
@@ -5828,7 +6672,7 @@ var FetchService = class {
|
|
|
5828
6672
|
if (!shouldInclude(key)) continue;
|
|
5829
6673
|
const drizzleRelName = relation.relationName || key;
|
|
5830
6674
|
if (relation.joinPath && relation.joinPath.length > 0) continue;
|
|
5831
|
-
if (relation.cardinality === "many" &&
|
|
6675
|
+
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
5832
6676
|
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
5833
6677
|
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
5834
6678
|
else withConfig[drizzleRelName] = true;
|
|
@@ -5837,14 +6681,6 @@ var FetchService = class {
|
|
|
5837
6681
|
return withConfig;
|
|
5838
6682
|
}
|
|
5839
6683
|
/**
|
|
5840
|
-
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
5841
|
-
*/
|
|
5842
|
-
isJunctionRelation(relation, _collection) {
|
|
5843
|
-
if (relation.through) return true;
|
|
5844
|
-
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
5845
|
-
return false;
|
|
5846
|
-
}
|
|
5847
|
-
/**
|
|
5848
6684
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
5849
6685
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
5850
6686
|
*/
|
|
@@ -5853,55 +6689,6 @@ var FetchService = class {
|
|
|
5853
6689
|
return null;
|
|
5854
6690
|
}
|
|
5855
6691
|
/**
|
|
5856
|
-
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
5857
|
-
* Handles:
|
|
5858
|
-
* - Placing `id` at the top level as a string
|
|
5859
|
-
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
5860
|
-
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
5861
|
-
* - Flattening junction-table many-to-many results
|
|
5862
|
-
*/
|
|
5863
|
-
drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
|
|
5864
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5865
|
-
const normalizedValues = normalizeDbValues(row, collection);
|
|
5866
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
5867
|
-
const relData = row[relation.relationName || key];
|
|
5868
|
-
if (relData === void 0 || relData === null) continue;
|
|
5869
|
-
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
5870
|
-
const targetCollection = relation.target();
|
|
5871
|
-
const targetPath = targetCollection.slug;
|
|
5872
|
-
const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
|
|
5873
|
-
normalizedValues[key] = relData.map((item) => {
|
|
5874
|
-
let targetRow = item;
|
|
5875
|
-
if (this.isJunctionRelation(relation, collection)) {
|
|
5876
|
-
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
5877
|
-
if (nestedKey) targetRow = item[nestedKey];
|
|
5878
|
-
}
|
|
5879
|
-
const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
|
|
5880
|
-
return createRelationRefWithData(relId, targetPath, {
|
|
5881
|
-
id: relId,
|
|
5882
|
-
path: targetPath,
|
|
5883
|
-
values: normalizeDbValues(targetRow, targetCollection)
|
|
5884
|
-
});
|
|
5885
|
-
});
|
|
5886
|
-
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
5887
|
-
const targetCollection = relation.target();
|
|
5888
|
-
const targetPath = targetCollection.slug;
|
|
5889
|
-
const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
|
|
5890
|
-
const relObj = relData;
|
|
5891
|
-
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
5892
|
-
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
5893
|
-
id: relId,
|
|
5894
|
-
path: targetPath,
|
|
5895
|
-
values: normalizeDbValues(relObj, targetCollection)
|
|
5896
|
-
});
|
|
5897
|
-
}
|
|
5898
|
-
}
|
|
5899
|
-
return {
|
|
5900
|
-
...normalizedValues,
|
|
5901
|
-
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
5902
|
-
};
|
|
5903
|
-
}
|
|
5904
|
-
/**
|
|
5905
6692
|
* Post-fetch joinPath relations for a single flat row.
|
|
5906
6693
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
5907
6694
|
* so they must be loaded separately after the primary query.
|
|
@@ -5922,31 +6709,6 @@ var FetchService = class {
|
|
|
5922
6709
|
await Promise.all(promises);
|
|
5923
6710
|
}
|
|
5924
6711
|
/**
|
|
5925
|
-
* Post-fetch joinPath relations for a batch of flat rows.
|
|
5926
|
-
* Uses batch fetching to avoid N+1 queries for list views.
|
|
5927
|
-
*/
|
|
5928
|
-
async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
|
|
5929
|
-
if (rows.length === 0) return;
|
|
5930
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5931
|
-
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
|
|
5932
|
-
if (joinPathRelations.length === 0) return;
|
|
5933
|
-
for (const [key, relation] of joinPathRelations) try {
|
|
5934
|
-
const rowIds = rows.map((r) => {
|
|
5935
|
-
return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
|
|
5936
|
-
});
|
|
5937
|
-
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
5938
|
-
for (const row of rows) {
|
|
5939
|
-
const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
|
|
5940
|
-
const relatedRow = resultMap.get(String(id));
|
|
5941
|
-
if (relatedRow) {
|
|
5942
|
-
if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
5943
|
-
}
|
|
5944
|
-
}
|
|
5945
|
-
} catch (e) {
|
|
5946
|
-
logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
|
|
5947
|
-
}
|
|
5948
|
-
}
|
|
5949
|
-
/**
|
|
5950
6712
|
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
5951
6713
|
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
5952
6714
|
*/
|
|
@@ -5957,72 +6719,29 @@ var FetchService = class {
|
|
|
5957
6719
|
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
5958
6720
|
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
|
|
5959
6721
|
if (joinPathRelations.length === 0) return;
|
|
5960
|
-
const
|
|
6722
|
+
const parentIdOf = (row) => {
|
|
6723
|
+
const address = buildCompositeId(row, idInfoArray);
|
|
6724
|
+
return address && address.split(":::").some((part) => part !== "") ? address : void 0;
|
|
6725
|
+
};
|
|
5961
6726
|
for (const [key, relation] of joinPathRelations) try {
|
|
5962
|
-
const
|
|
5963
|
-
|
|
5964
|
-
|
|
6727
|
+
const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
|
|
6728
|
+
if (addressable.length === 0) continue;
|
|
6729
|
+
const rowIds = addressable.map((r) => parentIdOf(r));
|
|
5965
6730
|
if (relation.cardinality === "one") {
|
|
5966
6731
|
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
5967
|
-
for (const row of
|
|
5968
|
-
const
|
|
5969
|
-
|
|
5970
|
-
if (relatedRow) row[key] = {
|
|
5971
|
-
...relatedRow.values,
|
|
5972
|
-
id: relatedRow.id
|
|
5973
|
-
};
|
|
5974
|
-
else row[key] = null;
|
|
6732
|
+
for (const row of addressable) {
|
|
6733
|
+
const relatedRow = resultMap.get(String(parentIdOf(row)));
|
|
6734
|
+
row[key] = relatedRow ? { ...relatedRow.values } : null;
|
|
5975
6735
|
}
|
|
5976
6736
|
} else if (relation.cardinality === "many") {
|
|
5977
6737
|
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
|
|
5978
|
-
for (const row of
|
|
5979
|
-
const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
|
|
5980
|
-
row[key] = (resultMap.get(String(id)) || []).map((e) => ({
|
|
5981
|
-
...e.values,
|
|
5982
|
-
id: e.id
|
|
5983
|
-
}));
|
|
5984
|
-
}
|
|
6738
|
+
for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
|
|
5985
6739
|
}
|
|
5986
6740
|
} catch (e) {
|
|
5987
6741
|
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
5988
6742
|
}
|
|
5989
6743
|
}
|
|
5990
6744
|
/**
|
|
5991
|
-
* Convert a db.query result row to a flat REST-style object with populated relations.
|
|
5992
|
-
*/
|
|
5993
|
-
drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
|
|
5994
|
-
const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
5995
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5996
|
-
for (const [k, v] of Object.entries(row)) {
|
|
5997
|
-
if (k === idInfo.fieldName) continue;
|
|
5998
|
-
const relation = findRelation(resolvedRelations, k);
|
|
5999
|
-
if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
|
|
6000
|
-
if (this.isJunctionRelation(relation, collection)) {
|
|
6001
|
-
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
6002
|
-
if (nestedKey) {
|
|
6003
|
-
const nested = item[nestedKey];
|
|
6004
|
-
return {
|
|
6005
|
-
...nested,
|
|
6006
|
-
id: String(nested.id ?? nested[Object.keys(nested)[0]])
|
|
6007
|
-
};
|
|
6008
|
-
}
|
|
6009
|
-
}
|
|
6010
|
-
return {
|
|
6011
|
-
...item,
|
|
6012
|
-
id: String(item.id ?? item[Object.keys(item)[0]])
|
|
6013
|
-
};
|
|
6014
|
-
});
|
|
6015
|
-
else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
6016
|
-
const relObj = v;
|
|
6017
|
-
flat[k] = {
|
|
6018
|
-
...relObj,
|
|
6019
|
-
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
|
|
6020
|
-
};
|
|
6021
|
-
} else flat[k] = v;
|
|
6022
|
-
}
|
|
6023
|
-
return flat;
|
|
6024
|
-
}
|
|
6025
|
-
/**
|
|
6026
6745
|
* Build db.query-compatible options from standard fetch options.
|
|
6027
6746
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
6028
6747
|
*/
|
|
@@ -6092,7 +6811,7 @@ var FetchService = class {
|
|
|
6092
6811
|
async fetchOne(collectionPath, id, databaseId) {
|
|
6093
6812
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6094
6813
|
const table = getTableForCollection(collection, this.registry);
|
|
6095
|
-
const idInfoArray =
|
|
6814
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6096
6815
|
const idInfo = idInfoArray[0];
|
|
6097
6816
|
const idField = table[idInfo.fieldName];
|
|
6098
6817
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
@@ -6106,7 +6825,7 @@ var FetchService = class {
|
|
|
6106
6825
|
with: withConfig
|
|
6107
6826
|
});
|
|
6108
6827
|
if (!row) return void 0;
|
|
6109
|
-
const flatRow =
|
|
6828
|
+
const flatRow = toCmsRow(row, collection, this.registry);
|
|
6110
6829
|
await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
6111
6830
|
return flatRow;
|
|
6112
6831
|
} catch (e) {
|
|
@@ -6148,7 +6867,7 @@ var FetchService = class {
|
|
|
6148
6867
|
async fetchRowsWithConditions(collectionPath, options = {}) {
|
|
6149
6868
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6150
6869
|
const table = getTableForCollection(collection, this.registry);
|
|
6151
|
-
const idInfoArray =
|
|
6870
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6152
6871
|
const idInfo = idInfoArray[0];
|
|
6153
6872
|
const idField = table[idInfo.fieldName];
|
|
6154
6873
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
@@ -6158,7 +6877,7 @@ var FetchService = class {
|
|
|
6158
6877
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
6159
6878
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
6160
6879
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
|
|
6161
|
-
return (await qb.findMany(queryOpts)).map((row) =>
|
|
6880
|
+
return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
|
|
6162
6881
|
} catch (e) {
|
|
6163
6882
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
6164
6883
|
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
@@ -6219,8 +6938,10 @@ var FetchService = class {
|
|
|
6219
6938
|
}
|
|
6220
6939
|
/**
|
|
6221
6940
|
* Fallback path used when db.query is unavailable.
|
|
6222
|
-
*
|
|
6223
|
-
*
|
|
6941
|
+
*
|
|
6942
|
+
* The primary path runs the results through `toCmsRow`, which maps
|
|
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.
|
|
6224
6945
|
*
|
|
6225
6946
|
* Process raw database results into flat rows with relations.
|
|
6226
6947
|
*/
|
|
@@ -6229,8 +6950,7 @@ var FetchService = class {
|
|
|
6229
6950
|
const parsedRows = await Promise.all(results.map(async (rawRow) => {
|
|
6230
6951
|
return {
|
|
6231
6952
|
rawRow,
|
|
6232
|
-
values: await parseDataFromServer(rawRow, collection)
|
|
6233
|
-
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
|
|
6953
|
+
values: await parseDataFromServer(rawRow, collection)
|
|
6234
6954
|
};
|
|
6235
6955
|
}));
|
|
6236
6956
|
if (!skipRelations) {
|
|
@@ -6270,10 +6990,7 @@ var FetchService = class {
|
|
|
6270
6990
|
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
6271
6991
|
}
|
|
6272
6992
|
}
|
|
6273
|
-
return parsedRows.map((item) =>
|
|
6274
|
-
...item.values,
|
|
6275
|
-
id: item.id
|
|
6276
|
-
}));
|
|
6993
|
+
return parsedRows.map((item) => item.values);
|
|
6277
6994
|
}
|
|
6278
6995
|
/**
|
|
6279
6996
|
* Fetch a collection of rows
|
|
@@ -6304,10 +7021,7 @@ var FetchService = class {
|
|
|
6304
7021
|
const relationKey = pathSegments[i];
|
|
6305
7022
|
const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
|
|
6306
7023
|
if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
6307
|
-
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
|
|
6308
|
-
...row.values,
|
|
6309
|
-
id: row.id
|
|
6310
|
-
}));
|
|
7024
|
+
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
|
|
6311
7025
|
if (i + 1 < pathSegments.length) {
|
|
6312
7026
|
const nextEntityId = pathSegments[i + 1];
|
|
6313
7027
|
currentCollection = relation.target();
|
|
@@ -6369,7 +7083,7 @@ var FetchService = class {
|
|
|
6369
7083
|
if (value === void 0 || value === null) return true;
|
|
6370
7084
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6371
7085
|
const table = getTableForCollection(collection, this.registry);
|
|
6372
|
-
const idInfoArray =
|
|
7086
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6373
7087
|
const idInfo = idInfoArray[0];
|
|
6374
7088
|
const idField = table[idInfo.fieldName];
|
|
6375
7089
|
const field = table[fieldName];
|
|
@@ -6396,7 +7110,7 @@ var FetchService = class {
|
|
|
6396
7110
|
async fetchCollectionForRest(collectionPath, options = {}, include) {
|
|
6397
7111
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6398
7112
|
const table = getTableForCollection(collection, this.registry);
|
|
6399
|
-
const idInfoArray =
|
|
7113
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6400
7114
|
const idInfo = idInfoArray[0];
|
|
6401
7115
|
const idField = table[idInfo.fieldName];
|
|
6402
7116
|
const tableName = getTableName(table);
|
|
@@ -6404,7 +7118,7 @@ var FetchService = class {
|
|
|
6404
7118
|
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
6405
7119
|
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
6406
7120
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
|
|
6407
|
-
const restRows = (await qb.findMany(queryOpts)).map((row) =>
|
|
7121
|
+
const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
|
|
6408
7122
|
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
6409
7123
|
return restRows;
|
|
6410
7124
|
} catch (e) {
|
|
@@ -6415,10 +7129,7 @@ var FetchService = class {
|
|
|
6415
7129
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
6416
7130
|
}
|
|
6417
7131
|
const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
|
|
6418
|
-
if (!include || include.length === 0) return rows
|
|
6419
|
-
...row,
|
|
6420
|
-
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6421
|
-
}));
|
|
7132
|
+
if (!include || include.length === 0) return rows;
|
|
6422
7133
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6423
7134
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
6424
7135
|
const shouldInclude = (key) => include[0] === "*" || include.includes(key);
|
|
@@ -6430,10 +7141,7 @@ var FetchService = class {
|
|
|
6430
7141
|
for (const row of rows) {
|
|
6431
7142
|
const eid = row[idInfo.fieldName];
|
|
6432
7143
|
const related = batchResults.get(String(eid));
|
|
6433
|
-
if (related) row[key] = {
|
|
6434
|
-
...related.values,
|
|
6435
|
-
id: related.id
|
|
6436
|
-
};
|
|
7144
|
+
if (related) row[key] = { ...related.values };
|
|
6437
7145
|
}
|
|
6438
7146
|
} catch (e) {
|
|
6439
7147
|
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
@@ -6451,10 +7159,7 @@ var FetchService = class {
|
|
|
6451
7159
|
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
6452
7160
|
}
|
|
6453
7161
|
}
|
|
6454
|
-
return rows
|
|
6455
|
-
...row,
|
|
6456
|
-
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6457
|
-
}));
|
|
7162
|
+
return rows;
|
|
6458
7163
|
}
|
|
6459
7164
|
/**
|
|
6460
7165
|
* Fetch a single row with optional relation includes for REST API.
|
|
@@ -6462,7 +7167,7 @@ var FetchService = class {
|
|
|
6462
7167
|
async fetchOneForRest(collectionPath, id, include, databaseId) {
|
|
6463
7168
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6464
7169
|
const table = getTableForCollection(collection, this.registry);
|
|
6465
|
-
const idInfoArray =
|
|
7170
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6466
7171
|
const idInfo = idInfoArray[0];
|
|
6467
7172
|
const idField = table[idInfo.fieldName];
|
|
6468
7173
|
const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
|
|
@@ -6475,7 +7180,7 @@ var FetchService = class {
|
|
|
6475
7180
|
...withConfig ? { with: withConfig } : {}
|
|
6476
7181
|
});
|
|
6477
7182
|
if (!row) return null;
|
|
6478
|
-
const restRow =
|
|
7183
|
+
const restRow = toRestRow(row, collection, this.registry);
|
|
6479
7184
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
6480
7185
|
return restRow;
|
|
6481
7186
|
} catch (e) {
|
|
@@ -6487,11 +7192,7 @@ var FetchService = class {
|
|
|
6487
7192
|
}
|
|
6488
7193
|
const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
|
|
6489
7194
|
if (result.length === 0) return null;
|
|
6490
|
-
const
|
|
6491
|
-
const flatEntity = {
|
|
6492
|
-
...raw,
|
|
6493
|
-
id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
|
|
6494
|
-
};
|
|
7195
|
+
const flatEntity = { ...result[0] };
|
|
6495
7196
|
if (!include || include.length === 0) return flatEntity;
|
|
6496
7197
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6497
7198
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
@@ -6524,7 +7225,7 @@ var FetchService = class {
|
|
|
6524
7225
|
async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
|
|
6525
7226
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6526
7227
|
const table = getTableForCollection(collection, this.registry);
|
|
6527
|
-
const idField = table[
|
|
7228
|
+
const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
|
|
6528
7229
|
let vectorMeta;
|
|
6529
7230
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
6530
7231
|
let query = vectorMeta ? this.db.select({
|
|
@@ -6603,32 +7304,15 @@ var FetchService = class {
|
|
|
6603
7304
|
if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
6604
7305
|
}
|
|
6605
7306
|
return (await queryTarget.findMany(queryOpts)).map((row) => {
|
|
6606
|
-
const flat = {
|
|
6607
|
-
for (const [k, v] of Object.entries(row)) {
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
...nested,
|
|
6616
|
-
id: String(nested.id ?? nested[Object.keys(nested)[0]])
|
|
6617
|
-
};
|
|
6618
|
-
}
|
|
6619
|
-
return {
|
|
6620
|
-
...item,
|
|
6621
|
-
id: String(item.id ?? item[Object.keys(item)[0]])
|
|
6622
|
-
};
|
|
6623
|
-
});
|
|
6624
|
-
else if (typeof v === "object" && v !== null) {
|
|
6625
|
-
const relObj = v;
|
|
6626
|
-
flat[k] = {
|
|
6627
|
-
...relObj,
|
|
6628
|
-
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
|
|
6629
|
-
};
|
|
6630
|
-
} else flat[k] = v;
|
|
6631
|
-
}
|
|
7307
|
+
const flat = {};
|
|
7308
|
+
for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
|
|
7309
|
+
const keys = Object.keys(item);
|
|
7310
|
+
const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
7311
|
+
if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
|
|
7312
|
+
return { ...item };
|
|
7313
|
+
});
|
|
7314
|
+
else if (typeof v === "object" && v !== null) flat[k] = { ...v };
|
|
7315
|
+
else flat[k] = v;
|
|
6632
7316
|
return flat;
|
|
6633
7317
|
});
|
|
6634
7318
|
} catch (e) {
|
|
@@ -6889,8 +7573,14 @@ var PersistService = class {
|
|
|
6889
7573
|
}
|
|
6890
7574
|
/**
|
|
6891
7575
|
* 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.
|
|
6892
7582
|
*/
|
|
6893
|
-
async save(collectionPath, values, id, databaseId) {
|
|
7583
|
+
async save(collectionPath, values, id, databaseId, options) {
|
|
6894
7584
|
let effectiveCollectionPath = collectionPath;
|
|
6895
7585
|
const effectiveValues = { ...values };
|
|
6896
7586
|
let junctionTableInfo;
|
|
@@ -6981,7 +7671,7 @@ var PersistService = class {
|
|
|
6981
7671
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
6982
7672
|
savedId = await this.db.transaction(async (tx) => {
|
|
6983
7673
|
let currentId;
|
|
6984
|
-
if (id) {
|
|
7674
|
+
if (id && !options?.upsert) {
|
|
6985
7675
|
currentId = id;
|
|
6986
7676
|
const idValues = parseIdValues(id, idInfoArray);
|
|
6987
7677
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
@@ -6996,9 +7686,24 @@ var PersistService = class {
|
|
|
6996
7686
|
}
|
|
6997
7687
|
} else {
|
|
6998
7688
|
const dataForInsert = { ...entityData };
|
|
7689
|
+
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
6999
7690
|
for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
|
|
7000
|
-
const
|
|
7001
|
-
|
|
7691
|
+
const insertQuery = tx.insert(table).values(dataForInsert);
|
|
7692
|
+
const hasFullKey = idInfoArray.length > 0 && idInfoArray.every((info) => dataForInsert[info.fieldName] !== void 0);
|
|
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);
|
|
7002
7707
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
7003
7708
|
}
|
|
7004
7709
|
if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
@@ -7009,7 +7714,7 @@ var PersistService = class {
|
|
|
7009
7714
|
} catch (error) {
|
|
7010
7715
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
7011
7716
|
}
|
|
7012
|
-
const finalEntity = await this.fetchService.
|
|
7717
|
+
const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
|
|
7013
7718
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
7014
7719
|
return finalEntity;
|
|
7015
7720
|
}
|
|
@@ -7107,8 +7812,8 @@ var DataService = class {
|
|
|
7107
7812
|
/**
|
|
7108
7813
|
* Save an row (create or update)
|
|
7109
7814
|
*/
|
|
7110
|
-
async save(collectionPath, values, id, databaseId) {
|
|
7111
|
-
return this.persistService.save(collectionPath, values, id, databaseId);
|
|
7815
|
+
async save(collectionPath, values, id, databaseId, options) {
|
|
7816
|
+
return this.persistService.save(collectionPath, values, id, databaseId, options);
|
|
7112
7817
|
}
|
|
7113
7818
|
/**
|
|
7114
7819
|
* Delete an row by ID
|
|
@@ -7176,6 +7881,26 @@ var DataService = class {
|
|
|
7176
7881
|
*/
|
|
7177
7882
|
/** Internal prefix applied to branch database names to avoid collisions. */
|
|
7178
7883
|
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
|
+
}
|
|
7179
7904
|
/** Fully-qualified metadata table in the rebase schema. */
|
|
7180
7905
|
var BRANCHES_TABLE = "rebase.branches";
|
|
7181
7906
|
/**
|
|
@@ -7244,10 +7969,8 @@ var BranchService = class {
|
|
|
7244
7969
|
try {
|
|
7245
7970
|
await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
|
|
7246
7971
|
} catch (err) {
|
|
7247
|
-
|
|
7248
|
-
|
|
7249
|
-
if (msg.includes("being accessed by other users")) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
|
|
7250
|
-
throw err;
|
|
7972
|
+
if (extractPgError(err)?.code === PG_OBJECT_IN_USE) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
|
|
7973
|
+
throw describeBranchDdlError(err, dbName);
|
|
7251
7974
|
}
|
|
7252
7975
|
const now = /* @__PURE__ */ new Date();
|
|
7253
7976
|
await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
@@ -7272,8 +7995,8 @@ var BranchService = class {
|
|
|
7272
7995
|
try {
|
|
7273
7996
|
await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
|
|
7274
7997
|
} catch (err) {
|
|
7275
|
-
if ((err
|
|
7276
|
-
throw err;
|
|
7998
|
+
if (extractPgError(err)?.code === PG_OBJECT_IN_USE) throw new Error(`Cannot delete branch "${sanitizedName}": the database has active connections. Close other clients and try again.`);
|
|
7999
|
+
throw describeBranchDdlError(err, dbName);
|
|
7277
8000
|
}
|
|
7278
8001
|
await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
|
|
7279
8002
|
}
|
|
@@ -7459,7 +8182,7 @@ async function ensureAppRole(run, schemas) {
|
|
|
7459
8182
|
* SECURITY: this function is only ever called on the **user** path (the server
|
|
7460
8183
|
* context uses the base/owner driver and never calls it). The default policies
|
|
7461
8184
|
* treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
|
|
7462
|
-
* is `NULLIF(current_setting('app.
|
|
8185
|
+
* is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
|
|
7463
8186
|
* be read as NULL and silently escalate a user request to server privileges.
|
|
7464
8187
|
* Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
|
|
7465
8188
|
* rather than trusting every caller (e.g. realtime subscription auth) to do it.
|
|
@@ -7467,14 +8190,15 @@ async function ensureAppRole(run, schemas) {
|
|
|
7467
8190
|
* semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
|
|
7468
8191
|
*/
|
|
7469
8192
|
async function applyAuthContext(tx, auth, userRole) {
|
|
7470
|
-
const
|
|
8193
|
+
const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
|
|
7471
8194
|
const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
|
|
7472
8195
|
await tx.execute(sql`
|
|
7473
8196
|
SELECT
|
|
7474
|
-
set_config('app.
|
|
8197
|
+
set_config('app.uid', ${uid}, true),
|
|
8198
|
+
set_config('app.user_id', ${uid}, true),
|
|
7475
8199
|
set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
|
|
7476
8200
|
set_config('app.jwt', ${JSON.stringify({
|
|
7477
|
-
sub:
|
|
8201
|
+
sub: uid,
|
|
7478
8202
|
roles: auth.roles
|
|
7479
8203
|
})}, true)
|
|
7480
8204
|
`);
|
|
@@ -7620,6 +8344,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7620
8344
|
executeSql: (...args) => this.executeSql(...args),
|
|
7621
8345
|
fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
|
|
7622
8346
|
fetchAvailableRoles: () => this.fetchAvailableRoles(),
|
|
8347
|
+
fetchApplicationRoles: () => this.fetchApplicationRoles(),
|
|
7623
8348
|
fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
|
|
7624
8349
|
fetchUnmappedTables: (...args) => this.fetchUnmappedTables(...args),
|
|
7625
8350
|
fetchTableMetadata: (...args) => this.fetchTableMetadata(...args),
|
|
@@ -7905,17 +8630,19 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7905
8630
|
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
7906
8631
|
};
|
|
7907
8632
|
}
|
|
7908
|
-
async save({ path, id, values, collection, status }) {
|
|
8633
|
+
async save({ path, id, values, collection, status, upsert }) {
|
|
7909
8634
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
7910
8635
|
let updatedValues = values;
|
|
7911
8636
|
const contextForCallback = this.buildCallContext();
|
|
7912
8637
|
let previousValuesForHistory;
|
|
7913
|
-
if (status === "existing" && id) {
|
|
7914
|
-
const existing = await this.dataService.
|
|
8638
|
+
if (status === "existing" && id) try {
|
|
8639
|
+
const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
|
|
7915
8640
|
if (existing) {
|
|
7916
8641
|
const { id: _existingId, ...existingValues } = existing;
|
|
7917
8642
|
previousValuesForHistory = existingValues;
|
|
7918
8643
|
}
|
|
8644
|
+
} catch (err) {
|
|
8645
|
+
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
7919
8646
|
}
|
|
7920
8647
|
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
7921
8648
|
if (globalCallbacks?.beforeSave) {
|
|
@@ -7962,7 +8689,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7962
8689
|
timestampNowValue: /* @__PURE__ */ new Date()
|
|
7963
8690
|
});
|
|
7964
8691
|
try {
|
|
7965
|
-
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
|
|
8692
|
+
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
|
|
7966
8693
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
7967
8694
|
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
7968
8695
|
collection: resolvedCollection,
|
|
@@ -7983,8 +8710,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7983
8710
|
context: contextForCallback
|
|
7984
8711
|
});
|
|
7985
8712
|
}
|
|
7986
|
-
const savedId = savedRow.
|
|
7987
|
-
const
|
|
8713
|
+
const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
|
|
8714
|
+
const savedValues = savedRow;
|
|
7988
8715
|
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
7989
8716
|
if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
|
|
7990
8717
|
collection: resolvedCollection,
|
|
@@ -8016,7 +8743,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8016
8743
|
}
|
|
8017
8744
|
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
8018
8745
|
tableName: path,
|
|
8019
|
-
id: savedId
|
|
8746
|
+
id: savedId,
|
|
8020
8747
|
action: status === "new" ? "create" : "update",
|
|
8021
8748
|
values: savedValues,
|
|
8022
8749
|
previousValues: previousValuesForHistory,
|
|
@@ -8024,11 +8751,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8024
8751
|
});
|
|
8025
8752
|
if (this._deferNotifications) this._pendingNotifications.push({
|
|
8026
8753
|
path,
|
|
8027
|
-
id: savedId
|
|
8754
|
+
id: savedId,
|
|
8028
8755
|
row: savedRow,
|
|
8029
8756
|
databaseId: resolvedCollection?.databaseId
|
|
8030
8757
|
});
|
|
8031
|
-
else await this.realtimeService.notifyUpdate(path, savedId
|
|
8758
|
+
else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
|
|
8032
8759
|
return savedRow;
|
|
8033
8760
|
} catch (error) {
|
|
8034
8761
|
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
@@ -8063,12 +8790,52 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8063
8790
|
throw error;
|
|
8064
8791
|
}
|
|
8065
8792
|
}
|
|
8793
|
+
/**
|
|
8794
|
+
* Write many rows through the same pipeline as {@link save}.
|
|
8795
|
+
*
|
|
8796
|
+
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
8797
|
+
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
8798
|
+
* state. When this driver is already inside a transaction (the authenticated
|
|
8799
|
+
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
8800
|
+
* still atomic and still commits once.
|
|
8801
|
+
*
|
|
8802
|
+
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
8803
|
+
* with the last write winning, exactly as separate calls would.
|
|
8804
|
+
*/
|
|
8805
|
+
async saveMany({ path, rows, collection, upsert }) {
|
|
8806
|
+
return this.db.transaction(async (tx) => {
|
|
8807
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
8808
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
8809
|
+
txDriver.client = this.client;
|
|
8810
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
8811
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
8812
|
+
const saved = [];
|
|
8813
|
+
for (let i = 0; i < rows.length; i++) {
|
|
8814
|
+
const values = rows[i];
|
|
8815
|
+
const id = values?.id;
|
|
8816
|
+
try {
|
|
8817
|
+
saved.push(await txDriver.save({
|
|
8818
|
+
path,
|
|
8819
|
+
values,
|
|
8820
|
+
collection,
|
|
8821
|
+
status: "new",
|
|
8822
|
+
upsert
|
|
8823
|
+
}));
|
|
8824
|
+
} catch (error) {
|
|
8825
|
+
const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
|
|
8826
|
+
throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
8827
|
+
statusCode: error?.statusCode,
|
|
8828
|
+
code: error?.code,
|
|
8829
|
+
name: error?.name
|
|
8830
|
+
});
|
|
8831
|
+
}
|
|
8832
|
+
}
|
|
8833
|
+
return saved;
|
|
8834
|
+
});
|
|
8835
|
+
}
|
|
8066
8836
|
async delete({ row, collection }) {
|
|
8067
8837
|
const targetPath = row.path;
|
|
8068
|
-
const targetRow = {
|
|
8069
|
-
id: row.id,
|
|
8070
|
-
...row.values ?? {}
|
|
8071
|
-
};
|
|
8838
|
+
const targetRow = { ...row.values ?? {} };
|
|
8072
8839
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
|
|
8073
8840
|
const contextForCallback = this.buildCallContext();
|
|
8074
8841
|
if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
|
|
@@ -8231,6 +8998,45 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8231
8998
|
async fetchAvailableRoles() {
|
|
8232
8999
|
return (await this.executeSql("SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, rolname, 'member') ORDER BY rolname;")).map((r) => r.rolname);
|
|
8233
9000
|
}
|
|
9001
|
+
/**
|
|
9002
|
+
* Application-level roles actually in use in this project.
|
|
9003
|
+
*
|
|
9004
|
+
* Distinct from {@link fetchAvailableRoles}, which returns native
|
|
9005
|
+
* PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
|
|
9006
|
+
* are the roles the SQL editor can `SET ROLE` to. *These* are the strings
|
|
9007
|
+
* held in the users table's `roles` column, injected per-transaction as
|
|
9008
|
+
* `auth.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
|
|
9009
|
+
* into a `SecurityRule.roles` field produces a condition no user can ever
|
|
9010
|
+
* satisfy, so the two must not be conflated.
|
|
9011
|
+
*
|
|
9012
|
+
* Roles have no registry table — they were migrated out of
|
|
9013
|
+
* `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
|
|
9014
|
+
* set is derived from what is assigned. A role that is declared in a policy
|
|
9015
|
+
* but held by nobody yet cannot be discovered here; callers that need it
|
|
9016
|
+
* should union in the roles they already know about.
|
|
9017
|
+
*/
|
|
9018
|
+
async fetchApplicationRoles() {
|
|
9019
|
+
const located = await this.executeSql(`
|
|
9020
|
+
SELECT table_schema, table_name
|
|
9021
|
+
FROM information_schema.columns
|
|
9022
|
+
WHERE column_name = 'roles'
|
|
9023
|
+
AND data_type = 'ARRAY'
|
|
9024
|
+
AND table_name = 'users'
|
|
9025
|
+
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
9026
|
+
ORDER BY (table_schema = 'rebase') DESC, table_schema
|
|
9027
|
+
LIMIT 1;
|
|
9028
|
+
`);
|
|
9029
|
+
if (located.length === 0) return [];
|
|
9030
|
+
const schema = located[0].table_schema;
|
|
9031
|
+
const table = located[0].table_name;
|
|
9032
|
+
const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
|
|
9033
|
+
return (await this.executeSql(`
|
|
9034
|
+
SELECT DISTINCT unnest(roles) AS role
|
|
9035
|
+
FROM ${qualified}
|
|
9036
|
+
WHERE roles IS NOT NULL
|
|
9037
|
+
ORDER BY role;
|
|
9038
|
+
`)).map((r) => r.role).filter((r) => typeof r === "string" && r.length > 0);
|
|
9039
|
+
}
|
|
8234
9040
|
async fetchCurrentDatabase() {
|
|
8235
9041
|
return this.poolManager?.defaultDatabaseName;
|
|
8236
9042
|
}
|
|
@@ -8372,15 +9178,15 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
8372
9178
|
async withTransaction(operation, options) {
|
|
8373
9179
|
const pendingNotifications = [];
|
|
8374
9180
|
const result = await this.delegate.db.transaction(async (tx) => {
|
|
8375
|
-
let
|
|
8376
|
-
if (!
|
|
9181
|
+
let uid = this.user?.uid;
|
|
9182
|
+
if (!uid) {
|
|
8377
9183
|
logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
8378
|
-
|
|
9184
|
+
uid = "anonymous";
|
|
8379
9185
|
}
|
|
8380
9186
|
const userRoles = this.user?.roles ?? [];
|
|
8381
9187
|
if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
|
|
8382
9188
|
await applyAuthContext(tx, {
|
|
8383
|
-
|
|
9189
|
+
uid,
|
|
8384
9190
|
roles: userRoles
|
|
8385
9191
|
}, this.delegate.rlsUserRole);
|
|
8386
9192
|
const txEntityService = new DataService(tx, this.delegate.registry);
|
|
@@ -8407,7 +9213,7 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
8407
9213
|
*/
|
|
8408
9214
|
injectAuthContext(unsubscribe) {
|
|
8409
9215
|
const authContext = {
|
|
8410
|
-
|
|
9216
|
+
uid: this.user?.uid || "anonymous",
|
|
8411
9217
|
roles: this.user?.roles ?? []
|
|
8412
9218
|
};
|
|
8413
9219
|
const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
|
|
@@ -8427,6 +9233,18 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
8427
9233
|
async save(props) {
|
|
8428
9234
|
return this.withTransaction((delegate) => delegate.save(props));
|
|
8429
9235
|
}
|
|
9236
|
+
/**
|
|
9237
|
+
* One transaction for the whole batch, rather than one per row.
|
|
9238
|
+
*
|
|
9239
|
+
* This is the point of the method: `save` opens a transaction per call, so
|
|
9240
|
+
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
9241
|
+
* round trips). Here the RLS context is established once and every row lands
|
|
9242
|
+
* or none does. Realtime notifications are already deferred to commit by
|
|
9243
|
+
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
9244
|
+
*/
|
|
9245
|
+
async saveMany(props) {
|
|
9246
|
+
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
9247
|
+
}
|
|
8430
9248
|
async delete(props) {
|
|
8431
9249
|
return this.withTransaction((delegate) => delegate.delete(props));
|
|
8432
9250
|
}
|
|
@@ -8476,6 +9294,7 @@ var DatabasePoolManager = class {
|
|
|
8476
9294
|
pool.on("error", (err) => {
|
|
8477
9295
|
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
8478
9296
|
});
|
|
9297
|
+
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
8479
9298
|
this.pools.set(databaseName, pool);
|
|
8480
9299
|
return pool;
|
|
8481
9300
|
}
|
|
@@ -8538,19 +9357,19 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
8538
9357
|
*/
|
|
8539
9358
|
const refreshTokens = tableCreator("refresh_tokens", {
|
|
8540
9359
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
8541
|
-
|
|
9360
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
8542
9361
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
8543
9362
|
expiresAt: timestamp("expires_at").notNull(),
|
|
8544
9363
|
userAgent: varchar("user_agent", { length: 500 }),
|
|
8545
9364
|
ipAddress: varchar("ip_address", { length: 45 }),
|
|
8546
9365
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
8547
|
-
}, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.
|
|
9366
|
+
}, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.uid, table.userAgent, table.ipAddress) }));
|
|
8548
9367
|
/**
|
|
8549
9368
|
* Password reset tokens for forgot password flow
|
|
8550
9369
|
*/
|
|
8551
9370
|
const passwordResetTokens = tableCreator("password_reset_tokens", {
|
|
8552
9371
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
8553
|
-
|
|
9372
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
8554
9373
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
8555
9374
|
expiresAt: timestamp("expires_at").notNull(),
|
|
8556
9375
|
usedAt: timestamp("used_at"),
|
|
@@ -8569,7 +9388,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
8569
9388
|
*/
|
|
8570
9389
|
const userIdentities = tableCreator("user_identities", {
|
|
8571
9390
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
8572
|
-
|
|
9391
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
8573
9392
|
provider: varchar("provider", { length: 50 }).notNull(),
|
|
8574
9393
|
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
|
8575
9394
|
profileData: jsonb("profile_data"),
|
|
@@ -8581,7 +9400,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
8581
9400
|
*/
|
|
8582
9401
|
const mfaFactors = tableCreator("mfa_factors", {
|
|
8583
9402
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
8584
|
-
|
|
9403
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
8585
9404
|
factorType: varchar("factor_type", { length: 20 }).notNull(),
|
|
8586
9405
|
secretEncrypted: varchar("secret_encrypted", { length: 500 }).notNull(),
|
|
8587
9406
|
friendlyName: varchar("friendly_name", { length: 255 }),
|
|
@@ -8607,14 +9426,14 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
8607
9426
|
}),
|
|
8608
9427
|
recoveryCodes: tableCreator("recovery_codes", {
|
|
8609
9428
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
8610
|
-
|
|
9429
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
8611
9430
|
codeHash: varchar("code_hash", { length: 255 }).notNull(),
|
|
8612
9431
|
usedAt: timestamp("used_at"),
|
|
8613
9432
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
8614
9433
|
}),
|
|
8615
9434
|
magicLinkTokens: tableCreator("magic_link_tokens", {
|
|
8616
9435
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
8617
|
-
|
|
9436
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
8618
9437
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
8619
9438
|
expiresAt: timestamp("expires_at").notNull(),
|
|
8620
9439
|
usedAt: timestamp("used_at"),
|
|
@@ -8641,136 +9460,37 @@ var usersRelations = relations(users, ({ many }) => ({
|
|
|
8641
9460
|
recoveryCodes: many(recoveryCodes),
|
|
8642
9461
|
magicLinkTokens: many(magicLinkTokens)
|
|
8643
9462
|
}));
|
|
8644
|
-
var refreshTokensRelations = relations(refreshTokens, ({ one }) => ({ user: one(users, {
|
|
8645
|
-
fields: [refreshTokens.
|
|
8646
|
-
references: [users.id]
|
|
8647
|
-
}) }));
|
|
8648
|
-
var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
|
|
8649
|
-
fields: [passwordResetTokens.
|
|
8650
|
-
references: [users.id]
|
|
8651
|
-
}) }));
|
|
8652
|
-
var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
|
|
8653
|
-
fields: [userIdentities.
|
|
8654
|
-
references: [users.id]
|
|
8655
|
-
}) }));
|
|
8656
|
-
var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
|
|
8657
|
-
user: one(users, {
|
|
8658
|
-
fields: [mfaFactors.
|
|
8659
|
-
references: [users.id]
|
|
8660
|
-
}),
|
|
8661
|
-
challenges: many(mfaChallenges)
|
|
8662
|
-
}));
|
|
8663
|
-
var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: one(mfaFactors, {
|
|
8664
|
-
fields: [mfaChallenges.factorId],
|
|
8665
|
-
references: [mfaFactors.id]
|
|
8666
|
-
}) }));
|
|
8667
|
-
var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
|
|
8668
|
-
fields: [recoveryCodes.
|
|
8669
|
-
references: [users.id]
|
|
8670
|
-
}) }));
|
|
8671
|
-
var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
|
|
8672
|
-
fields: [magicLinkTokens.
|
|
8673
|
-
references: [users.id]
|
|
8674
|
-
}) }));
|
|
8675
|
-
//#endregion
|
|
8676
|
-
//#region src/schema/auth-default-policies.ts
|
|
8677
|
-
/**
|
|
8678
|
-
* Default RLS policies injected by the schema generator.
|
|
8679
|
-
*
|
|
8680
|
-
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
8681
|
-
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
8682
|
-
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
8683
|
-
* authorization model. The server context (auth flows, migrations,
|
|
8684
|
-
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
8685
|
-
*
|
|
8686
|
-
* Because RLS default-denies, every collection is **locked by default**: with
|
|
8687
|
-
* no rules, only the server context and admins can touch it. The generator
|
|
8688
|
-
* injects that safe baseline:
|
|
8689
|
-
*
|
|
8690
|
-
* **For every collection**
|
|
8691
|
-
* 1. A permissive **server-or-admin SELECT** grant.
|
|
8692
|
-
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
8693
|
-
*
|
|
8694
|
-
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
8695
|
-
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
8696
|
-
* rows").
|
|
8697
|
-
*
|
|
8698
|
-
* **For auth collections additionally**
|
|
8699
|
-
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
8700
|
-
* their own row (profile, session bootstrap) without every app re-declaring
|
|
8701
|
-
* it.
|
|
8702
|
-
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
8703
|
-
* every other policy, so a write is rejected unless the caller is an admin
|
|
8704
|
-
* (or the server context) — even if the author also wrote a permissive rule
|
|
8705
|
-
* such as "a user may edit their own row". Without this, a permissive owner
|
|
8706
|
-
* rule would let a user change their own `roles`.
|
|
8707
|
-
*
|
|
8708
|
-
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
8709
|
-
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
8710
|
-
* GUC — which also lets the owner connection satisfy these policies even under
|
|
8711
|
-
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
8712
|
-
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
8713
|
-
*
|
|
8714
|
-
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
8715
|
-
* the collection's RLS.
|
|
8716
|
-
*/
|
|
8717
|
-
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
8718
|
-
/** Write operations that must be admin-gated by default on auth collections. */
|
|
8719
|
-
var DEFAULT_GUARDED_OPS = [
|
|
8720
|
-
"insert",
|
|
8721
|
-
"update",
|
|
8722
|
-
"delete"
|
|
8723
|
-
];
|
|
8724
|
-
/** Whether a collection is flagged as an authentication collection. */
|
|
8725
|
-
function isAuthCollection(collection) {
|
|
8726
|
-
const auth = collection.auth;
|
|
8727
|
-
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
8728
|
-
}
|
|
8729
|
-
/** The property marked as the row id (falls back to `id`). */
|
|
8730
|
-
function getIdPropertyName(collection) {
|
|
8731
|
-
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
8732
|
-
return "id";
|
|
8733
|
-
}
|
|
8734
|
-
/**
|
|
8735
|
-
* Returns the security rules that should be applied to a collection: the
|
|
8736
|
-
* author's explicit `securityRules` plus the framework defaults described in
|
|
8737
|
-
* the module doc (baseline server/admin read for all collections; self-read
|
|
8738
|
-
* and the admin write gate for auth collections).
|
|
8739
|
-
*
|
|
8740
|
-
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
8741
|
-
*/
|
|
8742
|
-
function getEffectiveSecurityRules(collection) {
|
|
8743
|
-
const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
|
|
8744
|
-
if (collection.disableDefaultPolicies) return explicit;
|
|
8745
|
-
const tableName = getTableName$1(collection);
|
|
8746
|
-
const injected = [];
|
|
8747
|
-
injected.push({
|
|
8748
|
-
name: `${tableName}_default_admin_read`,
|
|
8749
|
-
operations: ["select"],
|
|
8750
|
-
condition: SERVER_OR_ADMIN_EXPR
|
|
8751
|
-
});
|
|
8752
|
-
injected.push({
|
|
8753
|
-
name: `${tableName}_default_admin_write`,
|
|
8754
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
8755
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
8756
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
8757
|
-
});
|
|
8758
|
-
if (isAuthCollection(collection)) {
|
|
8759
|
-
injected.push({
|
|
8760
|
-
name: `${tableName}_default_self_read`,
|
|
8761
|
-
operations: ["select"],
|
|
8762
|
-
condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
|
|
8763
|
-
});
|
|
8764
|
-
injected.push({
|
|
8765
|
-
name: `${tableName}_require_admin_write`,
|
|
8766
|
-
mode: "restrictive",
|
|
8767
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
8768
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
8769
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
8770
|
-
});
|
|
8771
|
-
}
|
|
8772
|
-
return [...explicit, ...injected];
|
|
8773
|
-
}
|
|
9463
|
+
var refreshTokensRelations = relations(refreshTokens, ({ one }) => ({ user: one(users, {
|
|
9464
|
+
fields: [refreshTokens.uid],
|
|
9465
|
+
references: [users.id]
|
|
9466
|
+
}) }));
|
|
9467
|
+
var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
|
|
9468
|
+
fields: [passwordResetTokens.uid],
|
|
9469
|
+
references: [users.id]
|
|
9470
|
+
}) }));
|
|
9471
|
+
var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
|
|
9472
|
+
fields: [userIdentities.uid],
|
|
9473
|
+
references: [users.id]
|
|
9474
|
+
}) }));
|
|
9475
|
+
var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
|
|
9476
|
+
user: one(users, {
|
|
9477
|
+
fields: [mfaFactors.uid],
|
|
9478
|
+
references: [users.id]
|
|
9479
|
+
}),
|
|
9480
|
+
challenges: many(mfaChallenges)
|
|
9481
|
+
}));
|
|
9482
|
+
var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: one(mfaFactors, {
|
|
9483
|
+
fields: [mfaChallenges.factorId],
|
|
9484
|
+
references: [mfaFactors.id]
|
|
9485
|
+
}) }));
|
|
9486
|
+
var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
|
|
9487
|
+
fields: [recoveryCodes.uid],
|
|
9488
|
+
references: [users.id]
|
|
9489
|
+
}) }));
|
|
9490
|
+
var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
|
|
9491
|
+
fields: [magicLinkTokens.uid],
|
|
9492
|
+
references: [users.id]
|
|
9493
|
+
}) }));
|
|
8774
9494
|
//#endregion
|
|
8775
9495
|
//#region src/schema/generate-drizzle-schema-logic.ts
|
|
8776
9496
|
/**
|
|
@@ -8969,31 +9689,12 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
8969
9689
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
8970
9690
|
*/
|
|
8971
9691
|
var wrapSql = (clause) => `sql\`${clause}\``;
|
|
8972
|
-
/**
|
|
8973
|
-
* Generates a deterministic hash based on the rule configuration.
|
|
8974
|
-
*/
|
|
8975
|
-
var getPolicyNameHash = (rule) => {
|
|
8976
|
-
const data = JSON.stringify({
|
|
8977
|
-
a: rule.access,
|
|
8978
|
-
m: rule.mode,
|
|
8979
|
-
op: rule.operation,
|
|
8980
|
-
ops: rule.operations?.slice().sort(),
|
|
8981
|
-
own: rule.ownerField,
|
|
8982
|
-
rol: rule.roles?.slice().sort(),
|
|
8983
|
-
pg: rule.pgRoles?.slice().sort(),
|
|
8984
|
-
u: rule.using,
|
|
8985
|
-
w: rule.withCheck,
|
|
8986
|
-
c: rule.condition,
|
|
8987
|
-
ch: rule.check
|
|
8988
|
-
});
|
|
8989
|
-
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
8990
|
-
};
|
|
8991
9692
|
var generatePolicyCode = (collection, rule, index, resolveCollection) => {
|
|
8992
9693
|
const tableName = getTableName$1(collection);
|
|
8993
9694
|
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
8994
|
-
const
|
|
9695
|
+
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
8995
9696
|
return ops.map((op, opIdx) => {
|
|
8996
|
-
return generateSinglePolicyCode(collection, rule, op,
|
|
9697
|
+
return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
8997
9698
|
}).join("");
|
|
8998
9699
|
};
|
|
8999
9700
|
/**
|
|
@@ -9122,6 +9823,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9122
9823
|
});
|
|
9123
9824
|
});
|
|
9124
9825
|
schemaContent += "\n";
|
|
9826
|
+
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
9125
9827
|
for (const collection of collections) {
|
|
9126
9828
|
const tableName = getTableName$1(collection);
|
|
9127
9829
|
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
@@ -9155,9 +9857,17 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9155
9857
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
9156
9858
|
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
9157
9859
|
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
9158
|
-
schemaContent += "}, (table) => (
|
|
9159
|
-
schemaContent += `
|
|
9160
|
-
|
|
9860
|
+
schemaContent += "}, (table) => ([\n";
|
|
9861
|
+
schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
|
|
9862
|
+
const junctionSpec = junctionSpecs.get(baseTableName);
|
|
9863
|
+
if (!stripPolicies && junctionSpec) {
|
|
9864
|
+
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
9865
|
+
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
9866
|
+
getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
|
|
9867
|
+
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
9868
|
+
});
|
|
9869
|
+
}
|
|
9870
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
9161
9871
|
} else if (!isJunction) {
|
|
9162
9872
|
const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
|
|
9163
9873
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
@@ -9611,6 +10321,267 @@ var CdcListener = class CdcListener {
|
|
|
9611
10321
|
}
|
|
9612
10322
|
};
|
|
9613
10323
|
//#endregion
|
|
10324
|
+
//#region src/services/channel-history.ts
|
|
10325
|
+
/**
|
|
10326
|
+
* Ordered, replayable per-channel message history.
|
|
10327
|
+
*
|
|
10328
|
+
* Broadcast on its own is fire-and-forget to whoever is connected at the
|
|
10329
|
+
* instant it is sent: fine for presence and for "someone saved" notifications,
|
|
10330
|
+
* not enough for op-based collaborative editing, where a client that blinks
|
|
10331
|
+
* out for two seconds has to resync a whole document rather than catch up on
|
|
10332
|
+
* the four operations it missed. This adds the missing half — every retained
|
|
10333
|
+
* broadcast gets a per-channel sequence number, and a client can ask for
|
|
10334
|
+
* everything after the last one it saw.
|
|
10335
|
+
*
|
|
10336
|
+
* Three decisions worth stating, because each rules out a simpler-looking one:
|
|
10337
|
+
*
|
|
10338
|
+
* - **Retention is server-side and opt-in.** A channel is created by whoever
|
|
10339
|
+
* names it, so a client-supplied history depth would let any visitor commit
|
|
10340
|
+
* the backend to unbounded storage. And presence channels — the common case
|
|
10341
|
+
* — must not pay for this: with no rules configured nothing is written, no
|
|
10342
|
+
* table is created, and `broadcast` runs exactly the code it ran before.
|
|
10343
|
+
*
|
|
10344
|
+
* - **Sequence numbers come from the database, not from a counter in this
|
|
10345
|
+
* process.** They have to survive a restart and be shared across instances;
|
|
10346
|
+
* an in-memory counter would restart at 1 after a deploy and hand a
|
|
10347
|
+
* reconnecting client a replay from the wrong era, silently.
|
|
10348
|
+
*
|
|
10349
|
+
* - **The cursor row outlives the messages it numbered.** Pruning is what
|
|
10350
|
+
* makes retention affordable, but pruning the cursor along with the messages
|
|
10351
|
+
* would restart the sequence and make `sinceSeq` mean something different
|
|
10352
|
+
* before and after — the worst kind of bug, because replay would still
|
|
10353
|
+
* return rows and they would look plausible. Cursors are tiny and are kept
|
|
10354
|
+
* forever; see {@link prune}, which touches only `channel_messages`.
|
|
10355
|
+
*/
|
|
10356
|
+
/** How many messages a replay returns when the caller does not say. */
|
|
10357
|
+
var DEFAULT_REPLAY_LIMIT = 200;
|
|
10358
|
+
/**
|
|
10359
|
+
* Hard ceiling on one replay, whatever the caller asks for.
|
|
10360
|
+
*
|
|
10361
|
+
* A reconnecting client names its own `limit`, so this is the only thing
|
|
10362
|
+
* standing between a stale `sinceSeq` and a single frame carrying a channel's
|
|
10363
|
+
* entire retained history. A client that is further behind than this is told so
|
|
10364
|
+
* via `latestSeq` and can decide to resync wholesale instead of paging.
|
|
10365
|
+
*/
|
|
10366
|
+
var MAX_REPLAY_LIMIT = 1e3;
|
|
10367
|
+
/** Minimum gap between two prunes of the same channel. */
|
|
10368
|
+
var PRUNE_THROTTLE_MS = 3e4;
|
|
10369
|
+
/**
|
|
10370
|
+
* Parse a retention TTL into milliseconds.
|
|
10371
|
+
*
|
|
10372
|
+
* Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`,
|
|
10373
|
+
* `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the
|
|
10374
|
+
* caller treats as "no TTL" — a misspelt duration must not silently become an
|
|
10375
|
+
* aggressive one.
|
|
10376
|
+
*/
|
|
10377
|
+
function parseTtlMs(ttl) {
|
|
10378
|
+
if (ttl === void 0 || ttl === null) return void 0;
|
|
10379
|
+
if (typeof ttl === "number") return Number.isFinite(ttl) && ttl > 0 ? ttl : void 0;
|
|
10380
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/i.exec(ttl);
|
|
10381
|
+
if (!match) {
|
|
10382
|
+
logger.warn(`⚠️ [ChannelHistory] Ignoring unparseable retention ttl "${ttl}" — expected e.g. "30s", "15m", "24h", "7d".`);
|
|
10383
|
+
return;
|
|
10384
|
+
}
|
|
10385
|
+
const value = parseFloat(match[1]);
|
|
10386
|
+
const unit = match[2].toLowerCase();
|
|
10387
|
+
const ms = value * (unit === "ms" ? 1 : unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5);
|
|
10388
|
+
return ms > 0 ? ms : void 0;
|
|
10389
|
+
}
|
|
10390
|
+
/**
|
|
10391
|
+
* Whether `channel` is covered by `rule`.
|
|
10392
|
+
*
|
|
10393
|
+
* Exact match, or a trailing `*` acting as a prefix. Not a general glob: this
|
|
10394
|
+
* decides what reaches disk, and a pattern language whose reach is not obvious
|
|
10395
|
+
* at a glance is the wrong tool for that job.
|
|
10396
|
+
*/
|
|
10397
|
+
function channelMatchesRule(channel, rule) {
|
|
10398
|
+
const pattern = rule.match;
|
|
10399
|
+
if (pattern === "*") return true;
|
|
10400
|
+
if (pattern.endsWith("*")) return channel.startsWith(pattern.slice(0, -1));
|
|
10401
|
+
return channel === pattern;
|
|
10402
|
+
}
|
|
10403
|
+
/**
|
|
10404
|
+
* Persistence and replay for retained channels.
|
|
10405
|
+
*
|
|
10406
|
+
* Inert unless constructed with at least one rule: {@link enabled} is false,
|
|
10407
|
+
* {@link ensureTables} does nothing, and {@link retentionFor} answers undefined
|
|
10408
|
+
* for every channel, so the realtime service never reaches the SQL below.
|
|
10409
|
+
*/
|
|
10410
|
+
var ChannelHistoryStore = class {
|
|
10411
|
+
db;
|
|
10412
|
+
rules;
|
|
10413
|
+
/** Resolved rule per channel name, so the match runs once per channel. */
|
|
10414
|
+
resolved = /* @__PURE__ */ new Map();
|
|
10415
|
+
/** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */
|
|
10416
|
+
lastPruned = /* @__PURE__ */ new Map();
|
|
10417
|
+
tablesReady = false;
|
|
10418
|
+
constructor(db, rules = []) {
|
|
10419
|
+
this.db = db;
|
|
10420
|
+
this.rules = rules.filter((rule) => {
|
|
10421
|
+
if (!rule?.match) {
|
|
10422
|
+
logger.warn("⚠️ [ChannelHistory] Ignoring a retention rule with no `match`.");
|
|
10423
|
+
return false;
|
|
10424
|
+
}
|
|
10425
|
+
if (!(rule.limit !== void 0 || rule.ttl !== void 0)) {
|
|
10426
|
+
logger.warn(`⚠️ [ChannelHistory] Retention rule "${rule.match}" sets neither \`limit\` nor \`ttl\` — ignoring it, as it would retain forever.`);
|
|
10427
|
+
return false;
|
|
10428
|
+
}
|
|
10429
|
+
return true;
|
|
10430
|
+
});
|
|
10431
|
+
}
|
|
10432
|
+
/** Whether any channel retains anything at all. */
|
|
10433
|
+
get enabled() {
|
|
10434
|
+
return this.rules.length > 0;
|
|
10435
|
+
}
|
|
10436
|
+
/**
|
|
10437
|
+
* The retention that applies to `channel`, or undefined when none does.
|
|
10438
|
+
*
|
|
10439
|
+
* First matching rule wins, so callers order them most-specific first.
|
|
10440
|
+
*/
|
|
10441
|
+
retentionFor(channel) {
|
|
10442
|
+
if (!this.enabled || !channel) return void 0;
|
|
10443
|
+
const cached = this.resolved.get(channel);
|
|
10444
|
+
if (cached !== void 0) return cached ?? void 0;
|
|
10445
|
+
const rule = this.rules.find((r) => channelMatchesRule(channel, r));
|
|
10446
|
+
const resolved = rule ? {
|
|
10447
|
+
limit: rule.limit,
|
|
10448
|
+
ttlMs: parseTtlMs(rule.ttl)
|
|
10449
|
+
} : null;
|
|
10450
|
+
this.resolved.set(channel, resolved);
|
|
10451
|
+
return resolved ?? void 0;
|
|
10452
|
+
}
|
|
10453
|
+
/**
|
|
10454
|
+
* Create the history tables. Idempotent, and a no-op when no rule is set —
|
|
10455
|
+
* a deployment that never retains anything gets no schema for it.
|
|
10456
|
+
*/
|
|
10457
|
+
async ensureTables() {
|
|
10458
|
+
if (!this.enabled || this.tablesReady) return;
|
|
10459
|
+
await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
10460
|
+
await this.db.execute(sql`
|
|
10461
|
+
CREATE TABLE IF NOT EXISTS rebase.channel_messages (
|
|
10462
|
+
channel TEXT NOT NULL,
|
|
10463
|
+
seq BIGINT NOT NULL,
|
|
10464
|
+
event TEXT NOT NULL,
|
|
10465
|
+
payload JSONB,
|
|
10466
|
+
sender_id TEXT,
|
|
10467
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
10468
|
+
PRIMARY KEY (channel, seq)
|
|
10469
|
+
)
|
|
10470
|
+
`);
|
|
10471
|
+
await this.db.execute(sql`
|
|
10472
|
+
CREATE INDEX IF NOT EXISTS idx_channel_messages_created
|
|
10473
|
+
ON rebase.channel_messages (created_at)
|
|
10474
|
+
`);
|
|
10475
|
+
await this.db.execute(sql`
|
|
10476
|
+
CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
|
|
10477
|
+
channel TEXT PRIMARY KEY,
|
|
10478
|
+
last_seq BIGINT NOT NULL
|
|
10479
|
+
)
|
|
10480
|
+
`);
|
|
10481
|
+
this.tablesReady = true;
|
|
10482
|
+
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
10483
|
+
}
|
|
10484
|
+
/**
|
|
10485
|
+
* Append a broadcast and return the sequence number it was given.
|
|
10486
|
+
*
|
|
10487
|
+
* The sequence is allocated by the same statement that stores the message,
|
|
10488
|
+
* so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE`
|
|
10489
|
+
* takes a row lock on the channel's cursor, which is what makes concurrent
|
|
10490
|
+
* broadcasts to one channel line up in a single order — and what keeps
|
|
10491
|
+
* different channels from contending with each other at all.
|
|
10492
|
+
*/
|
|
10493
|
+
async append(channel, event, payload, senderId) {
|
|
10494
|
+
const row = (await this.db.execute(sql`
|
|
10495
|
+
WITH next AS (
|
|
10496
|
+
INSERT INTO rebase.channel_cursors (channel, last_seq)
|
|
10497
|
+
VALUES (${channel}, 1)
|
|
10498
|
+
ON CONFLICT (channel)
|
|
10499
|
+
DO UPDATE SET last_seq = rebase.channel_cursors.last_seq + 1
|
|
10500
|
+
RETURNING last_seq
|
|
10501
|
+
)
|
|
10502
|
+
INSERT INTO rebase.channel_messages (channel, seq, event, payload, sender_id)
|
|
10503
|
+
SELECT ${channel}, next.last_seq, ${event}, ${JSON.stringify(payload ?? null)}::jsonb, ${senderId ?? null}
|
|
10504
|
+
FROM next
|
|
10505
|
+
RETURNING seq, created_at
|
|
10506
|
+
`)).rows[0];
|
|
10507
|
+
if (!row) throw new Error(`Failed to append to channel history for "${channel}"`);
|
|
10508
|
+
return {
|
|
10509
|
+
seq: Number(row.seq),
|
|
10510
|
+
at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
|
|
10511
|
+
};
|
|
10512
|
+
}
|
|
10513
|
+
/**
|
|
10514
|
+
* Everything retained for `channel` after `sinceSeq`, oldest first.
|
|
10515
|
+
*
|
|
10516
|
+
* `latestSeq` is reported whether or not the messages were capped, so a
|
|
10517
|
+
* client that is further behind than one page can tell.
|
|
10518
|
+
*/
|
|
10519
|
+
async replay(channel, sinceSeq = 0, limit = DEFAULT_REPLAY_LIMIT) {
|
|
10520
|
+
const capped = Math.max(1, Math.min(Math.floor(limit) || DEFAULT_REPLAY_LIMIT, MAX_REPLAY_LIMIT));
|
|
10521
|
+
const after = Number.isFinite(sinceSeq) && sinceSeq > 0 ? Math.floor(sinceSeq) : 0;
|
|
10522
|
+
const messages = (await this.db.execute(sql`
|
|
10523
|
+
SELECT seq, event, payload, sender_id, created_at
|
|
10524
|
+
FROM rebase.channel_messages
|
|
10525
|
+
WHERE channel = ${channel} AND seq > ${after}
|
|
10526
|
+
ORDER BY seq ASC
|
|
10527
|
+
LIMIT ${capped}
|
|
10528
|
+
`)).rows.map((row) => ({
|
|
10529
|
+
seq: Number(row.seq),
|
|
10530
|
+
event: row.event,
|
|
10531
|
+
payload: row.payload,
|
|
10532
|
+
senderId: row.sender_id ?? void 0,
|
|
10533
|
+
at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
|
|
10534
|
+
}));
|
|
10535
|
+
const cursorRow = (await this.db.execute(sql`
|
|
10536
|
+
SELECT last_seq FROM rebase.channel_cursors WHERE channel = ${channel}
|
|
10537
|
+
`)).rows[0];
|
|
10538
|
+
return {
|
|
10539
|
+
messages,
|
|
10540
|
+
latestSeq: cursorRow ? Number(cursorRow.last_seq) : 0
|
|
10541
|
+
};
|
|
10542
|
+
}
|
|
10543
|
+
/**
|
|
10544
|
+
* Enforce a channel's retention bounds.
|
|
10545
|
+
*
|
|
10546
|
+
* Throttled per channel, so a burst of operations prunes once rather than
|
|
10547
|
+
* once per message — the cost then tracks elapsed time instead of write
|
|
10548
|
+
* volume, which is what makes retention affordable on a hot channel.
|
|
10549
|
+
*/
|
|
10550
|
+
async prune(channel, retention) {
|
|
10551
|
+
const now = Date.now();
|
|
10552
|
+
if (now - (this.lastPruned.get(channel) ?? 0) < PRUNE_THROTTLE_MS) return 0;
|
|
10553
|
+
this.lastPruned.set(channel, now);
|
|
10554
|
+
let deleted = 0;
|
|
10555
|
+
if (retention.ttlMs !== void 0) {
|
|
10556
|
+
const result = await this.db.execute(sql`
|
|
10557
|
+
DELETE FROM rebase.channel_messages
|
|
10558
|
+
WHERE channel = ${channel}
|
|
10559
|
+
AND created_at < NOW() - MAKE_INTERVAL(secs => ${retention.ttlMs / 1e3})
|
|
10560
|
+
`);
|
|
10561
|
+
deleted += result.rowCount ?? 0;
|
|
10562
|
+
}
|
|
10563
|
+
if (retention.limit !== void 0 && retention.limit > 0) {
|
|
10564
|
+
const result = await this.db.execute(sql`
|
|
10565
|
+
DELETE FROM rebase.channel_messages
|
|
10566
|
+
WHERE channel = ${channel}
|
|
10567
|
+
AND seq <= (
|
|
10568
|
+
SELECT seq FROM rebase.channel_messages
|
|
10569
|
+
WHERE channel = ${channel}
|
|
10570
|
+
ORDER BY seq DESC
|
|
10571
|
+
OFFSET ${Math.floor(retention.limit)} LIMIT 1
|
|
10572
|
+
)
|
|
10573
|
+
`);
|
|
10574
|
+
deleted += result.rowCount ?? 0;
|
|
10575
|
+
}
|
|
10576
|
+
return deleted;
|
|
10577
|
+
}
|
|
10578
|
+
/** Forget throttle and match caches. Called on shutdown. */
|
|
10579
|
+
clear() {
|
|
10580
|
+
this.resolved.clear();
|
|
10581
|
+
this.lastPruned.clear();
|
|
10582
|
+
}
|
|
10583
|
+
};
|
|
10584
|
+
//#endregion
|
|
9614
10585
|
//#region src/services/realtimeService.ts
|
|
9615
10586
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
9616
10587
|
var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -9626,6 +10597,26 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9626
10597
|
clients = /* @__PURE__ */ new Map();
|
|
9627
10598
|
channels = /* @__PURE__ */ new Map();
|
|
9628
10599
|
presence = /* @__PURE__ */ new Map();
|
|
10600
|
+
/**
|
|
10601
|
+
* Ordered, replayable history for channels that opt into it.
|
|
10602
|
+
*
|
|
10603
|
+
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
10604
|
+
* then unless retention rules were supplied — so presence and ephemeral
|
|
10605
|
+
* notification channels never touch it. See `channel-history.ts`.
|
|
10606
|
+
*/
|
|
10607
|
+
channelHistory;
|
|
10608
|
+
/**
|
|
10609
|
+
* One promise chain per retained channel, so that assigning a sequence
|
|
10610
|
+
* number and fanning the message out happen in the same order for every
|
|
10611
|
+
* message on that channel.
|
|
10612
|
+
*
|
|
10613
|
+
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
10614
|
+
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
10615
|
+
* order would disagree, which is exactly the divergence sequence numbers
|
|
10616
|
+
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
10617
|
+
* wait on each other.
|
|
10618
|
+
*/
|
|
10619
|
+
channelSendQueues = /* @__PURE__ */ new Map();
|
|
9629
10620
|
presenceInterval;
|
|
9630
10621
|
static PRESENCE_TIMEOUT_MS = 3e4;
|
|
9631
10622
|
dataService;
|
|
@@ -9802,6 +10793,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9802
10793
|
case "broadcast":
|
|
9803
10794
|
this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
|
|
9804
10795
|
break;
|
|
10796
|
+
case "channel_history":
|
|
10797
|
+
await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
|
|
10798
|
+
break;
|
|
9805
10799
|
case "presence_track":
|
|
9806
10800
|
this.joinChannel(clientId, payload?.channel);
|
|
9807
10801
|
this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
|
|
@@ -9848,7 +10842,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9848
10842
|
startAfter: request.startAfter,
|
|
9849
10843
|
searchString: request.searchString
|
|
9850
10844
|
}, authContext);
|
|
9851
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows);
|
|
10845
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
9852
10846
|
} catch (error) {
|
|
9853
10847
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
9854
10848
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -9949,7 +10943,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9949
10943
|
if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
9950
10944
|
else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
|
|
9951
10945
|
else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
9952
|
-
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
|
|
10946
|
+
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
|
|
9953
10947
|
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
9954
10948
|
}
|
|
9955
10949
|
} catch (error) {
|
|
@@ -9979,7 +10973,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9979
10973
|
if (!this._subscriptions.has(subscriptionId)) return;
|
|
9980
10974
|
try {
|
|
9981
10975
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
|
|
9982
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
|
|
10976
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
9983
10977
|
} catch (error) {
|
|
9984
10978
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
9985
10979
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -10012,12 +11006,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10012
11006
|
if (this.driver) {
|
|
10013
11007
|
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
10014
11008
|
const activeAuth = authContext || {
|
|
10015
|
-
|
|
11009
|
+
uid: "anon",
|
|
10016
11010
|
roles: ["anon"]
|
|
10017
11011
|
};
|
|
10018
11012
|
return await this.db.transaction(async (tx) => {
|
|
10019
11013
|
await applyAuthContext(tx, {
|
|
10020
|
-
|
|
11014
|
+
uid: activeAuth.uid,
|
|
10021
11015
|
roles: activeAuth.roles
|
|
10022
11016
|
}, this.rlsUserRole);
|
|
10023
11017
|
const txEntityService = new DataService(tx, this.registry);
|
|
@@ -10049,7 +11043,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10049
11043
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
10050
11044
|
const contextForCallback = {
|
|
10051
11045
|
user: {
|
|
10052
|
-
uid: activeAuth.
|
|
11046
|
+
uid: activeAuth.uid,
|
|
10053
11047
|
roles: activeAuth.roles
|
|
10054
11048
|
},
|
|
10055
11049
|
driver: this.driver,
|
|
@@ -10141,12 +11135,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10141
11135
|
if (this.driver) {
|
|
10142
11136
|
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
10143
11137
|
const activeAuth = authContext || {
|
|
10144
|
-
|
|
11138
|
+
uid: "anon",
|
|
10145
11139
|
roles: ["anon"]
|
|
10146
11140
|
};
|
|
10147
11141
|
return await this.db.transaction(async (tx) => {
|
|
10148
11142
|
await applyAuthContext(tx, {
|
|
10149
|
-
|
|
11143
|
+
uid: activeAuth.uid,
|
|
10150
11144
|
roles: activeAuth.roles
|
|
10151
11145
|
}, this.rlsUserRole);
|
|
10152
11146
|
let processedEntity = await new DataService(tx, this.registry).fetchOne(notifyPath, id, collection?.databaseId);
|
|
@@ -10162,7 +11156,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10162
11156
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
10163
11157
|
const contextForCallback = {
|
|
10164
11158
|
user: {
|
|
10165
|
-
uid: activeAuth.
|
|
11159
|
+
uid: activeAuth.uid,
|
|
10166
11160
|
roles: activeAuth.roles
|
|
10167
11161
|
},
|
|
10168
11162
|
driver: this.driver,
|
|
@@ -10193,11 +11187,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10193
11187
|
}
|
|
10194
11188
|
return await this.dataService.fetchOne(notifyPath, id);
|
|
10195
11189
|
}
|
|
10196
|
-
sendCollectionUpdate(clientId, subscriptionId, rows) {
|
|
11190
|
+
sendCollectionUpdate(clientId, subscriptionId, rows, path) {
|
|
10197
11191
|
const message = {
|
|
10198
11192
|
type: "collection_update",
|
|
10199
11193
|
subscriptionId,
|
|
10200
|
-
rows
|
|
11194
|
+
rows,
|
|
11195
|
+
pks: this.primaryKeysForPath(path)
|
|
10201
11196
|
};
|
|
10202
11197
|
this.sendMessage(clientId, message);
|
|
10203
11198
|
}
|
|
@@ -10212,16 +11207,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10212
11207
|
/**
|
|
10213
11208
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
10214
11209
|
* The client can merge this into its cached data for instant feedback.
|
|
11210
|
+
*
|
|
11211
|
+
* The key columns ride along: the patch names a row by address, and the
|
|
11212
|
+
* client has to find that row among the ones it cached — which carry
|
|
11213
|
+
* columns and no address. The SDK holds no collection config to derive one
|
|
11214
|
+
* from, so this is the only place the mapping can come from.
|
|
10215
11215
|
*/
|
|
10216
|
-
sendCollectionPatch(clientId, subscriptionId, id, row) {
|
|
11216
|
+
sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
|
|
10217
11217
|
const message = {
|
|
10218
11218
|
type: "collection_patch",
|
|
10219
11219
|
subscriptionId,
|
|
10220
11220
|
id,
|
|
10221
|
-
row
|
|
11221
|
+
row,
|
|
11222
|
+
pks: this.primaryKeysForPath(notifyPath)
|
|
10222
11223
|
};
|
|
10223
11224
|
this.sendMessage(clientId, message);
|
|
10224
11225
|
}
|
|
11226
|
+
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
11227
|
+
primaryKeysForPath(path) {
|
|
11228
|
+
try {
|
|
11229
|
+
const collection = this.registry.getCollectionByPath(path);
|
|
11230
|
+
if (!collection) return void 0;
|
|
11231
|
+
const keys = getPrimaryKeys(collection, this.registry);
|
|
11232
|
+
return keys.length > 0 ? keys : void 0;
|
|
11233
|
+
} catch {
|
|
11234
|
+
return;
|
|
11235
|
+
}
|
|
11236
|
+
}
|
|
10225
11237
|
sendError(clientId, error, subscriptionId, code) {
|
|
10226
11238
|
const message = {
|
|
10227
11239
|
type: "error",
|
|
@@ -10270,15 +11282,67 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10270
11282
|
}
|
|
10271
11283
|
this.removePresence(clientId, channel);
|
|
10272
11284
|
}
|
|
10273
|
-
/**
|
|
11285
|
+
/**
|
|
11286
|
+
* Broadcast a message to all clients in a channel except the sender.
|
|
11287
|
+
*
|
|
11288
|
+
* On a channel with no retention rule this is what it always was: a
|
|
11289
|
+
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
11290
|
+
* SQL and no await — the body below runs to completion before returning.
|
|
11291
|
+
*
|
|
11292
|
+
* On a retained channel the message is durably numbered first and only then
|
|
11293
|
+
* delivered, through a per-channel queue so that delivery order matches
|
|
11294
|
+
* sequence order. That ordering is the whole point: a client that catches up
|
|
11295
|
+
* with `sinceSeq` has to arrive at the same state as one that never
|
|
11296
|
+
* disconnected.
|
|
11297
|
+
*/
|
|
10274
11298
|
broadcastToChannel(clientId, channel, event, payload) {
|
|
11299
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
11300
|
+
if (!retention) {
|
|
11301
|
+
this.fanOutBroadcast(clientId, channel, event, payload);
|
|
11302
|
+
return;
|
|
11303
|
+
}
|
|
11304
|
+
const next = (this.channelSendQueues.get(channel) ?? Promise.resolve()).catch(() => {}).then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
|
|
11305
|
+
this.channelSendQueues.set(channel, next);
|
|
11306
|
+
next.finally(() => {
|
|
11307
|
+
if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
|
|
11308
|
+
});
|
|
11309
|
+
}
|
|
11310
|
+
/**
|
|
11311
|
+
* Number a broadcast, store it, then deliver it.
|
|
11312
|
+
*
|
|
11313
|
+
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
11314
|
+
* put it in front of live subscribers while leaving it absent from every
|
|
11315
|
+
* future replay — the two views of the channel would disagree permanently,
|
|
11316
|
+
* and no later message could repair the gap. Failing loudly to the sender
|
|
11317
|
+
* instead lets it retry, which for an operation stream is the only outcome
|
|
11318
|
+
* that keeps clients convergent.
|
|
11319
|
+
*/
|
|
11320
|
+
async persistAndFanOut(clientId, channel, event, payload, retention) {
|
|
11321
|
+
let seq;
|
|
11322
|
+
try {
|
|
11323
|
+
({seq} = await this.channelHistory.append(channel, event, payload, clientId));
|
|
11324
|
+
} catch (error) {
|
|
11325
|
+
logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
|
|
11326
|
+
this.sendError(clientId, `Could not persist broadcast on retained channel "${channel}"`, void 0, "CHANNEL_HISTORY_WRITE_FAILED");
|
|
11327
|
+
return;
|
|
11328
|
+
}
|
|
11329
|
+
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
11330
|
+
try {
|
|
11331
|
+
await this.channelHistory.prune(channel, retention);
|
|
11332
|
+
} catch (error) {
|
|
11333
|
+
logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
|
|
11334
|
+
}
|
|
11335
|
+
}
|
|
11336
|
+
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
11337
|
+
fanOutBroadcast(clientId, channel, event, payload, seq) {
|
|
10275
11338
|
const members = this.channels.get(channel);
|
|
10276
11339
|
if (!members) return;
|
|
10277
11340
|
const message = JSON.stringify({
|
|
10278
11341
|
type: "broadcast",
|
|
10279
11342
|
channel,
|
|
10280
11343
|
event,
|
|
10281
|
-
payload
|
|
11344
|
+
payload,
|
|
11345
|
+
...seq !== void 0 ? { seq } : {}
|
|
10282
11346
|
});
|
|
10283
11347
|
for (const memberId of members) {
|
|
10284
11348
|
if (memberId === clientId) continue;
|
|
@@ -10286,6 +11350,55 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10286
11350
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(message);
|
|
10287
11351
|
}
|
|
10288
11352
|
}
|
|
11353
|
+
/**
|
|
11354
|
+
* Install retention rules and create the tables they need.
|
|
11355
|
+
*
|
|
11356
|
+
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
11357
|
+
* inert, no schema is created, and broadcast keeps its original
|
|
11358
|
+
* fire-and-forget path.
|
|
11359
|
+
*/
|
|
11360
|
+
async configureChannelHistory(rules) {
|
|
11361
|
+
this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
|
|
11362
|
+
if (!this.channelHistory.enabled) return;
|
|
11363
|
+
await this.channelHistory.ensureTables();
|
|
11364
|
+
}
|
|
11365
|
+
/** Whether any channel is configured to retain messages. */
|
|
11366
|
+
isChannelHistoryEnabled() {
|
|
11367
|
+
return this.channelHistory?.enabled ?? false;
|
|
11368
|
+
}
|
|
11369
|
+
/**
|
|
11370
|
+
* Answer a client's catch-up request.
|
|
11371
|
+
*
|
|
11372
|
+
* A channel with no retention rule is answered with `retained: false`
|
|
11373
|
+
* rather than an empty list, so the client can tell "you missed nothing"
|
|
11374
|
+
* apart from "this channel never keeps anything" — the second means its
|
|
11375
|
+
* reconnect strategy has to be a full resync, and silence would leave it
|
|
11376
|
+
* guessing.
|
|
11377
|
+
*/
|
|
11378
|
+
async handleChannelHistoryRequest(clientId, channel, sinceSeq, limit) {
|
|
11379
|
+
if (!channel) return;
|
|
11380
|
+
if (!this.channelHistory?.retentionFor(channel)) {
|
|
11381
|
+
this.sendChannelHistory(clientId, channel, [], false);
|
|
11382
|
+
return;
|
|
11383
|
+
}
|
|
11384
|
+
try {
|
|
11385
|
+
const { messages, latestSeq } = await this.channelHistory.replay(channel, sinceSeq, limit);
|
|
11386
|
+
this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
|
|
11387
|
+
} catch (error) {
|
|
11388
|
+
logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
|
|
11389
|
+
this.sendError(clientId, `Could not replay history for channel "${channel}"`, void 0, "CHANNEL_HISTORY_READ_FAILED");
|
|
11390
|
+
}
|
|
11391
|
+
}
|
|
11392
|
+
sendChannelHistory(clientId, channel, messages, retained, latestSeq) {
|
|
11393
|
+
const ws = this.clients.get(clientId);
|
|
11394
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
|
|
11395
|
+
type: "channel_history",
|
|
11396
|
+
channel,
|
|
11397
|
+
messages,
|
|
11398
|
+
retained,
|
|
11399
|
+
...latestSeq !== void 0 ? { latestSeq } : {}
|
|
11400
|
+
}));
|
|
11401
|
+
}
|
|
10289
11402
|
/** Track presence in a channel */
|
|
10290
11403
|
trackPresence(clientId, channel, state) {
|
|
10291
11404
|
if (!this.presence.has(channel)) this.presence.set(channel, /* @__PURE__ */ new Map());
|
|
@@ -10366,6 +11479,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10366
11479
|
this.subscriptionCallbacks.clear();
|
|
10367
11480
|
this.channels.clear();
|
|
10368
11481
|
this.presence.clear();
|
|
11482
|
+
await Promise.allSettled([...this.channelSendQueues.values()]);
|
|
11483
|
+
this.channelSendQueues.clear();
|
|
11484
|
+
this.channelHistory?.clear();
|
|
10369
11485
|
if (this.presenceInterval) {
|
|
10370
11486
|
clearInterval(this.presenceInterval);
|
|
10371
11487
|
this.presenceInterval = void 0;
|
|
@@ -10469,12 +11585,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10469
11585
|
}
|
|
10470
11586
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
10471
11587
|
extractIdFromCdcRow(collection, row) {
|
|
10472
|
-
|
|
10473
|
-
const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
|
|
10474
|
-
if (composite && composite !== ":::") return composite;
|
|
10475
|
-
} catch {}
|
|
10476
|
-
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
10477
|
-
return "*";
|
|
11588
|
+
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
10478
11589
|
}
|
|
10479
11590
|
dedupKey(path, id, databaseId) {
|
|
10480
11591
|
return `${databaseId ?? ""}::${path}::${id}`;
|
|
@@ -10712,20 +11823,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10712
11823
|
if (authAdapter) try {
|
|
10713
11824
|
const adapterUser = authAdapter.verifyToken ? await authAdapter.verifyToken(token) : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", { headers: { Authorization: `Bearer ${token}` } }));
|
|
10714
11825
|
if (adapterUser) verifiedUser = {
|
|
10715
|
-
|
|
11826
|
+
uid: adapterUser.uid,
|
|
10716
11827
|
roles: adapterUser.roles,
|
|
10717
11828
|
isAdmin: adapterUser.isAdmin
|
|
10718
11829
|
};
|
|
10719
11830
|
} catch {}
|
|
10720
11831
|
else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
|
|
10721
|
-
|
|
11832
|
+
uid: "service",
|
|
10722
11833
|
roles: ["admin"],
|
|
10723
11834
|
isAdmin: true
|
|
10724
11835
|
};
|
|
10725
11836
|
else {
|
|
10726
11837
|
const jwtPayload = extractUserFromToken(token);
|
|
10727
11838
|
if (jwtPayload) verifiedUser = {
|
|
10728
|
-
|
|
11839
|
+
uid: jwtPayload.uid,
|
|
10729
11840
|
roles: jwtPayload.roles ?? [],
|
|
10730
11841
|
isAdmin: (jwtPayload.roles ?? []).some((r) => r === "admin")
|
|
10731
11842
|
};
|
|
@@ -10741,11 +11852,11 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10741
11852
|
type: "AUTH_SUCCESS",
|
|
10742
11853
|
requestId,
|
|
10743
11854
|
payload: {
|
|
10744
|
-
|
|
11855
|
+
uid: verifiedUser.uid,
|
|
10745
11856
|
roles: verifiedUser.roles
|
|
10746
11857
|
}
|
|
10747
11858
|
}));
|
|
10748
|
-
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.
|
|
11859
|
+
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
|
|
10749
11860
|
} else {
|
|
10750
11861
|
wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
|
|
10751
11862
|
sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
|
|
@@ -10783,7 +11894,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10783
11894
|
const session = clientSessions.get(clientId);
|
|
10784
11895
|
if (typeof driver.withAuth === "function") try {
|
|
10785
11896
|
const userForAuth = session?.user ? {
|
|
10786
|
-
uid: session.user.
|
|
11897
|
+
uid: session.user.uid,
|
|
10787
11898
|
displayName: null,
|
|
10788
11899
|
email: null,
|
|
10789
11900
|
photoURL: null,
|
|
@@ -10917,7 +12028,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10917
12028
|
sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
|
|
10918
12029
|
options,
|
|
10919
12030
|
resultRows: Array.isArray(result) ? result.length : "unknown",
|
|
10920
|
-
|
|
12031
|
+
uid: auditSession?.user?.uid ?? "unknown",
|
|
10921
12032
|
roles: auditSession?.user?.roles ?? [],
|
|
10922
12033
|
isAdmin: auditSession?.user?.isAdmin ?? false
|
|
10923
12034
|
}));
|
|
@@ -10962,6 +12073,21 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10962
12073
|
ws.send(JSON.stringify(response));
|
|
10963
12074
|
}
|
|
10964
12075
|
break;
|
|
12076
|
+
case "FETCH_APPLICATION_ROLES":
|
|
12077
|
+
{
|
|
12078
|
+
wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
|
|
12079
|
+
const admin = (await getScopedDelegate()).admin;
|
|
12080
|
+
let roles = [];
|
|
12081
|
+
if (isSQLAdmin(admin) && admin.fetchApplicationRoles) roles = await admin.fetchApplicationRoles();
|
|
12082
|
+
wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
|
|
12083
|
+
const response = {
|
|
12084
|
+
type: "FETCH_APPLICATION_ROLES_SUCCESS",
|
|
12085
|
+
payload: { roles },
|
|
12086
|
+
requestId
|
|
12087
|
+
};
|
|
12088
|
+
ws.send(JSON.stringify(response));
|
|
12089
|
+
}
|
|
12090
|
+
break;
|
|
10965
12091
|
case "FETCH_CURRENT_DATABASE":
|
|
10966
12092
|
{
|
|
10967
12093
|
wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
|
|
@@ -11068,14 +12194,15 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11068
12194
|
case "broadcast":
|
|
11069
12195
|
case "presence_track":
|
|
11070
12196
|
case "presence_untrack":
|
|
11071
|
-
case "presence_state":
|
|
12197
|
+
case "presence_state":
|
|
12198
|
+
case "channel_history": {
|
|
11072
12199
|
wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
|
|
11073
12200
|
const session = clientSessions.get(clientId);
|
|
11074
12201
|
const authContext = session?.user ? {
|
|
11075
|
-
|
|
12202
|
+
uid: session.user.uid,
|
|
11076
12203
|
roles: session.user.roles ?? []
|
|
11077
12204
|
} : {
|
|
11078
|
-
|
|
12205
|
+
uid: "anon",
|
|
11079
12206
|
roles: ["anon"]
|
|
11080
12207
|
};
|
|
11081
12208
|
await realtimeService.handleClientMessage(clientId, {
|
|
@@ -11090,7 +12217,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11090
12217
|
} catch (error) {
|
|
11091
12218
|
logger.error("💥 [WebSocket Server] Error handling message", { error });
|
|
11092
12219
|
if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
|
|
11093
|
-
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error
|
|
12220
|
+
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
|
|
11094
12221
|
const errorResponse = {
|
|
11095
12222
|
type: "ERROR",
|
|
11096
12223
|
requestId,
|
|
@@ -19934,6 +21061,33 @@ function formatBytes(bytes) {
|
|
|
19934
21061
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
19935
21062
|
}
|
|
19936
21063
|
//#endregion
|
|
21064
|
+
//#region src/collections/buildRegistry.ts
|
|
21065
|
+
/**
|
|
21066
|
+
* Build the collection registry for a driver.
|
|
21067
|
+
*
|
|
21068
|
+
* The order matters and is the reason this is one function rather than a run of
|
|
21069
|
+
* statements in the bootstrapper. Keys are resolved from the drizzle schema, so
|
|
21070
|
+
* anything that inspects them has to run *after* the tables are registered —
|
|
21071
|
+
* and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
|
|
21072
|
+
* collection whose table it cannot look up is one it has nothing to say about.
|
|
21073
|
+
* Warned too early, it would skip every collection and report nothing, which
|
|
21074
|
+
* reads exactly like having nothing to report.
|
|
21075
|
+
*/
|
|
21076
|
+
function buildCollectionRegistry(schema) {
|
|
21077
|
+
const registry = new PostgresCollectionRegistry();
|
|
21078
|
+
if (schema.collections) {
|
|
21079
|
+
registry.registerMultiple(schema.collections);
|
|
21080
|
+
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
21081
|
+
}
|
|
21082
|
+
if (schema.tables) Object.values(schema.tables).forEach((table) => {
|
|
21083
|
+
if (isTable(table)) registry.registerTable(table, getTableName(table));
|
|
21084
|
+
});
|
|
21085
|
+
if (schema.enums) registry.registerEnums(schema.enums);
|
|
21086
|
+
if (schema.relations) registry.registerRelations(schema.relations);
|
|
21087
|
+
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
21088
|
+
return registry;
|
|
21089
|
+
}
|
|
21090
|
+
//#endregion
|
|
19937
21091
|
//#region src/auth/ensure-tables.ts
|
|
19938
21092
|
/**
|
|
19939
21093
|
* Auto-create auth tables if they don't exist.
|
|
@@ -20004,9 +21158,65 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20004
21158
|
)
|
|
20005
21159
|
`);
|
|
20006
21160
|
await db.execute(sql`
|
|
21161
|
+
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
21162
|
+
BEGIN
|
|
21163
|
+
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
21164
|
+
NEW.uid := NEW.user_id;
|
|
21165
|
+
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
21166
|
+
NEW.user_id := NEW.uid;
|
|
21167
|
+
END IF;
|
|
21168
|
+
RETURN NEW;
|
|
21169
|
+
END $$ LANGUAGE plpgsql
|
|
21170
|
+
`);
|
|
21171
|
+
for (const authTable of [
|
|
21172
|
+
"user_identities",
|
|
21173
|
+
"refresh_tokens",
|
|
21174
|
+
"password_reset_tokens",
|
|
21175
|
+
"magic_link_tokens",
|
|
21176
|
+
"mfa_factors",
|
|
21177
|
+
"recovery_codes"
|
|
21178
|
+
]) {
|
|
21179
|
+
const qualified = `"${authSchema}"."${authTable}"`;
|
|
21180
|
+
await db.execute(sql`
|
|
21181
|
+
DO $$
|
|
21182
|
+
DECLARE
|
|
21183
|
+
has_legacy boolean;
|
|
21184
|
+
has_uid boolean;
|
|
21185
|
+
BEGIN
|
|
21186
|
+
SELECT
|
|
21187
|
+
bool_or(column_name = 'user_id'),
|
|
21188
|
+
bool_or(column_name = 'uid')
|
|
21189
|
+
INTO has_legacy, has_uid
|
|
21190
|
+
FROM information_schema.columns
|
|
21191
|
+
WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
|
|
21192
|
+
AND table_name = ${sql.raw(`'${authTable}'`)};
|
|
21193
|
+
|
|
21194
|
+
-- Table absent, or already uid-only (a fresh install, or
|
|
21195
|
+
-- phase 2 already run): nothing to do.
|
|
21196
|
+
IF has_legacy IS NOT TRUE THEN
|
|
21197
|
+
RETURN;
|
|
21198
|
+
END IF;
|
|
21199
|
+
|
|
21200
|
+
IF has_uid IS NOT TRUE THEN
|
|
21201
|
+
EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
|
|
21202
|
+
EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
|
|
21203
|
+
EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
|
|
21204
|
+
END IF;
|
|
21205
|
+
|
|
21206
|
+
-- New code inserts uid and never user_id, so the legacy
|
|
21207
|
+
-- column can no longer be NOT NULL. The trigger below
|
|
21208
|
+
-- backfills it, but the constraint is checked first.
|
|
21209
|
+
EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
|
|
21210
|
+
|
|
21211
|
+
EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
|
|
21212
|
+
EXECUTE ${sql.raw(`'CREATE TRIGGER sync_uid_user_id BEFORE INSERT OR UPDATE ON ${qualified} FOR EACH ROW EXECUTE FUNCTION "${authSchema}".sync_uid_user_id()'`)};
|
|
21213
|
+
END $$
|
|
21214
|
+
`);
|
|
21215
|
+
}
|
|
21216
|
+
await db.execute(sql`
|
|
20007
21217
|
CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
|
|
20008
21218
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20009
|
-
|
|
21219
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20010
21220
|
provider TEXT NOT NULL,
|
|
20011
21221
|
provider_id TEXT NOT NULL,
|
|
20012
21222
|
profile_data JSONB,
|
|
@@ -20017,18 +21227,18 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20017
21227
|
`);
|
|
20018
21228
|
await db.execute(sql`
|
|
20019
21229
|
CREATE INDEX IF NOT EXISTS idx_user_identities_user
|
|
20020
|
-
ON ${sql.raw(userIdentitiesTable)}(
|
|
21230
|
+
ON ${sql.raw(userIdentitiesTable)}(uid)
|
|
20021
21231
|
`);
|
|
20022
21232
|
await db.execute(sql`
|
|
20023
21233
|
CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
|
|
20024
21234
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20025
|
-
|
|
21235
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20026
21236
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20027
21237
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
20028
21238
|
user_agent TEXT,
|
|
20029
21239
|
ip_address TEXT,
|
|
20030
21240
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
20031
|
-
CONSTRAINT unique_device_session UNIQUE (
|
|
21241
|
+
CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
|
|
20032
21242
|
)
|
|
20033
21243
|
`);
|
|
20034
21244
|
await db.execute(sql`
|
|
@@ -20037,12 +21247,12 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20037
21247
|
`);
|
|
20038
21248
|
await db.execute(sql`
|
|
20039
21249
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
|
|
20040
|
-
ON ${sql.raw(refreshTokensTableName)}(
|
|
21250
|
+
ON ${sql.raw(refreshTokensTableName)}(uid)
|
|
20041
21251
|
`);
|
|
20042
21252
|
await db.execute(sql`
|
|
20043
21253
|
CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
|
|
20044
21254
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20045
|
-
|
|
21255
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20046
21256
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20047
21257
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
20048
21258
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
@@ -20055,13 +21265,13 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20055
21265
|
`);
|
|
20056
21266
|
await db.execute(sql`
|
|
20057
21267
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
|
|
20058
|
-
ON ${sql.raw(passwordResetTokensTableName)}(
|
|
21268
|
+
ON ${sql.raw(passwordResetTokensTableName)}(uid)
|
|
20059
21269
|
`);
|
|
20060
21270
|
const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
|
|
20061
21271
|
await db.execute(sql`
|
|
20062
21272
|
CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
|
|
20063
21273
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20064
|
-
|
|
21274
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20065
21275
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20066
21276
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
20067
21277
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
@@ -20074,7 +21284,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20074
21284
|
`);
|
|
20075
21285
|
await db.execute(sql`
|
|
20076
21286
|
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
|
|
20077
|
-
ON ${sql.raw(magicLinkTokensTableName)}(
|
|
21287
|
+
ON ${sql.raw(magicLinkTokensTableName)}(uid)
|
|
20078
21288
|
`);
|
|
20079
21289
|
await db.execute(sql`
|
|
20080
21290
|
CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
|
|
@@ -20088,7 +21298,10 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20088
21298
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
|
|
20089
21299
|
await tx.execute(sql`
|
|
20090
21300
|
CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
|
|
20091
|
-
SELECT
|
|
21301
|
+
SELECT COALESCE(
|
|
21302
|
+
NULLIF(current_setting('app.uid', true), ''),
|
|
21303
|
+
NULLIF(current_setting('app.user_id', true), '')
|
|
21304
|
+
);
|
|
20092
21305
|
$$ LANGUAGE sql STABLE
|
|
20093
21306
|
`);
|
|
20094
21307
|
await tx.execute(sql`
|
|
@@ -20105,14 +21318,22 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20105
21318
|
$$ LANGUAGE sql STABLE
|
|
20106
21319
|
`);
|
|
20107
21320
|
});
|
|
20108
|
-
|
|
20109
|
-
|
|
20110
|
-
|
|
20111
|
-
|
|
20112
|
-
|
|
20113
|
-
|
|
20114
|
-
|
|
20115
|
-
|
|
21321
|
+
for (const columnDef of [
|
|
21322
|
+
"display_name VARCHAR(255)",
|
|
21323
|
+
"photo_url VARCHAR(500)",
|
|
21324
|
+
"roles TEXT[] DEFAULT '{}' NOT NULL",
|
|
21325
|
+
"password_hash VARCHAR(255)",
|
|
21326
|
+
"email_verified BOOLEAN DEFAULT FALSE NOT NULL",
|
|
21327
|
+
"email_verification_token VARCHAR(255)",
|
|
21328
|
+
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
21329
|
+
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
21330
|
+
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
21331
|
+
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
21332
|
+
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
21333
|
+
]) await db.execute(sql`
|
|
21334
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
21335
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
|
|
21336
|
+
`);
|
|
20116
21337
|
try {
|
|
20117
21338
|
if ((await db.execute(sql`
|
|
20118
21339
|
SELECT EXISTS (
|
|
@@ -20143,7 +21364,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20143
21364
|
await db.execute(sql`
|
|
20144
21365
|
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
|
|
20145
21366
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20146
|
-
|
|
21367
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20147
21368
|
factor_type TEXT NOT NULL DEFAULT 'totp',
|
|
20148
21369
|
secret_encrypted TEXT NOT NULL,
|
|
20149
21370
|
friendly_name TEXT,
|
|
@@ -20154,7 +21375,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20154
21375
|
`);
|
|
20155
21376
|
await db.execute(sql`
|
|
20156
21377
|
CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
|
|
20157
|
-
ON ${sql.raw(mfaFactorsTableName)}(
|
|
21378
|
+
ON ${sql.raw(mfaFactorsTableName)}(uid)
|
|
20158
21379
|
`);
|
|
20159
21380
|
await db.execute(sql`
|
|
20160
21381
|
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (
|
|
@@ -20173,7 +21394,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20173
21394
|
await db.execute(sql`
|
|
20174
21395
|
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
20175
21396
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20176
|
-
|
|
21397
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20177
21398
|
code_hash TEXT NOT NULL,
|
|
20178
21399
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
20179
21400
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
@@ -20181,8 +21402,36 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20181
21402
|
`);
|
|
20182
21403
|
await db.execute(sql`
|
|
20183
21404
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
20184
|
-
ON ${sql.raw(recoveryCodesTableName)}(
|
|
21405
|
+
ON ${sql.raw(recoveryCodesTableName)}(uid)
|
|
20185
21406
|
`);
|
|
21407
|
+
try {
|
|
21408
|
+
const authTablePairs = [
|
|
21409
|
+
[usersSchema, resolvedTable],
|
|
21410
|
+
[authSchema, "user_identities"],
|
|
21411
|
+
[authSchema, "refresh_tokens"],
|
|
21412
|
+
[authSchema, "password_reset_tokens"],
|
|
21413
|
+
[authSchema, "app_config"],
|
|
21414
|
+
[authSchema, "mfa_factors"],
|
|
21415
|
+
[authSchema, "mfa_challenges"],
|
|
21416
|
+
[authSchema, "recovery_codes"]
|
|
21417
|
+
];
|
|
21418
|
+
for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
|
|
21419
|
+
SELECT 1
|
|
21420
|
+
FROM pg_class c
|
|
21421
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
21422
|
+
WHERE n.nspname = ${schemaName}
|
|
21423
|
+
AND c.relname = ${tableName}
|
|
21424
|
+
AND c.relforcerowsecurity
|
|
21425
|
+
`)).rows.length > 0) {
|
|
21426
|
+
await db.execute(sql`
|
|
21427
|
+
ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
|
|
21428
|
+
NO FORCE ROW LEVEL SECURITY
|
|
21429
|
+
`);
|
|
21430
|
+
logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
|
|
21431
|
+
}
|
|
21432
|
+
} catch (rlsReconcileError) {
|
|
21433
|
+
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
21434
|
+
}
|
|
20186
21435
|
logger.info("✅ Auth tables ready");
|
|
20187
21436
|
} catch (error) {
|
|
20188
21437
|
logger.error("❌ Failed to create auth tables", { error });
|
|
@@ -20230,6 +21479,32 @@ var UserService = class {
|
|
|
20230
21479
|
const name = getTableName(this.usersTable);
|
|
20231
21480
|
return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
|
|
20232
21481
|
}
|
|
21482
|
+
/**
|
|
21483
|
+
* Run a privileged auth write with an explicitly cleared RLS context.
|
|
21484
|
+
*
|
|
21485
|
+
* The auth services run on the base/owner connection, which by design
|
|
21486
|
+
* carries a NULL `app.uid` so the `auth.uid() IS NULL` server-escape
|
|
21487
|
+
* in the default policies applies. That NULL is normally guaranteed by
|
|
21488
|
+
* `set_config(..., is_local = true)` resetting at transaction end — but a
|
|
21489
|
+
* GUC that survives on a pooled connection (or a connection role that
|
|
21490
|
+
* doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
|
|
21491
|
+
* turns the trusted write into an RLS-scoped one and denies it with
|
|
21492
|
+
* SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
|
|
21493
|
+
* single chokepoint, makes the server context deterministic instead of
|
|
21494
|
+
* trusting whatever state the pool hands us. `auth.uid()` reads '' as
|
|
21495
|
+
* NULL via NULLIF, so '' is the server context.
|
|
21496
|
+
*/
|
|
21497
|
+
async withServerContext(fn) {
|
|
21498
|
+
return await this.db.transaction(async (tx) => {
|
|
21499
|
+
await tx.execute(sql`
|
|
21500
|
+
SELECT set_config('app.uid', '', true),
|
|
21501
|
+
set_config('app.user_id', '', true),
|
|
21502
|
+
set_config('app.user_roles', '', true),
|
|
21503
|
+
set_config('app.jwt', '', true)
|
|
21504
|
+
`);
|
|
21505
|
+
return await fn(tx);
|
|
21506
|
+
});
|
|
21507
|
+
}
|
|
20233
21508
|
mapRowToUser(row) {
|
|
20234
21509
|
if (!row) return row;
|
|
20235
21510
|
const id = row.id ?? row.uid;
|
|
@@ -20327,7 +21602,7 @@ var UserService = class {
|
|
|
20327
21602
|
}
|
|
20328
21603
|
async createUser(data) {
|
|
20329
21604
|
const payload = this.mapPayload(data);
|
|
20330
|
-
const [row] = await this.db.insert(this.usersTable).values(payload).returning();
|
|
21605
|
+
const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
|
|
20331
21606
|
return this.mapRowToUser(row);
|
|
20332
21607
|
}
|
|
20333
21608
|
async getUserById(id) {
|
|
@@ -20345,19 +21620,19 @@ var UserService = class {
|
|
|
20345
21620
|
async getUserByIdentity(provider, providerId) {
|
|
20346
21621
|
const userIdCol = getColumn(this.usersTable, "id");
|
|
20347
21622
|
if (!userIdCol) return null;
|
|
20348
|
-
const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.
|
|
21623
|
+
const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.uid)).where(sql`${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`).limit(1);
|
|
20349
21624
|
if (result.length === 0) return null;
|
|
20350
21625
|
return this.mapRowToUser(result[0].user);
|
|
20351
21626
|
}
|
|
20352
|
-
async getUserIdentities(
|
|
21627
|
+
async getUserIdentities(uid) {
|
|
20353
21628
|
const schema = getTableConfig(this.userIdentitiesTable).schema || "public";
|
|
20354
21629
|
return (await this.db.execute(sql`
|
|
20355
|
-
SELECT id,
|
|
21630
|
+
SELECT id, uid, provider, provider_id, profile_data, created_at, updated_at
|
|
20356
21631
|
FROM ${sql.raw(`"${schema}"."user_identities"`)}
|
|
20357
|
-
WHERE
|
|
21632
|
+
WHERE uid = ${uid}
|
|
20358
21633
|
`)).rows.map((row) => ({
|
|
20359
21634
|
id: row.id,
|
|
20360
|
-
|
|
21635
|
+
uid: row.uid,
|
|
20361
21636
|
provider: row.provider,
|
|
20362
21637
|
providerId: row.provider_id,
|
|
20363
21638
|
profileData: row.profile_data ?? null,
|
|
@@ -20365,13 +21640,13 @@ var UserService = class {
|
|
|
20365
21640
|
updatedAt: row.updated_at
|
|
20366
21641
|
}));
|
|
20367
21642
|
}
|
|
20368
|
-
async linkUserIdentity(
|
|
20369
|
-
await this.db.insert(this.userIdentitiesTable).values({
|
|
20370
|
-
|
|
21643
|
+
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
21644
|
+
await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
|
|
21645
|
+
uid,
|
|
20371
21646
|
provider,
|
|
20372
21647
|
providerId,
|
|
20373
21648
|
profileData: profileData || null
|
|
20374
|
-
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
|
|
21649
|
+
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
|
|
20375
21650
|
}
|
|
20376
21651
|
async updateUser(id, data) {
|
|
20377
21652
|
const idCol = getColumn(this.usersTable, "id");
|
|
@@ -20379,13 +21654,13 @@ var UserService = class {
|
|
|
20379
21654
|
const payload = this.mapPayload(data);
|
|
20380
21655
|
const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20381
21656
|
payload[updatedAtKey] = /* @__PURE__ */ new Date();
|
|
20382
|
-
const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
|
|
21657
|
+
const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
|
|
20383
21658
|
return row ? this.mapRowToUser(row) : null;
|
|
20384
21659
|
}
|
|
20385
21660
|
async deleteUser(id) {
|
|
20386
21661
|
const idCol = getColumn(this.usersTable, "id");
|
|
20387
21662
|
if (!idCol) return;
|
|
20388
|
-
await this.db.delete(this.usersTable).where(eq(idCol, id));
|
|
21663
|
+
await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
|
|
20389
21664
|
}
|
|
20390
21665
|
async listUsers() {
|
|
20391
21666
|
return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
|
|
@@ -20439,10 +21714,10 @@ var UserService = class {
|
|
|
20439
21714
|
if (!idCol) return;
|
|
20440
21715
|
const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
|
|
20441
21716
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20442
|
-
await this.db.update(this.usersTable).set({
|
|
21717
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20443
21718
|
[passwordHashColKey]: passwordHash,
|
|
20444
21719
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20445
|
-
}).where(eq(idCol, id));
|
|
21720
|
+
}).where(eq(idCol, id)));
|
|
20446
21721
|
}
|
|
20447
21722
|
/**
|
|
20448
21723
|
* Set email verification status
|
|
@@ -20453,11 +21728,11 @@ var UserService = class {
|
|
|
20453
21728
|
const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
|
|
20454
21729
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
20455
21730
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20456
|
-
await this.db.update(this.usersTable).set({
|
|
21731
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20457
21732
|
[emailVerifiedColKey]: verified,
|
|
20458
21733
|
[emailVerificationTokenColKey]: null,
|
|
20459
21734
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20460
|
-
}).where(eq(idCol, id));
|
|
21735
|
+
}).where(eq(idCol, id)));
|
|
20461
21736
|
}
|
|
20462
21737
|
/**
|
|
20463
21738
|
* Set email verification token
|
|
@@ -20468,11 +21743,11 @@ var UserService = class {
|
|
|
20468
21743
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
20469
21744
|
const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
|
|
20470
21745
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20471
|
-
await this.db.update(this.usersTable).set({
|
|
21746
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20472
21747
|
[emailVerificationTokenColKey]: token,
|
|
20473
21748
|
[emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
|
|
20474
21749
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20475
|
-
}).where(eq(idCol, id));
|
|
21750
|
+
}).where(eq(idCol, id)));
|
|
20476
21751
|
}
|
|
20477
21752
|
/**
|
|
20478
21753
|
* Find user by email verification token
|
|
@@ -20486,10 +21761,10 @@ var UserService = class {
|
|
|
20486
21761
|
/**
|
|
20487
21762
|
* Get roles for a user from database (inline TEXT[] column)
|
|
20488
21763
|
*/
|
|
20489
|
-
async getUserRoles(
|
|
21764
|
+
async getUserRoles(uid) {
|
|
20490
21765
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
20491
21766
|
const result = await this.db.execute(sql`
|
|
20492
|
-
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${
|
|
21767
|
+
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
|
|
20493
21768
|
`);
|
|
20494
21769
|
if (result.rows.length === 0) return [];
|
|
20495
21770
|
return (result.rows[0].roles ?? []).map((id) => ({
|
|
@@ -20503,10 +21778,10 @@ var UserService = class {
|
|
|
20503
21778
|
/**
|
|
20504
21779
|
* Get role IDs for a user
|
|
20505
21780
|
*/
|
|
20506
|
-
async getUserRoleIds(
|
|
21781
|
+
async getUserRoleIds(uid) {
|
|
20507
21782
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
20508
21783
|
const result = await this.db.execute(sql`
|
|
20509
|
-
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${
|
|
21784
|
+
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
|
|
20510
21785
|
`);
|
|
20511
21786
|
if (result.rows.length === 0) return [];
|
|
20512
21787
|
return result.rows[0].roles ?? [];
|
|
@@ -20514,35 +21789,35 @@ var UserService = class {
|
|
|
20514
21789
|
/**
|
|
20515
21790
|
* Set roles for a user (replaces existing roles)
|
|
20516
21791
|
*/
|
|
20517
|
-
async setUserRoles(
|
|
21792
|
+
async setUserRoles(uid, roleIds) {
|
|
20518
21793
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
20519
21794
|
const rolesArray = `{${roleIds.join(",")}}`;
|
|
20520
|
-
await this.db.execute(sql`
|
|
21795
|
+
await this.withServerContext(async (db) => db.execute(sql`
|
|
20521
21796
|
UPDATE ${sql.raw(usersTableName)}
|
|
20522
21797
|
SET roles = ${rolesArray}::text[], updated_at = NOW()
|
|
20523
|
-
WHERE id = ${
|
|
20524
|
-
`);
|
|
21798
|
+
WHERE id = ${uid}
|
|
21799
|
+
`));
|
|
20525
21800
|
}
|
|
20526
21801
|
/**
|
|
20527
21802
|
* Assign a specific role to new user (appends if not present)
|
|
20528
21803
|
*/
|
|
20529
|
-
async assignDefaultRole(
|
|
21804
|
+
async assignDefaultRole(uid, roleId) {
|
|
20530
21805
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
20531
|
-
await this.db.execute(sql`
|
|
21806
|
+
await this.withServerContext(async (db) => db.execute(sql`
|
|
20532
21807
|
UPDATE ${sql.raw(usersTableName)}
|
|
20533
21808
|
SET roles = array_append(roles, ${roleId}), updated_at = NOW()
|
|
20534
|
-
WHERE id = ${
|
|
20535
|
-
`);
|
|
21809
|
+
WHERE id = ${uid} AND NOT (${roleId} = ANY(roles))
|
|
21810
|
+
`));
|
|
20536
21811
|
}
|
|
20537
21812
|
/**
|
|
20538
21813
|
* Get user with their roles
|
|
20539
21814
|
*/
|
|
20540
|
-
async getUserWithRoles(
|
|
20541
|
-
const user = await this.getUserById(
|
|
21815
|
+
async getUserWithRoles(uid) {
|
|
21816
|
+
const user = await this.getUserById(uid);
|
|
20542
21817
|
if (!user) return null;
|
|
20543
21818
|
return {
|
|
20544
21819
|
user,
|
|
20545
|
-
roles: await this.getUserRoles(
|
|
21820
|
+
roles: await this.getUserRoles(uid)
|
|
20546
21821
|
};
|
|
20547
21822
|
}
|
|
20548
21823
|
};
|
|
@@ -20554,18 +21829,18 @@ var RefreshTokenService = class {
|
|
|
20554
21829
|
if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users)) this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
|
|
20555
21830
|
else this.refreshTokensTable = tableOrTables || refreshTokens;
|
|
20556
21831
|
}
|
|
20557
|
-
async createToken(
|
|
21832
|
+
async createToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
|
|
20558
21833
|
const safeUserAgent = userAgent || "";
|
|
20559
21834
|
const safeIpAddress = ipAddress || "";
|
|
20560
21835
|
await this.db.insert(this.refreshTokensTable).values({
|
|
20561
|
-
|
|
21836
|
+
uid,
|
|
20562
21837
|
tokenHash,
|
|
20563
21838
|
expiresAt,
|
|
20564
21839
|
userAgent: safeUserAgent,
|
|
20565
21840
|
ipAddress: safeIpAddress
|
|
20566
21841
|
}).onConflictDoUpdate({
|
|
20567
21842
|
target: [
|
|
20568
|
-
this.refreshTokensTable.
|
|
21843
|
+
this.refreshTokensTable.uid,
|
|
20569
21844
|
this.refreshTokensTable.userAgent,
|
|
20570
21845
|
this.refreshTokensTable.ipAddress
|
|
20571
21846
|
],
|
|
@@ -20578,7 +21853,7 @@ var RefreshTokenService = class {
|
|
|
20578
21853
|
async findByHash(tokenHash) {
|
|
20579
21854
|
const [token] = await this.db.select({
|
|
20580
21855
|
id: this.refreshTokensTable.id,
|
|
20581
|
-
|
|
21856
|
+
uid: this.refreshTokensTable.uid,
|
|
20582
21857
|
tokenHash: this.refreshTokensTable.tokenHash,
|
|
20583
21858
|
expiresAt: this.refreshTokensTable.expiresAt,
|
|
20584
21859
|
createdAt: this.refreshTokensTable.createdAt,
|
|
@@ -20590,22 +21865,22 @@ var RefreshTokenService = class {
|
|
|
20590
21865
|
async deleteByHash(tokenHash) {
|
|
20591
21866
|
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
20592
21867
|
}
|
|
20593
|
-
async deleteAllForUser(
|
|
20594
|
-
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.
|
|
21868
|
+
async deleteAllForUser(uid) {
|
|
21869
|
+
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
|
|
20595
21870
|
}
|
|
20596
|
-
async listForUser(
|
|
21871
|
+
async listForUser(uid) {
|
|
20597
21872
|
return await this.db.select({
|
|
20598
21873
|
id: this.refreshTokensTable.id,
|
|
20599
|
-
|
|
21874
|
+
uid: this.refreshTokensTable.uid,
|
|
20600
21875
|
tokenHash: this.refreshTokensTable.tokenHash,
|
|
20601
21876
|
expiresAt: this.refreshTokensTable.expiresAt,
|
|
20602
21877
|
createdAt: this.refreshTokensTable.createdAt,
|
|
20603
21878
|
userAgent: this.refreshTokensTable.userAgent,
|
|
20604
21879
|
ipAddress: this.refreshTokensTable.ipAddress
|
|
20605
|
-
}).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.
|
|
21880
|
+
}).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
|
|
20606
21881
|
}
|
|
20607
|
-
async deleteById(id,
|
|
20608
|
-
await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.
|
|
21882
|
+
async deleteById(id, uid) {
|
|
21883
|
+
await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
|
|
20609
21884
|
}
|
|
20610
21885
|
};
|
|
20611
21886
|
/**
|
|
@@ -20626,14 +21901,14 @@ var PasswordResetTokenService = class {
|
|
|
20626
21901
|
/**
|
|
20627
21902
|
* Create a password reset token
|
|
20628
21903
|
*/
|
|
20629
|
-
async createToken(
|
|
21904
|
+
async createToken(uid, tokenHash, expiresAt) {
|
|
20630
21905
|
const tableName = this.getQualifiedPasswordResetTokensTableName();
|
|
20631
21906
|
await this.db.execute(sql`
|
|
20632
21907
|
DELETE FROM ${sql.raw(tableName)}
|
|
20633
|
-
WHERE
|
|
21908
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
20634
21909
|
`);
|
|
20635
21910
|
await this.db.insert(this.passwordResetTokensTable).values({
|
|
20636
|
-
|
|
21911
|
+
uid,
|
|
20637
21912
|
tokenHash,
|
|
20638
21913
|
expiresAt
|
|
20639
21914
|
});
|
|
@@ -20643,13 +21918,13 @@ var PasswordResetTokenService = class {
|
|
|
20643
21918
|
*/
|
|
20644
21919
|
async findValidByHash(tokenHash) {
|
|
20645
21920
|
const [token] = await this.db.select({
|
|
20646
|
-
|
|
21921
|
+
uid: this.passwordResetTokensTable.uid,
|
|
20647
21922
|
expiresAt: this.passwordResetTokensTable.expiresAt
|
|
20648
21923
|
}).from(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.tokenHash, tokenHash));
|
|
20649
21924
|
if (!token) return null;
|
|
20650
21925
|
const tableName = this.getQualifiedPasswordResetTokensTableName();
|
|
20651
21926
|
const result = await this.db.execute(sql`
|
|
20652
|
-
SELECT
|
|
21927
|
+
SELECT uid, expires_at
|
|
20653
21928
|
FROM ${sql.raw(tableName)}
|
|
20654
21929
|
WHERE token_hash = ${tokenHash}
|
|
20655
21930
|
AND used_at IS NULL
|
|
@@ -20658,7 +21933,7 @@ var PasswordResetTokenService = class {
|
|
|
20658
21933
|
if (result.rows.length === 0) return null;
|
|
20659
21934
|
const row = result.rows[0];
|
|
20660
21935
|
return {
|
|
20661
|
-
|
|
21936
|
+
uid: row.uid,
|
|
20662
21937
|
expiresAt: new Date(row.expires_at)
|
|
20663
21938
|
};
|
|
20664
21939
|
}
|
|
@@ -20671,8 +21946,8 @@ var PasswordResetTokenService = class {
|
|
|
20671
21946
|
/**
|
|
20672
21947
|
* Delete all tokens for a user
|
|
20673
21948
|
*/
|
|
20674
|
-
async deleteAllForUser(
|
|
20675
|
-
await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.
|
|
21949
|
+
async deleteAllForUser(uid) {
|
|
21950
|
+
await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.uid, uid));
|
|
20676
21951
|
}
|
|
20677
21952
|
/**
|
|
20678
21953
|
* Clean up expired tokens
|
|
@@ -20700,14 +21975,14 @@ var MagicLinkTokenService = class {
|
|
|
20700
21975
|
const name = getTableName(this.magicLinkTokensTable);
|
|
20701
21976
|
return `"${getTableConfig(this.magicLinkTokensTable).schema || "public"}"."${name}"`;
|
|
20702
21977
|
}
|
|
20703
|
-
async createToken(
|
|
21978
|
+
async createToken(uid, tokenHash, expiresAt) {
|
|
20704
21979
|
const tableName = this.getQualifiedTableName();
|
|
20705
21980
|
await this.db.execute(sql`
|
|
20706
21981
|
DELETE FROM ${sql.raw(tableName)}
|
|
20707
|
-
WHERE
|
|
21982
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
20708
21983
|
`);
|
|
20709
21984
|
await this.db.insert(this.magicLinkTokensTable).values({
|
|
20710
|
-
|
|
21985
|
+
uid,
|
|
20711
21986
|
tokenHash,
|
|
20712
21987
|
expiresAt
|
|
20713
21988
|
});
|
|
@@ -20715,7 +21990,7 @@ var MagicLinkTokenService = class {
|
|
|
20715
21990
|
async findValidByHash(tokenHash) {
|
|
20716
21991
|
const tableName = this.getQualifiedTableName();
|
|
20717
21992
|
const result = await this.db.execute(sql`
|
|
20718
|
-
SELECT
|
|
21993
|
+
SELECT uid, expires_at
|
|
20719
21994
|
FROM ${sql.raw(tableName)}
|
|
20720
21995
|
WHERE token_hash = ${tokenHash}
|
|
20721
21996
|
AND used_at IS NULL
|
|
@@ -20724,7 +21999,7 @@ var MagicLinkTokenService = class {
|
|
|
20724
21999
|
if (result.rows.length === 0) return null;
|
|
20725
22000
|
const row = result.rows[0];
|
|
20726
22001
|
return {
|
|
20727
|
-
|
|
22002
|
+
uid: row.uid,
|
|
20728
22003
|
expiresAt: new Date(row.expires_at)
|
|
20729
22004
|
};
|
|
20730
22005
|
}
|
|
@@ -20747,8 +22022,8 @@ var PostgresTokenRepository = class {
|
|
|
20747
22022
|
this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
|
|
20748
22023
|
this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
|
|
20749
22024
|
}
|
|
20750
|
-
async createRefreshToken(
|
|
20751
|
-
await this.refreshTokenService.createToken(
|
|
22025
|
+
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
|
|
22026
|
+
await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
|
|
20752
22027
|
}
|
|
20753
22028
|
async findRefreshTokenByHash(tokenHash) {
|
|
20754
22029
|
return this.refreshTokenService.findByHash(tokenHash);
|
|
@@ -20756,17 +22031,17 @@ var PostgresTokenRepository = class {
|
|
|
20756
22031
|
async deleteRefreshToken(tokenHash) {
|
|
20757
22032
|
await this.refreshTokenService.deleteByHash(tokenHash);
|
|
20758
22033
|
}
|
|
20759
|
-
async deleteAllRefreshTokensForUser(
|
|
20760
|
-
await this.refreshTokenService.deleteAllForUser(
|
|
22034
|
+
async deleteAllRefreshTokensForUser(uid) {
|
|
22035
|
+
await this.refreshTokenService.deleteAllForUser(uid);
|
|
20761
22036
|
}
|
|
20762
|
-
async listRefreshTokensForUser(
|
|
20763
|
-
return this.refreshTokenService.listForUser(
|
|
22037
|
+
async listRefreshTokensForUser(uid) {
|
|
22038
|
+
return this.refreshTokenService.listForUser(uid);
|
|
20764
22039
|
}
|
|
20765
|
-
async deleteRefreshTokenById(id,
|
|
20766
|
-
await this.refreshTokenService.deleteById(id,
|
|
22040
|
+
async deleteRefreshTokenById(id, uid) {
|
|
22041
|
+
await this.refreshTokenService.deleteById(id, uid);
|
|
20767
22042
|
}
|
|
20768
|
-
async createPasswordResetToken(
|
|
20769
|
-
await this.passwordResetTokenService.createToken(
|
|
22043
|
+
async createPasswordResetToken(uid, tokenHash, expiresAt) {
|
|
22044
|
+
await this.passwordResetTokenService.createToken(uid, tokenHash, expiresAt);
|
|
20770
22045
|
}
|
|
20771
22046
|
async findValidPasswordResetToken(tokenHash) {
|
|
20772
22047
|
return this.passwordResetTokenService.findValidByHash(tokenHash);
|
|
@@ -20774,14 +22049,14 @@ var PostgresTokenRepository = class {
|
|
|
20774
22049
|
async markPasswordResetTokenUsed(tokenHash) {
|
|
20775
22050
|
await this.passwordResetTokenService.markAsUsed(tokenHash);
|
|
20776
22051
|
}
|
|
20777
|
-
async deleteAllPasswordResetTokensForUser(
|
|
20778
|
-
await this.passwordResetTokenService.deleteAllForUser(
|
|
22052
|
+
async deleteAllPasswordResetTokensForUser(uid) {
|
|
22053
|
+
await this.passwordResetTokenService.deleteAllForUser(uid);
|
|
20779
22054
|
}
|
|
20780
22055
|
async deleteExpiredTokens() {
|
|
20781
22056
|
await this.passwordResetTokenService.deleteExpired();
|
|
20782
22057
|
}
|
|
20783
|
-
async createMagicLinkToken(
|
|
20784
|
-
await this.magicLinkTokenService.createToken(
|
|
22058
|
+
async createMagicLinkToken(uid, tokenHash, expiresAt) {
|
|
22059
|
+
await this.magicLinkTokenService.createToken(uid, tokenHash, expiresAt);
|
|
20785
22060
|
}
|
|
20786
22061
|
async findValidMagicLinkToken(tokenHash) {
|
|
20787
22062
|
return this.magicLinkTokenService.findValidByHash(tokenHash);
|
|
@@ -20816,11 +22091,11 @@ var PostgresAuthRepository = class {
|
|
|
20816
22091
|
async getUserByIdentity(provider, providerId) {
|
|
20817
22092
|
return this.userService.getUserByIdentity(provider, providerId);
|
|
20818
22093
|
}
|
|
20819
|
-
async getUserIdentities(
|
|
20820
|
-
return this.userService.getUserIdentities(
|
|
22094
|
+
async getUserIdentities(uid) {
|
|
22095
|
+
return this.userService.getUserIdentities(uid);
|
|
20821
22096
|
}
|
|
20822
|
-
async linkUserIdentity(
|
|
20823
|
-
return this.userService.linkUserIdentity(
|
|
22097
|
+
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
22098
|
+
return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
|
|
20824
22099
|
}
|
|
20825
22100
|
async updateUser(id, data) {
|
|
20826
22101
|
return this.userService.updateUser(id, data);
|
|
@@ -20846,20 +22121,20 @@ var PostgresAuthRepository = class {
|
|
|
20846
22121
|
async getUserByVerificationToken(token) {
|
|
20847
22122
|
return this.userService.getUserByVerificationToken(token);
|
|
20848
22123
|
}
|
|
20849
|
-
async getUserRoles(
|
|
20850
|
-
return this.userService.getUserRoles(
|
|
22124
|
+
async getUserRoles(uid) {
|
|
22125
|
+
return this.userService.getUserRoles(uid);
|
|
20851
22126
|
}
|
|
20852
|
-
async getUserRoleIds(
|
|
20853
|
-
return this.userService.getUserRoleIds(
|
|
22127
|
+
async getUserRoleIds(uid) {
|
|
22128
|
+
return this.userService.getUserRoleIds(uid);
|
|
20854
22129
|
}
|
|
20855
|
-
async setUserRoles(
|
|
20856
|
-
await this.userService.setUserRoles(
|
|
22130
|
+
async setUserRoles(uid, roleIds) {
|
|
22131
|
+
await this.userService.setUserRoles(uid, roleIds);
|
|
20857
22132
|
}
|
|
20858
|
-
async assignDefaultRole(
|
|
20859
|
-
await this.userService.assignDefaultRole(
|
|
22133
|
+
async assignDefaultRole(uid, roleId) {
|
|
22134
|
+
await this.userService.assignDefaultRole(uid, roleId);
|
|
20860
22135
|
}
|
|
20861
|
-
async getUserWithRoles(
|
|
20862
|
-
return this.userService.getUserWithRoles(
|
|
22136
|
+
async getUserWithRoles(uid) {
|
|
22137
|
+
return this.userService.getUserWithRoles(uid);
|
|
20863
22138
|
}
|
|
20864
22139
|
async getRoleById(id) {
|
|
20865
22140
|
return {
|
|
@@ -20914,8 +22189,8 @@ var PostgresAuthRepository = class {
|
|
|
20914
22189
|
};
|
|
20915
22190
|
}
|
|
20916
22191
|
async deleteRole(_id) {}
|
|
20917
|
-
async createRefreshToken(
|
|
20918
|
-
await this.tokenRepository.createRefreshToken(
|
|
22192
|
+
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
|
|
22193
|
+
await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
|
|
20919
22194
|
}
|
|
20920
22195
|
async findRefreshTokenByHash(tokenHash) {
|
|
20921
22196
|
return this.tokenRepository.findRefreshTokenByHash(tokenHash);
|
|
@@ -20923,17 +22198,17 @@ var PostgresAuthRepository = class {
|
|
|
20923
22198
|
async deleteRefreshToken(tokenHash) {
|
|
20924
22199
|
await this.tokenRepository.deleteRefreshToken(tokenHash);
|
|
20925
22200
|
}
|
|
20926
|
-
async deleteAllRefreshTokensForUser(
|
|
20927
|
-
await this.tokenRepository.deleteAllRefreshTokensForUser(
|
|
22201
|
+
async deleteAllRefreshTokensForUser(uid) {
|
|
22202
|
+
await this.tokenRepository.deleteAllRefreshTokensForUser(uid);
|
|
20928
22203
|
}
|
|
20929
|
-
async listRefreshTokensForUser(
|
|
20930
|
-
return this.tokenRepository.listRefreshTokensForUser(
|
|
22204
|
+
async listRefreshTokensForUser(uid) {
|
|
22205
|
+
return this.tokenRepository.listRefreshTokensForUser(uid);
|
|
20931
22206
|
}
|
|
20932
|
-
async deleteRefreshTokenById(id,
|
|
20933
|
-
await this.tokenRepository.deleteRefreshTokenById(id,
|
|
22207
|
+
async deleteRefreshTokenById(id, uid) {
|
|
22208
|
+
await this.tokenRepository.deleteRefreshTokenById(id, uid);
|
|
20934
22209
|
}
|
|
20935
|
-
async createPasswordResetToken(
|
|
20936
|
-
await this.tokenRepository.createPasswordResetToken(
|
|
22210
|
+
async createPasswordResetToken(uid, tokenHash, expiresAt) {
|
|
22211
|
+
await this.tokenRepository.createPasswordResetToken(uid, tokenHash, expiresAt);
|
|
20937
22212
|
}
|
|
20938
22213
|
async findValidPasswordResetToken(tokenHash) {
|
|
20939
22214
|
return this.tokenRepository.findValidPasswordResetToken(tokenHash);
|
|
@@ -20941,14 +22216,14 @@ var PostgresAuthRepository = class {
|
|
|
20941
22216
|
async markPasswordResetTokenUsed(tokenHash) {
|
|
20942
22217
|
await this.tokenRepository.markPasswordResetTokenUsed(tokenHash);
|
|
20943
22218
|
}
|
|
20944
|
-
async deleteAllPasswordResetTokensForUser(
|
|
20945
|
-
await this.tokenRepository.deleteAllPasswordResetTokensForUser(
|
|
22219
|
+
async deleteAllPasswordResetTokensForUser(uid) {
|
|
22220
|
+
await this.tokenRepository.deleteAllPasswordResetTokensForUser(uid);
|
|
20946
22221
|
}
|
|
20947
22222
|
async deleteExpiredTokens() {
|
|
20948
22223
|
await this.tokenRepository.deleteExpiredTokens();
|
|
20949
22224
|
}
|
|
20950
|
-
async createMagicLinkToken(
|
|
20951
|
-
await this.tokenRepository.createMagicLinkToken(
|
|
22225
|
+
async createMagicLinkToken(uid, tokenHash, expiresAt) {
|
|
22226
|
+
await this.tokenRepository.createMagicLinkToken(uid, tokenHash, expiresAt);
|
|
20952
22227
|
}
|
|
20953
22228
|
async findValidMagicLinkToken(tokenHash) {
|
|
20954
22229
|
return this.tokenRepository.findValidMagicLinkToken(tokenHash);
|
|
@@ -20961,11 +22236,11 @@ var PostgresAuthRepository = class {
|
|
|
20961
22236
|
if (!this._mfaService) this._mfaService = new MfaService(this.db);
|
|
20962
22237
|
return this._mfaService;
|
|
20963
22238
|
}
|
|
20964
|
-
async createMfaFactor(
|
|
20965
|
-
return this.getMfaService().createMfaFactor(
|
|
22239
|
+
async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
|
|
22240
|
+
return this.getMfaService().createMfaFactor(uid, factorType, secretEncrypted, friendlyName);
|
|
20966
22241
|
}
|
|
20967
|
-
async getMfaFactors(
|
|
20968
|
-
return this.getMfaService().getMfaFactors(
|
|
22242
|
+
async getMfaFactors(uid) {
|
|
22243
|
+
return this.getMfaService().getMfaFactors(uid);
|
|
20969
22244
|
}
|
|
20970
22245
|
async getMfaFactorById(factorId) {
|
|
20971
22246
|
return this.getMfaService().getMfaFactorById(factorId);
|
|
@@ -20973,8 +22248,8 @@ var PostgresAuthRepository = class {
|
|
|
20973
22248
|
async verifyMfaFactor(factorId) {
|
|
20974
22249
|
return this.getMfaService().verifyMfaFactor(factorId);
|
|
20975
22250
|
}
|
|
20976
|
-
async deleteMfaFactor(factorId,
|
|
20977
|
-
return this.getMfaService().deleteMfaFactor(factorId,
|
|
22251
|
+
async deleteMfaFactor(factorId, uid) {
|
|
22252
|
+
return this.getMfaService().deleteMfaFactor(factorId, uid);
|
|
20978
22253
|
}
|
|
20979
22254
|
async createMfaChallenge(factorId, ipAddress) {
|
|
20980
22255
|
return this.getMfaService().createMfaChallenge(factorId, ipAddress);
|
|
@@ -20985,20 +22260,20 @@ var PostgresAuthRepository = class {
|
|
|
20985
22260
|
async verifyMfaChallenge(challengeId) {
|
|
20986
22261
|
return this.getMfaService().verifyMfaChallenge(challengeId);
|
|
20987
22262
|
}
|
|
20988
|
-
async createRecoveryCodes(
|
|
20989
|
-
return this.getMfaService().createRecoveryCodes(
|
|
22263
|
+
async createRecoveryCodes(uid, codeHashes) {
|
|
22264
|
+
return this.getMfaService().createRecoveryCodes(uid, codeHashes);
|
|
20990
22265
|
}
|
|
20991
|
-
async useRecoveryCode(
|
|
20992
|
-
return this.getMfaService().useRecoveryCode(
|
|
22266
|
+
async useRecoveryCode(uid, codeHash) {
|
|
22267
|
+
return this.getMfaService().useRecoveryCode(uid, codeHash);
|
|
20993
22268
|
}
|
|
20994
|
-
async getUnusedRecoveryCodeCount(
|
|
20995
|
-
return this.getMfaService().getUnusedRecoveryCodeCount(
|
|
22269
|
+
async getUnusedRecoveryCodeCount(uid) {
|
|
22270
|
+
return this.getMfaService().getUnusedRecoveryCodeCount(uid);
|
|
20996
22271
|
}
|
|
20997
|
-
async deleteAllRecoveryCodes(
|
|
20998
|
-
return this.getMfaService().deleteAllRecoveryCodes(
|
|
22272
|
+
async deleteAllRecoveryCodes(uid) {
|
|
22273
|
+
return this.getMfaService().deleteAllRecoveryCodes(uid);
|
|
20999
22274
|
}
|
|
21000
|
-
async hasVerifiedMfaFactors(
|
|
21001
|
-
return this.getMfaService().hasVerifiedMfaFactors(
|
|
22275
|
+
async hasVerifiedMfaFactors(uid) {
|
|
22276
|
+
return this.getMfaService().hasVerifiedMfaFactors(uid);
|
|
21002
22277
|
}
|
|
21003
22278
|
};
|
|
21004
22279
|
/**
|
|
@@ -21015,16 +22290,16 @@ var MfaService = class {
|
|
|
21015
22290
|
qualify(tableName) {
|
|
21016
22291
|
return `"${this.schemaName}"."${tableName}"`;
|
|
21017
22292
|
}
|
|
21018
|
-
async createMfaFactor(
|
|
22293
|
+
async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
|
|
21019
22294
|
const tableName = this.qualify("mfa_factors");
|
|
21020
22295
|
const row = (await this.db.execute(sql`
|
|
21021
|
-
INSERT INTO ${sql.raw(tableName)} (
|
|
21022
|
-
VALUES (${
|
|
21023
|
-
RETURNING id,
|
|
22296
|
+
INSERT INTO ${sql.raw(tableName)} (uid, factor_type, secret_encrypted, friendly_name)
|
|
22297
|
+
VALUES (${uid}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
|
|
22298
|
+
RETURNING id, uid, factor_type, friendly_name, verified, created_at, updated_at
|
|
21024
22299
|
`)).rows[0];
|
|
21025
22300
|
return {
|
|
21026
22301
|
id: row.id,
|
|
21027
|
-
|
|
22302
|
+
uid: row.uid,
|
|
21028
22303
|
factorType: row.factor_type,
|
|
21029
22304
|
friendlyName: row.friendly_name ?? void 0,
|
|
21030
22305
|
verified: row.verified,
|
|
@@ -21032,16 +22307,16 @@ var MfaService = class {
|
|
|
21032
22307
|
updatedAt: new Date(row.updated_at)
|
|
21033
22308
|
};
|
|
21034
22309
|
}
|
|
21035
|
-
async getMfaFactors(
|
|
22310
|
+
async getMfaFactors(uid) {
|
|
21036
22311
|
const tableName = this.qualify("mfa_factors");
|
|
21037
22312
|
return (await this.db.execute(sql`
|
|
21038
|
-
SELECT id,
|
|
22313
|
+
SELECT id, uid, factor_type, friendly_name, verified, created_at, updated_at
|
|
21039
22314
|
FROM ${sql.raw(tableName)}
|
|
21040
|
-
WHERE
|
|
22315
|
+
WHERE uid = ${uid}
|
|
21041
22316
|
ORDER BY created_at
|
|
21042
22317
|
`)).rows.map((row) => ({
|
|
21043
22318
|
id: row.id,
|
|
21044
|
-
|
|
22319
|
+
uid: row.uid,
|
|
21045
22320
|
factorType: row.factor_type,
|
|
21046
22321
|
friendlyName: row.friendly_name ?? void 0,
|
|
21047
22322
|
verified: row.verified,
|
|
@@ -21052,7 +22327,7 @@ var MfaService = class {
|
|
|
21052
22327
|
async getMfaFactorById(factorId) {
|
|
21053
22328
|
const tableName = this.qualify("mfa_factors");
|
|
21054
22329
|
const result = await this.db.execute(sql`
|
|
21055
|
-
SELECT id,
|
|
22330
|
+
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
|
|
21056
22331
|
FROM ${sql.raw(tableName)}
|
|
21057
22332
|
WHERE id = ${factorId}
|
|
21058
22333
|
`);
|
|
@@ -21060,7 +22335,7 @@ var MfaService = class {
|
|
|
21060
22335
|
const row = result.rows[0];
|
|
21061
22336
|
return {
|
|
21062
22337
|
id: row.id,
|
|
21063
|
-
|
|
22338
|
+
uid: row.uid,
|
|
21064
22339
|
factorType: row.factor_type,
|
|
21065
22340
|
secretEncrypted: row.secret_encrypted,
|
|
21066
22341
|
friendlyName: row.friendly_name ?? void 0,
|
|
@@ -21077,11 +22352,11 @@ var MfaService = class {
|
|
|
21077
22352
|
WHERE id = ${factorId}
|
|
21078
22353
|
`);
|
|
21079
22354
|
}
|
|
21080
|
-
async deleteMfaFactor(factorId,
|
|
22355
|
+
async deleteMfaFactor(factorId, uid) {
|
|
21081
22356
|
const tableName = this.qualify("mfa_factors");
|
|
21082
22357
|
await this.db.execute(sql`
|
|
21083
22358
|
DELETE FROM ${sql.raw(tableName)}
|
|
21084
|
-
WHERE id = ${factorId} AND
|
|
22359
|
+
WHERE id = ${factorId} AND uid = ${uid}
|
|
21085
22360
|
`);
|
|
21086
22361
|
}
|
|
21087
22362
|
async createMfaChallenge(factorId, ipAddress) {
|
|
@@ -21125,43 +22400,43 @@ var MfaService = class {
|
|
|
21125
22400
|
WHERE id = ${challengeId}
|
|
21126
22401
|
`);
|
|
21127
22402
|
}
|
|
21128
|
-
async createRecoveryCodes(
|
|
22403
|
+
async createRecoveryCodes(uid, codeHashes) {
|
|
21129
22404
|
const tableName = this.qualify("recovery_codes");
|
|
21130
22405
|
await this.db.execute(sql`
|
|
21131
|
-
DELETE FROM ${sql.raw(tableName)} WHERE
|
|
22406
|
+
DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
|
|
21132
22407
|
`);
|
|
21133
22408
|
for (const hash of codeHashes) await this.db.execute(sql`
|
|
21134
|
-
INSERT INTO ${sql.raw(tableName)} (
|
|
21135
|
-
VALUES (${
|
|
22409
|
+
INSERT INTO ${sql.raw(tableName)} (uid, code_hash)
|
|
22410
|
+
VALUES (${uid}, ${hash})
|
|
21136
22411
|
`);
|
|
21137
22412
|
}
|
|
21138
|
-
async useRecoveryCode(
|
|
22413
|
+
async useRecoveryCode(uid, codeHash) {
|
|
21139
22414
|
const tableName = this.qualify("recovery_codes");
|
|
21140
22415
|
return (await this.db.execute(sql`
|
|
21141
22416
|
UPDATE ${sql.raw(tableName)}
|
|
21142
22417
|
SET used_at = NOW()
|
|
21143
|
-
WHERE
|
|
22418
|
+
WHERE uid = ${uid} AND code_hash = ${codeHash} AND used_at IS NULL
|
|
21144
22419
|
RETURNING id
|
|
21145
22420
|
`)).rows.length > 0;
|
|
21146
22421
|
}
|
|
21147
|
-
async getUnusedRecoveryCodeCount(
|
|
22422
|
+
async getUnusedRecoveryCodeCount(uid) {
|
|
21148
22423
|
const tableName = this.qualify("recovery_codes");
|
|
21149
22424
|
return (await this.db.execute(sql`
|
|
21150
22425
|
SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
|
|
21151
|
-
WHERE
|
|
22426
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
21152
22427
|
`)).rows[0].count;
|
|
21153
22428
|
}
|
|
21154
|
-
async deleteAllRecoveryCodes(
|
|
22429
|
+
async deleteAllRecoveryCodes(uid) {
|
|
21155
22430
|
const tableName = this.qualify("recovery_codes");
|
|
21156
22431
|
await this.db.execute(sql`
|
|
21157
|
-
DELETE FROM ${sql.raw(tableName)} WHERE
|
|
22432
|
+
DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
|
|
21158
22433
|
`);
|
|
21159
22434
|
}
|
|
21160
|
-
async hasVerifiedMfaFactors(
|
|
22435
|
+
async hasVerifiedMfaFactors(uid) {
|
|
21161
22436
|
const tableName = this.qualify("mfa_factors");
|
|
21162
22437
|
return (await this.db.execute(sql`
|
|
21163
22438
|
SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
|
|
21164
|
-
WHERE
|
|
22439
|
+
WHERE uid = ${uid} AND verified = TRUE
|
|
21165
22440
|
`)).rows[0].count > 0;
|
|
21166
22441
|
}
|
|
21167
22442
|
};
|
|
@@ -21401,6 +22676,20 @@ function patchPgArrayNullSafety(tables) {
|
|
|
21401
22676
|
if (patchedCount > 0) logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
|
|
21402
22677
|
}
|
|
21403
22678
|
//#endregion
|
|
22679
|
+
//#region src/schema/introspect-db-naming.ts
|
|
22680
|
+
/**
|
|
22681
|
+
* Naming helpers shared by the introspection modules. These live apart from
|
|
22682
|
+
* `introspect-db-logic.ts` because the inference pass needs them too, and
|
|
22683
|
+
* importing them from there would close a cycle back through this module.
|
|
22684
|
+
*/
|
|
22685
|
+
/**
|
|
22686
|
+
* Convert a snake_case name to a human-readable Title Case label.
|
|
22687
|
+
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
22688
|
+
*/
|
|
22689
|
+
function humanize(snakeName) {
|
|
22690
|
+
return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
22691
|
+
}
|
|
22692
|
+
//#endregion
|
|
21404
22693
|
//#region src/schema/introspect-db-logic.ts
|
|
21405
22694
|
var IRREGULAR_SINGULARS = {
|
|
21406
22695
|
people: "person",
|
|
@@ -21460,13 +22749,6 @@ function singularize(word) {
|
|
|
21460
22749
|
if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
|
|
21461
22750
|
return word;
|
|
21462
22751
|
}
|
|
21463
|
-
/**
|
|
21464
|
-
* Convert a snake_case name to a human-readable Title Case label.
|
|
21465
|
-
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
21466
|
-
*/
|
|
21467
|
-
function humanize(snakeName) {
|
|
21468
|
-
return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
21469
|
-
}
|
|
21470
22752
|
function getIconForTable(tableName) {
|
|
21471
22753
|
const table = tableName.toLowerCase();
|
|
21472
22754
|
if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
|
|
@@ -21916,21 +23198,14 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
21916
23198
|
logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
|
|
21917
23199
|
}
|
|
21918
23200
|
const activeCollections = introspectedCollections ?? collections;
|
|
21919
|
-
const registry = new PostgresCollectionRegistry();
|
|
21920
|
-
if (activeCollections) {
|
|
21921
|
-
registry.registerMultiple(activeCollections);
|
|
21922
|
-
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
21923
|
-
}
|
|
21924
23201
|
const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
|
|
21925
|
-
if (schemaTables) Object.values(schemaTables).forEach((table) => {
|
|
21926
|
-
if (isTable(table)) {
|
|
21927
|
-
const tableName = getTableName(table);
|
|
21928
|
-
registry.registerTable(table, tableName);
|
|
21929
|
-
}
|
|
21930
|
-
});
|
|
21931
|
-
if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
|
|
21932
23202
|
const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
|
|
21933
|
-
|
|
23203
|
+
const registry = buildCollectionRegistry({
|
|
23204
|
+
collections: activeCollections,
|
|
23205
|
+
tables: schemaTables,
|
|
23206
|
+
enums: pgConfig.schema?.enums,
|
|
23207
|
+
relations: schemaRelations
|
|
23208
|
+
});
|
|
21934
23209
|
if (schemaTables) patchPgArrayNullSafety(schemaTables);
|
|
21935
23210
|
const mergedSchema = {
|
|
21936
23211
|
...schemaTables,
|
|
@@ -21994,6 +23269,11 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
21994
23269
|
} catch (err) {
|
|
21995
23270
|
logger.warn("⚠️ Could not initialize branch metadata table", { error: err });
|
|
21996
23271
|
}
|
|
23272
|
+
try {
|
|
23273
|
+
await realtimeService.configureChannelHistory(pgConfig.realtime?.channels);
|
|
23274
|
+
} catch (err) {
|
|
23275
|
+
logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
|
|
23276
|
+
}
|
|
21997
23277
|
const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
|
|
21998
23278
|
const validModes = new Set([
|
|
21999
23279
|
"auto",
|
|
@@ -22009,6 +23289,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22009
23289
|
const wantsCdc = cdcMode !== "off";
|
|
22010
23290
|
const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
|
|
22011
23291
|
let cdcEnabled = false;
|
|
23292
|
+
let provisionCdcForTables;
|
|
22012
23293
|
if (wantsCdc && !directUrl) {
|
|
22013
23294
|
const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
|
|
22014
23295
|
if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
|
|
@@ -22025,6 +23306,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22025
23306
|
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
|
|
22026
23307
|
await realtimeService.enableCdc(directUrl);
|
|
22027
23308
|
cdcEnabled = true;
|
|
23309
|
+
provisionCdcForTables = async (tables) => {
|
|
23310
|
+
await provisionTriggerCdc(cdcRunSql, tables);
|
|
23311
|
+
};
|
|
22028
23312
|
logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
|
|
22029
23313
|
} catch (err) {
|
|
22030
23314
|
if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
|
|
@@ -22049,13 +23333,14 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22049
23333
|
const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
|
|
22050
23334
|
const missing = [];
|
|
22051
23335
|
for (const col of registeredCollections) {
|
|
23336
|
+
if (col.auth?.enabled) continue;
|
|
22052
23337
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
22053
23338
|
const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
|
|
22054
23339
|
const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
|
|
22055
23340
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
22056
23341
|
if (!dbTables.has(fullCheckName)) missing.push({
|
|
22057
23342
|
slug: col.slug,
|
|
22058
|
-
table:
|
|
23343
|
+
table: fullCheckName
|
|
22059
23344
|
});
|
|
22060
23345
|
}
|
|
22061
23346
|
if (missing.length > 0) {
|
|
@@ -22089,7 +23374,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22089
23374
|
registry,
|
|
22090
23375
|
realtimeService,
|
|
22091
23376
|
driver,
|
|
22092
|
-
poolManager
|
|
23377
|
+
poolManager,
|
|
23378
|
+
provisionCdcForTables
|
|
22093
23379
|
}
|
|
22094
23380
|
};
|
|
22095
23381
|
},
|
|
@@ -22101,6 +23387,18 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22101
23387
|
const registry = internals.registry;
|
|
22102
23388
|
const authCollection = authConfig.collection;
|
|
22103
23389
|
await ensureAuthTablesExist(db, authCollection);
|
|
23390
|
+
if (authCollection && internals.provisionCdcForTables) {
|
|
23391
|
+
const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
|
|
23392
|
+
const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
|
|
23393
|
+
if (authTable) try {
|
|
23394
|
+
await internals.provisionCdcForTables([{
|
|
23395
|
+
schema: authSchema,
|
|
23396
|
+
table: authTable
|
|
23397
|
+
}]);
|
|
23398
|
+
} catch (err) {
|
|
23399
|
+
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) });
|
|
23400
|
+
}
|
|
23401
|
+
}
|
|
22104
23402
|
let emailService;
|
|
22105
23403
|
if (authConfig.email) emailService = createEmailService(authConfig.email);
|
|
22106
23404
|
const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
|
|
@@ -22171,6 +23469,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
22171
23469
|
};
|
|
22172
23470
|
}
|
|
22173
23471
|
//#endregion
|
|
22174
|
-
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 };
|
|
23472
|
+
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
|
|
22175
23473
|
|
|
22176
23474
|
//# sourceMappingURL=index.es.js.map
|