@rebasepro/server-postgres 0.9.1-canary.58368ce → 0.9.1-canary.73476f2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +2 -25
- package/dist/data-transformer.d.ts +2 -9
- package/dist/index.es.js +388 -1069
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/services/FetchService.d.ts +7 -21
- package/dist/services/PersistService.d.ts +1 -27
- package/dist/services/RelationService.d.ts +1 -0
- package/dist/services/collection-helpers.d.ts +14 -52
- package/dist/services/dataService.d.ts +1 -3
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +0 -7
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +8 -109
- package/src/PostgresBootstrapper.ts +23 -11
- package/src/data-transformer.ts +9 -11
- package/src/schema/auth-default-policies.ts +132 -0
- package/src/schema/generate-drizzle-schema-logic.ts +29 -24
- package/src/schema/generate-postgres-ddl-logic.ts +28 -76
- package/src/services/BranchService.ts +10 -42
- package/src/services/FetchService.ts +155 -84
- package/src/services/PersistService.ts +12 -121
- package/src/services/RelationService.ts +11 -10
- package/src/services/collection-helpers.ts +44 -129
- package/src/services/dataService.ts +2 -3
- package/src/services/index.ts +0 -1
- package/src/services/realtimeService.ts +19 -38
- package/src/websocket.ts +1 -4
- package/dist/collections/buildRegistry.d.ts +0 -27
- package/src/collections/buildRegistry.ts +0 -59
package/dist/index.es.js
CHANGED
|
@@ -3,15 +3,15 @@ import process from "process";
|
|
|
3
3
|
const require = __createRequire(import.meta.url);
|
|
4
4
|
import { Client, Pool } from "pg";
|
|
5
5
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
6
|
-
import {
|
|
6
|
+
import { createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
|
|
7
7
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
8
8
|
import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
9
|
+
import { createHash, randomUUID } from "crypto";
|
|
9
10
|
import fs, { promises } from "fs";
|
|
10
11
|
import path from "path";
|
|
11
12
|
import chokidar from "chokidar";
|
|
12
13
|
import { WebSocket, WebSocketServer } from "ws";
|
|
13
14
|
import { EventEmitter } from "events";
|
|
14
|
-
import { randomUUID } from "crypto";
|
|
15
15
|
import { inspect } from "util";
|
|
16
16
|
import os from "os";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -1502,140 +1502,6 @@ function removeFunctions(o) {
|
|
|
1502
1502
|
return o;
|
|
1503
1503
|
}
|
|
1504
1504
|
//#endregion
|
|
1505
|
-
//#region ../utils/src/sha1.ts
|
|
1506
|
-
/**
|
|
1507
|
-
* Minimal SHA-1 implementation that runs in both Node and the browser.
|
|
1508
|
-
*
|
|
1509
|
-
* This exists because generated Postgres policy names embed a SHA-1 digest of
|
|
1510
|
-
* the security rule. The DDL generator runs on the server (where `node:crypto`
|
|
1511
|
-
* is available) but the Studio has to derive the same names in the browser to
|
|
1512
|
-
* tell a policy it generated apart from one it did not. `node:crypto` cannot be
|
|
1513
|
-
* bundled for the browser, so the shared derivation needs a portable digest.
|
|
1514
|
-
*
|
|
1515
|
-
* SHA-1 is used purely to name things deterministically — never for security.
|
|
1516
|
-
* The output is byte-identical to `createHash("sha1").update(str).digest("hex")`,
|
|
1517
|
-
* which `sha1.test.ts` pins against `node:crypto` directly.
|
|
1518
|
-
*/
|
|
1519
|
-
/** Rotate a 32-bit word left by `n` bits. */
|
|
1520
|
-
function rotl(value, n) {
|
|
1521
|
-
return value << n | value >>> 32 - n;
|
|
1522
|
-
}
|
|
1523
|
-
/**
|
|
1524
|
-
* SHA-1 digest of a string, hex-encoded.
|
|
1525
|
-
*
|
|
1526
|
-
* The input is encoded as UTF-8, matching Node's default handling of strings
|
|
1527
|
-
* passed to `hash.update(str)`.
|
|
1528
|
-
*/
|
|
1529
|
-
function sha1Hex(input) {
|
|
1530
|
-
const bytes = Array.from(new TextEncoder().encode(input));
|
|
1531
|
-
const bitLength = bytes.length * 8;
|
|
1532
|
-
bytes.push(128);
|
|
1533
|
-
while (bytes.length % 64 !== 56) bytes.push(0);
|
|
1534
|
-
const hi = Math.floor(bitLength / 4294967296);
|
|
1535
|
-
const lo = bitLength >>> 0;
|
|
1536
|
-
bytes.push(hi >>> 24 & 255, hi >>> 16 & 255, hi >>> 8 & 255, hi & 255);
|
|
1537
|
-
bytes.push(lo >>> 24 & 255, lo >>> 16 & 255, lo >>> 8 & 255, lo & 255);
|
|
1538
|
-
let h0 = 1732584193;
|
|
1539
|
-
let h1 = 4023233417;
|
|
1540
|
-
let h2 = 2562383102;
|
|
1541
|
-
let h3 = 271733878;
|
|
1542
|
-
let h4 = 3285377520;
|
|
1543
|
-
const w = new Array(80);
|
|
1544
|
-
for (let offset = 0; offset < bytes.length; offset += 64) {
|
|
1545
|
-
for (let i = 0; i < 16; i++) {
|
|
1546
|
-
const j = offset + i * 4;
|
|
1547
|
-
w[i] = bytes[j] << 24 | bytes[j + 1] << 16 | bytes[j + 2] << 8 | bytes[j + 3] | 0;
|
|
1548
|
-
}
|
|
1549
|
-
for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
|
|
1550
|
-
let a = h0;
|
|
1551
|
-
let b = h1;
|
|
1552
|
-
let c = h2;
|
|
1553
|
-
let d = h3;
|
|
1554
|
-
let e = h4;
|
|
1555
|
-
for (let i = 0; i < 80; i++) {
|
|
1556
|
-
let f;
|
|
1557
|
-
let k;
|
|
1558
|
-
if (i < 20) {
|
|
1559
|
-
f = b & c | ~b & d;
|
|
1560
|
-
k = 1518500249;
|
|
1561
|
-
} else if (i < 40) {
|
|
1562
|
-
f = b ^ c ^ d;
|
|
1563
|
-
k = 1859775393;
|
|
1564
|
-
} else if (i < 60) {
|
|
1565
|
-
f = b & c | b & d | c & d;
|
|
1566
|
-
k = 2400959708;
|
|
1567
|
-
} else {
|
|
1568
|
-
f = b ^ c ^ d;
|
|
1569
|
-
k = 3395469782;
|
|
1570
|
-
}
|
|
1571
|
-
const temp = rotl(a, 5) + f + e + k + w[i] | 0;
|
|
1572
|
-
e = d;
|
|
1573
|
-
d = c;
|
|
1574
|
-
c = rotl(b, 30);
|
|
1575
|
-
b = a;
|
|
1576
|
-
a = temp;
|
|
1577
|
-
}
|
|
1578
|
-
h0 = h0 + a | 0;
|
|
1579
|
-
h1 = h1 + b | 0;
|
|
1580
|
-
h2 = h2 + c | 0;
|
|
1581
|
-
h3 = h3 + d | 0;
|
|
1582
|
-
h4 = h4 + e | 0;
|
|
1583
|
-
}
|
|
1584
|
-
return [
|
|
1585
|
-
h0,
|
|
1586
|
-
h1,
|
|
1587
|
-
h2,
|
|
1588
|
-
h3,
|
|
1589
|
-
h4
|
|
1590
|
-
].map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
|
|
1591
|
-
}
|
|
1592
|
-
//#endregion
|
|
1593
|
-
//#region ../utils/src/policy-names.ts
|
|
1594
|
-
/**
|
|
1595
|
-
* Naming of the Postgres policies generated from a collection's security rules.
|
|
1596
|
-
*
|
|
1597
|
-
* A rule without an explicit `name` is compiled to `<table>_<op>_<hash>`, where
|
|
1598
|
-
* the hash covers the rule's semantics. The Studio needs the same names to tell
|
|
1599
|
-
* "this policy came from your code" apart from "someone wrote this in SQL" —
|
|
1600
|
-
* without them it treats generated policies as foreign and offers to import
|
|
1601
|
-
* them back into the codebase they came from.
|
|
1602
|
-
*
|
|
1603
|
-
* This is the single definition of that naming. The DDL and Drizzle generators
|
|
1604
|
-
* both derive names from here, so a change cannot silently rename every policy
|
|
1605
|
-
* in every deployed database while the UI keeps matching the old ones.
|
|
1606
|
-
*/
|
|
1607
|
-
/** Stable digest of the parts of a rule that determine what the policy does. */
|
|
1608
|
-
function getPolicyNameHash(rule) {
|
|
1609
|
-
return sha1Hex(JSON.stringify({
|
|
1610
|
-
a: rule.access,
|
|
1611
|
-
m: rule.mode,
|
|
1612
|
-
op: rule.operation,
|
|
1613
|
-
ops: rule.operations?.slice().sort(),
|
|
1614
|
-
own: rule.ownerField,
|
|
1615
|
-
rol: rule.roles?.slice().sort(),
|
|
1616
|
-
pg: rule.pgRoles?.slice().sort(),
|
|
1617
|
-
u: rule.using,
|
|
1618
|
-
w: rule.withCheck,
|
|
1619
|
-
c: rule.condition,
|
|
1620
|
-
ch: rule.check
|
|
1621
|
-
})).substring(0, 7);
|
|
1622
|
-
}
|
|
1623
|
-
/** The operations a rule expands to — `operations` wins over `operation`. */
|
|
1624
|
-
function getPolicyOperations(rule) {
|
|
1625
|
-
return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
1626
|
-
}
|
|
1627
|
-
/**
|
|
1628
|
-
* Every Postgres policy name a single rule compiles to — one per operation.
|
|
1629
|
-
*
|
|
1630
|
-
* @param rule The security rule as written in the collection config.
|
|
1631
|
-
* @param tableName The rule's table (see `getTableName` in `@rebasepro/common`).
|
|
1632
|
-
*/
|
|
1633
|
-
function getPolicyNamesForRule(rule, tableName) {
|
|
1634
|
-
const ops = getPolicyOperations(rule);
|
|
1635
|
-
const ruleHash = getPolicyNameHash(rule);
|
|
1636
|
-
return ops.map((op, opIdx) => rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`);
|
|
1637
|
-
}
|
|
1638
|
-
//#endregion
|
|
1639
1505
|
//#region ../utils/src/names.ts
|
|
1640
1506
|
/**
|
|
1641
1507
|
* Generates a foreign key column name from a given string, typically a collection slug or name.
|
|
@@ -1760,109 +1626,6 @@ function createRelationRefWithData(id, path, data) {
|
|
|
1760
1626
|
data
|
|
1761
1627
|
};
|
|
1762
1628
|
}
|
|
1763
|
-
/**
|
|
1764
|
-
* Derive a row's address from its key columns.
|
|
1765
|
-
*
|
|
1766
|
-
* Single key → the value as a string. Composite → each part joined by
|
|
1767
|
-
* {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what
|
|
1768
|
-
* {@link parseIdValues} expects to invert.
|
|
1769
|
-
*/
|
|
1770
|
-
function buildCompositeId(values, primaryKeys) {
|
|
1771
|
-
if (primaryKeys.length === 0) return "";
|
|
1772
|
-
if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
|
|
1773
|
-
return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
|
|
1774
|
-
}
|
|
1775
|
-
/**
|
|
1776
|
-
* Invert {@link buildCompositeId}: turn an address back into key columns, each
|
|
1777
|
-
* coerced to the type its column actually round-trips as.
|
|
1778
|
-
*
|
|
1779
|
-
* This is the boundary where a URL segment becomes a query parameter, so a
|
|
1780
|
-
* malformed address must throw rather than silently produce a query that
|
|
1781
|
-
* matches the wrong row (or none).
|
|
1782
|
-
*/
|
|
1783
|
-
function parseIdValues(idValue, primaryKeys) {
|
|
1784
|
-
const result = {};
|
|
1785
|
-
if (primaryKeys.length === 0) return result;
|
|
1786
|
-
if (primaryKeys.length === 1) {
|
|
1787
|
-
const pk = primaryKeys[0];
|
|
1788
|
-
if (pk.type === "number" && !pk.isUUID) {
|
|
1789
|
-
const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
|
|
1790
|
-
if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
1791
|
-
result[pk.fieldName] = parsed;
|
|
1792
|
-
} else result[pk.fieldName] = String(idValue);
|
|
1793
|
-
return result;
|
|
1794
|
-
}
|
|
1795
|
-
const parts = String(idValue).split(":::");
|
|
1796
|
-
if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
|
|
1797
|
-
for (let i = 0; i < primaryKeys.length; i++) {
|
|
1798
|
-
const pk = primaryKeys[i];
|
|
1799
|
-
const val = parts[i];
|
|
1800
|
-
if (pk.type === "number" && !pk.isUUID) {
|
|
1801
|
-
const parsed = parseInt(val, 10);
|
|
1802
|
-
if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
|
|
1803
|
-
result[pk.fieldName] = parsed;
|
|
1804
|
-
} else result[pk.fieldName] = val;
|
|
1805
|
-
}
|
|
1806
|
-
return result;
|
|
1807
|
-
}
|
|
1808
|
-
/**
|
|
1809
|
-
* The primary keys of a collection, as declared by its properties.
|
|
1810
|
-
*
|
|
1811
|
-
* This is the only tier both sides can read, because it is the only one written
|
|
1812
|
-
* in the config: the postgres driver can also infer keys from the Drizzle
|
|
1813
|
-
* schema, which the browser never sees and is never sent — the admin compiles
|
|
1814
|
-
* the collection files into its own bundle rather than being served them. A key
|
|
1815
|
-
* that lives only in the Drizzle schema is therefore invisible here, and the
|
|
1816
|
-
* server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
|
|
1817
|
-
* to add.
|
|
1818
|
-
*
|
|
1819
|
-
* Returns an empty array when a collection declares none, which callers must
|
|
1820
|
-
* treat as "not addressable" rather than defaulting to `id`: guessing a key
|
|
1821
|
-
* that is not the real one produces confidently wrong addresses.
|
|
1822
|
-
*/
|
|
1823
|
-
function getDeclaredPrimaryKeys(collection) {
|
|
1824
|
-
const properties = collection.properties;
|
|
1825
|
-
if (!properties) return [];
|
|
1826
|
-
const keys = [];
|
|
1827
|
-
for (const [fieldName, propRaw] of Object.entries(properties)) {
|
|
1828
|
-
const prop = propRaw;
|
|
1829
|
-
if (!prop || typeof prop !== "object") continue;
|
|
1830
|
-
if (!("isId" in prop) || !prop.isId) continue;
|
|
1831
|
-
keys.push({
|
|
1832
|
-
fieldName,
|
|
1833
|
-
type: prop.type === "number" ? "number" : "string",
|
|
1834
|
-
isUUID: prop.isId === "uuid"
|
|
1835
|
-
});
|
|
1836
|
-
}
|
|
1837
|
-
return keys;
|
|
1838
|
-
}
|
|
1839
|
-
/**
|
|
1840
|
-
* The keys to address a collection's rows with, resolved the way the driver
|
|
1841
|
-
* resolves them — minus the tier the browser cannot reach.
|
|
1842
|
-
*
|
|
1843
|
-
* The postgres driver tries, in order: properties marked `isId`; the primary
|
|
1844
|
-
* keys of the Drizzle schema; and finally a column literally named `id`. Only
|
|
1845
|
-
* the first and last are visible in a `CollectionConfig`, which is what both
|
|
1846
|
-
* sides share.
|
|
1847
|
-
*
|
|
1848
|
-
* So the two agree except on a collection that declares no `isId` and whose key
|
|
1849
|
-
* is known only to Drizzle. There, the driver reads the real key, and this
|
|
1850
|
-
* either resolves nothing (reported to the console by the caller) or — if the
|
|
1851
|
-
* table happens to have an unrelated `id` property — resolves `id`, which is
|
|
1852
|
-
* the wrong key and cannot be detected from here: the addresses look right and
|
|
1853
|
-
* route wrong. Only the config can settle it, so the server names both cases
|
|
1854
|
-
* at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
|
|
1855
|
-
*/
|
|
1856
|
-
function resolvePrimaryKeys(collection) {
|
|
1857
|
-
const declared = getDeclaredPrimaryKeys(collection);
|
|
1858
|
-
if (declared.length > 0) return declared;
|
|
1859
|
-
const idProp = collection.properties?.id;
|
|
1860
|
-
if (idProp && typeof idProp === "object") return [{
|
|
1861
|
-
fieldName: "id",
|
|
1862
|
-
type: idProp.type === "number" ? "number" : "string"
|
|
1863
|
-
}];
|
|
1864
|
-
return [];
|
|
1865
|
-
}
|
|
1866
1629
|
//#endregion
|
|
1867
1630
|
//#region ../common/src/util/enums.ts
|
|
1868
1631
|
function enumToObjectEntries(enumValues) {
|
|
@@ -2097,109 +1860,15 @@ function getSubcollections(collection) {
|
|
|
2097
1860
|
* - `field = 'literal'`
|
|
2098
1861
|
* - `field != 'literal'`
|
|
2099
1862
|
* - `field = current_setting('app.user_id')`
|
|
2100
|
-
* - `A AND B
|
|
1863
|
+
* - `A AND B`
|
|
2101
1864
|
* - `true`
|
|
2102
1865
|
* - `IN (...)` (as optimistic true)
|
|
2103
1866
|
*
|
|
2104
1867
|
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
2105
1868
|
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
2106
|
-
*
|
|
2107
|
-
* **This output also round-trips back into DDL** via `policyToPostgres` (the
|
|
2108
|
-
* schema/policy generators), so decomposing a clause the parser only partly
|
|
2109
|
-
* understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
|
|
2110
|
-
* prefer `raw`: it is reproduced verbatim.
|
|
2111
1869
|
*/
|
|
2112
|
-
/** True when `keyword` starts at `i` as a standalone word. */
|
|
2113
|
-
function isKeywordAt(upper, i, keyword) {
|
|
2114
|
-
if (!upper.startsWith(keyword, i)) return false;
|
|
2115
|
-
const before = i === 0 ? " " : upper[i - 1];
|
|
2116
|
-
const after = upper[i + keyword.length] ?? " ";
|
|
2117
|
-
return /[\s()]/.test(before) && /[\s()]/.test(after);
|
|
2118
|
-
}
|
|
2119
|
-
/**
|
|
2120
|
-
* Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
|
|
2121
|
-
* outside a string literal. Returns null when it never does, so the caller
|
|
2122
|
-
* leaves the clause alone.
|
|
2123
|
-
*
|
|
2124
|
-
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
2125
|
-
* `AND` inside
|
|
2126
|
-
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
|
|
2127
|
-
* split the expression, and re-emitting the halves produced
|
|
2128
|
-
* `(EXISTS (...) AND m.user_id = auth.uid())`
|
|
2129
|
-
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
2130
|
-
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
2131
|
-
* clause as a `raw` expression, which round-trips verbatim.
|
|
2132
|
-
*/
|
|
2133
|
-
function splitTopLevel(sql, keyword) {
|
|
2134
|
-
const upper = sql.toUpperCase();
|
|
2135
|
-
const parts = [];
|
|
2136
|
-
let depth = 0;
|
|
2137
|
-
let inString = false;
|
|
2138
|
-
let start = 0;
|
|
2139
|
-
for (let i = 0; i < sql.length; i++) {
|
|
2140
|
-
const ch = sql[i];
|
|
2141
|
-
if (inString) {
|
|
2142
|
-
if (ch === "'") if (sql[i + 1] === "'") i++;
|
|
2143
|
-
else inString = false;
|
|
2144
|
-
continue;
|
|
2145
|
-
}
|
|
2146
|
-
if (ch === "'") {
|
|
2147
|
-
inString = true;
|
|
2148
|
-
continue;
|
|
2149
|
-
}
|
|
2150
|
-
if (ch === "(") {
|
|
2151
|
-
depth++;
|
|
2152
|
-
continue;
|
|
2153
|
-
}
|
|
2154
|
-
if (ch === ")") {
|
|
2155
|
-
depth--;
|
|
2156
|
-
continue;
|
|
2157
|
-
}
|
|
2158
|
-
if (depth === 0 && isKeywordAt(upper, i, keyword)) {
|
|
2159
|
-
parts.push(sql.slice(start, i));
|
|
2160
|
-
i += keyword.length - 1;
|
|
2161
|
-
start = i + 1;
|
|
2162
|
-
}
|
|
2163
|
-
}
|
|
2164
|
-
if (parts.length === 0) return null;
|
|
2165
|
-
parts.push(sql.slice(start));
|
|
2166
|
-
const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
2167
|
-
return trimmedParts.length > 1 ? trimmedParts : null;
|
|
2168
|
-
}
|
|
2169
|
-
/** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
|
|
2170
|
-
function stripOuterParens(sql) {
|
|
2171
|
-
let s = sql.trim();
|
|
2172
|
-
for (;;) {
|
|
2173
|
-
if (!s.startsWith("(") || !s.endsWith(")")) return s;
|
|
2174
|
-
let depth = 0;
|
|
2175
|
-
let inString = false;
|
|
2176
|
-
let wraps = true;
|
|
2177
|
-
for (let i = 0; i < s.length; i++) {
|
|
2178
|
-
const ch = s[i];
|
|
2179
|
-
if (inString) {
|
|
2180
|
-
if (ch === "'") if (s[i + 1] === "'") i++;
|
|
2181
|
-
else inString = false;
|
|
2182
|
-
continue;
|
|
2183
|
-
}
|
|
2184
|
-
if (ch === "'") {
|
|
2185
|
-
inString = true;
|
|
2186
|
-
continue;
|
|
2187
|
-
}
|
|
2188
|
-
if (ch === "(") depth++;
|
|
2189
|
-
else if (ch === ")") {
|
|
2190
|
-
depth--;
|
|
2191
|
-
if (depth === 0 && i < s.length - 1) {
|
|
2192
|
-
wraps = false;
|
|
2193
|
-
break;
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2197
|
-
if (!wraps) return s;
|
|
2198
|
-
s = s.slice(1, -1).trim();
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
1870
|
function sqlToPolicy(sql) {
|
|
2202
|
-
const trimmed =
|
|
1871
|
+
const trimmed = sql.trim();
|
|
2203
1872
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
2204
1873
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
2205
1874
|
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
@@ -2212,10 +1881,14 @@ function sqlToPolicy(sql) {
|
|
|
2212
1881
|
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
2213
1882
|
return policy.rolesContain(roles);
|
|
2214
1883
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
1884
|
+
if (trimmed.toUpperCase().includes(" OR ")) {
|
|
1885
|
+
const parts = trimmed.split(/ OR /i);
|
|
1886
|
+
return policy.or(...parts.map(sqlToPolicy));
|
|
1887
|
+
}
|
|
1888
|
+
if (trimmed.toUpperCase().includes(" AND ")) {
|
|
1889
|
+
const parts = trimmed.split(/ AND /i);
|
|
1890
|
+
return policy.and(...parts.map(sqlToPolicy));
|
|
1891
|
+
}
|
|
2219
1892
|
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
2220
1893
|
if (match) {
|
|
2221
1894
|
const [, leftStr, op, rightStr] = match;
|
|
@@ -2521,307 +2194,6 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
2521
2194
|
};
|
|
2522
2195
|
return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
|
|
2523
2196
|
};
|
|
2524
|
-
//#endregion
|
|
2525
|
-
//#region ../common/src/util/auth-default-policies.ts
|
|
2526
|
-
/**
|
|
2527
|
-
* Default RLS policies injected by the schema generator.
|
|
2528
|
-
*
|
|
2529
|
-
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
2530
|
-
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
2531
|
-
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
2532
|
-
* authorization model. The server context (auth flows, migrations,
|
|
2533
|
-
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
2534
|
-
*
|
|
2535
|
-
* Because RLS default-denies, every collection is **locked by default**: with
|
|
2536
|
-
* no rules, only the server context and admins can touch it. The generator
|
|
2537
|
-
* injects that safe baseline:
|
|
2538
|
-
*
|
|
2539
|
-
* **For every collection**
|
|
2540
|
-
* 1. A permissive **server-or-admin SELECT** grant.
|
|
2541
|
-
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
2542
|
-
*
|
|
2543
|
-
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
2544
|
-
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
2545
|
-
* rows").
|
|
2546
|
-
*
|
|
2547
|
-
* **For auth collections additionally**
|
|
2548
|
-
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
2549
|
-
* their own row (profile, session bootstrap) without every app re-declaring
|
|
2550
|
-
* it.
|
|
2551
|
-
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
2552
|
-
* every other policy, so a write is rejected unless the caller is an admin
|
|
2553
|
-
* (or the server context) — even if the author also wrote a permissive rule
|
|
2554
|
-
* such as "a user may edit their own row". Without this, a permissive owner
|
|
2555
|
-
* rule would let a user change their own `roles`.
|
|
2556
|
-
*
|
|
2557
|
-
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
2558
|
-
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
2559
|
-
* GUC — which also lets the owner connection satisfy these policies even under
|
|
2560
|
-
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
2561
|
-
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
2562
|
-
*
|
|
2563
|
-
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
2564
|
-
* the collection's RLS.
|
|
2565
|
-
*/
|
|
2566
|
-
var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2567
|
-
/** Write operations that must be admin-gated by default on auth collections. */
|
|
2568
|
-
var DEFAULT_GUARDED_OPS = [
|
|
2569
|
-
"insert",
|
|
2570
|
-
"update",
|
|
2571
|
-
"delete"
|
|
2572
|
-
];
|
|
2573
|
-
/** Whether a collection is flagged as an authentication collection. */
|
|
2574
|
-
function isAuthCollection(collection) {
|
|
2575
|
-
const auth = collection.auth;
|
|
2576
|
-
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
2577
|
-
}
|
|
2578
|
-
/** The property marked as the row id (falls back to `id`). */
|
|
2579
|
-
function getIdPropertyName$1(collection) {
|
|
2580
|
-
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2581
|
-
return "id";
|
|
2582
|
-
}
|
|
2583
|
-
/**
|
|
2584
|
-
* Returns the security rules that should be applied to a collection: the
|
|
2585
|
-
* author's explicit `securityRules` plus the framework defaults described in
|
|
2586
|
-
* the module doc (baseline server/admin read for all collections; self-read
|
|
2587
|
-
* and the admin write gate for auth collections).
|
|
2588
|
-
*
|
|
2589
|
-
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
2590
|
-
*/
|
|
2591
|
-
function getEffectiveSecurityRules(collection) {
|
|
2592
|
-
const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
|
|
2593
|
-
if (collection.disableDefaultPolicies) return explicit;
|
|
2594
|
-
const tableName = getTableName$1(collection);
|
|
2595
|
-
const injected = [];
|
|
2596
|
-
injected.push({
|
|
2597
|
-
name: `${tableName}_default_admin_read`,
|
|
2598
|
-
operations: ["select"],
|
|
2599
|
-
condition: SERVER_OR_ADMIN_EXPR$1
|
|
2600
|
-
});
|
|
2601
|
-
injected.push({
|
|
2602
|
-
name: `${tableName}_default_admin_write`,
|
|
2603
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
2604
|
-
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2605
|
-
check: SERVER_OR_ADMIN_EXPR$1
|
|
2606
|
-
});
|
|
2607
|
-
if (isAuthCollection(collection)) {
|
|
2608
|
-
injected.push({
|
|
2609
|
-
name: `${tableName}_default_self_read`,
|
|
2610
|
-
operations: ["select"],
|
|
2611
|
-
condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
|
|
2612
|
-
});
|
|
2613
|
-
injected.push({
|
|
2614
|
-
name: `${tableName}_require_admin_write`,
|
|
2615
|
-
mode: "restrictive",
|
|
2616
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
2617
|
-
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2618
|
-
check: SERVER_OR_ADMIN_EXPR$1
|
|
2619
|
-
});
|
|
2620
|
-
}
|
|
2621
|
-
return [...explicit, ...injected];
|
|
2622
|
-
}
|
|
2623
|
-
//#endregion
|
|
2624
|
-
//#region ../common/src/util/junction-policies.ts
|
|
2625
|
-
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2626
|
-
/**
|
|
2627
|
-
* Walk every collection's resolved relations and aggregate the junction tables
|
|
2628
|
-
* they declare. Two collections may declare the same junction from opposite
|
|
2629
|
-
* sides (posts→tags and tags→posts through `posts_tags`); both become
|
|
2630
|
-
* `declaringSides` of one spec, so derived write grants consider both.
|
|
2631
|
-
*/
|
|
2632
|
-
function resolveJunctionSpecs(collections) {
|
|
2633
|
-
const specs = /* @__PURE__ */ new Map();
|
|
2634
|
-
for (const collection of collections) {
|
|
2635
|
-
const resolved = resolveCollectionRelations(collection);
|
|
2636
|
-
for (const relation of Object.values(resolved)) {
|
|
2637
|
-
if (!relation.through) continue;
|
|
2638
|
-
const targetCollection = typeof relation.target === "function" ? relation.target() : void 0;
|
|
2639
|
-
if (!targetCollection) continue;
|
|
2640
|
-
const rawName = relation.through.table;
|
|
2641
|
-
const table = rawName.includes(".") ? rawName.split(".").pop() : rawName;
|
|
2642
|
-
const schema = "public";
|
|
2643
|
-
const source = {
|
|
2644
|
-
collection,
|
|
2645
|
-
junctionColumn: relation.through.sourceColumn,
|
|
2646
|
-
relation
|
|
2647
|
-
};
|
|
2648
|
-
const target = {
|
|
2649
|
-
collection: targetCollection,
|
|
2650
|
-
junctionColumn: relation.through.targetColumn
|
|
2651
|
-
};
|
|
2652
|
-
const existing = specs.get(table);
|
|
2653
|
-
if (!existing) specs.set(table, {
|
|
2654
|
-
table,
|
|
2655
|
-
schema,
|
|
2656
|
-
endpoints: [source, target],
|
|
2657
|
-
declaringSides: [source]
|
|
2658
|
-
});
|
|
2659
|
-
else if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
return specs;
|
|
2663
|
-
}
|
|
2664
|
-
/**
|
|
2665
|
-
* A synthetic CollectionConfig standing in for the junction during policy
|
|
2666
|
-
* compilation and naming. Its two FK columns carry explicit `columnName`s so
|
|
2667
|
-
* `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
|
|
2668
|
-
* whatever their casing.
|
|
2669
|
-
*/
|
|
2670
|
-
function getJunctionCollectionConfig(spec) {
|
|
2671
|
-
const properties = {};
|
|
2672
|
-
for (const endpoint of spec.endpoints) properties[endpoint.junctionColumn] = {
|
|
2673
|
-
type: "string",
|
|
2674
|
-
columnName: endpoint.junctionColumn
|
|
2675
|
-
};
|
|
2676
|
-
return {
|
|
2677
|
-
slug: spec.table,
|
|
2678
|
-
name: spec.table,
|
|
2679
|
-
table: spec.table,
|
|
2680
|
-
schema: spec.schema,
|
|
2681
|
-
properties
|
|
2682
|
-
};
|
|
2683
|
-
}
|
|
2684
|
-
/** The property marked as the row id (falls back to `id`). */
|
|
2685
|
-
function getIdPropertyName(collection) {
|
|
2686
|
-
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2687
|
-
return "id";
|
|
2688
|
-
}
|
|
2689
|
-
/** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */
|
|
2690
|
-
function existsEndpoint(endpoint, extra) {
|
|
2691
|
-
const correlation = policy.compare(policy.field(getIdPropertyName(endpoint.collection)), "eq", policy.outerField(endpoint.junctionColumn));
|
|
2692
|
-
return policy.existsIn({
|
|
2693
|
-
collection: endpoint.collection.slug,
|
|
2694
|
-
where: extra ? policy.and(correlation, extra) : correlation
|
|
2695
|
-
});
|
|
2696
|
-
}
|
|
2697
|
-
/**
|
|
2698
|
-
* Whether a parent-rule expression keeps its meaning when moved inside the
|
|
2699
|
-
* junction's `EXISTS` subquery — and the re-scoped copy if it does.
|
|
2700
|
-
*
|
|
2701
|
-
* Returns `null` when the rule cannot be embedded faithfully: `raw` SQL
|
|
2702
|
-
* anywhere (its `{column}` placeholders would bind to the junction), or an
|
|
2703
|
-
* `outerField` inside a nested `existsIn` (it would bind to the junction while
|
|
2704
|
-
* the author meant the parent, and no operand can express "the middle scope").
|
|
2705
|
-
* Top-level `outerField`s are rewritten to `field`, which is what they meant.
|
|
2706
|
-
*/
|
|
2707
|
-
function embedParentExpression(expr, depth = 0) {
|
|
2708
|
-
switch (expr.kind) {
|
|
2709
|
-
case "raw": return null;
|
|
2710
|
-
case "and":
|
|
2711
|
-
case "or": {
|
|
2712
|
-
const parts = [];
|
|
2713
|
-
for (const child of expr.operands) {
|
|
2714
|
-
const embedded = embedParentExpression(child, depth);
|
|
2715
|
-
if (!embedded) return null;
|
|
2716
|
-
parts.push(embedded);
|
|
2717
|
-
}
|
|
2718
|
-
return expr.kind === "and" ? policy.and(...parts) : policy.or(...parts);
|
|
2719
|
-
}
|
|
2720
|
-
case "not": {
|
|
2721
|
-
const embedded = embedParentExpression(expr.operand, depth);
|
|
2722
|
-
return embedded ? policy.not(embedded) : null;
|
|
2723
|
-
}
|
|
2724
|
-
case "existsIn": {
|
|
2725
|
-
const where = embedParentExpression(expr.where, depth + 1);
|
|
2726
|
-
return where ? policy.existsIn({
|
|
2727
|
-
collection: expr.collection,
|
|
2728
|
-
where
|
|
2729
|
-
}) : null;
|
|
2730
|
-
}
|
|
2731
|
-
case "compare": {
|
|
2732
|
-
const left = embedOperand(expr.left, depth);
|
|
2733
|
-
const right = embedOperand(expr.right, depth);
|
|
2734
|
-
if (!left || !right) return null;
|
|
2735
|
-
return {
|
|
2736
|
-
...expr,
|
|
2737
|
-
left,
|
|
2738
|
-
right
|
|
2739
|
-
};
|
|
2740
|
-
}
|
|
2741
|
-
default: return expr;
|
|
2742
|
-
}
|
|
2743
|
-
}
|
|
2744
|
-
/** Re-scope an operand, or return `null` if its binding cannot be preserved. */
|
|
2745
|
-
function embedOperand(operand, depth) {
|
|
2746
|
-
if (operand.kind === "outerField") {
|
|
2747
|
-
if (depth === 0) return policy.field(operand.name);
|
|
2748
|
-
return null;
|
|
2749
|
-
}
|
|
2750
|
-
return operand;
|
|
2751
|
-
}
|
|
2752
|
-
/** Does the rule cover the `update` operation? */
|
|
2753
|
-
function coversUpdate(rule) {
|
|
2754
|
-
return getPolicyOperations(rule).some((op) => op === "update" || op === "all");
|
|
2755
|
-
}
|
|
2756
|
-
/**
|
|
2757
|
-
* The full derived policy set for a junction table: the locked server/admin
|
|
2758
|
-
* baseline, the endpoint-visibility read grant, inherited write grants, and
|
|
2759
|
-
* inherited restrictive gates. Returns `[]` when every declaring collection set
|
|
2760
|
-
* `disableDefaultPolicies` — the junction is then the author's to police, and
|
|
2761
|
-
* stays locked (RLS is still enabled) until they write policies for it.
|
|
2762
|
-
*/
|
|
2763
|
-
function getJunctionSecurityRules(spec) {
|
|
2764
|
-
if (spec.declaringSides.every((side) => side.collection.disableDefaultPolicies)) return [];
|
|
2765
|
-
const rules = [];
|
|
2766
|
-
rules.push({
|
|
2767
|
-
name: `${spec.table}_default_admin_read`,
|
|
2768
|
-
operations: ["select"],
|
|
2769
|
-
condition: SERVER_OR_ADMIN_EXPR
|
|
2770
|
-
});
|
|
2771
|
-
rules.push({
|
|
2772
|
-
name: `${spec.table}_default_admin_write`,
|
|
2773
|
-
operations: [
|
|
2774
|
-
"insert",
|
|
2775
|
-
"update",
|
|
2776
|
-
"delete"
|
|
2777
|
-
],
|
|
2778
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
2779
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
2780
|
-
});
|
|
2781
|
-
rules.push({
|
|
2782
|
-
name: `${spec.table}_default_edge_read`,
|
|
2783
|
-
operations: ["select"],
|
|
2784
|
-
condition: policy.and(existsEndpoint(spec.endpoints[0]), existsEndpoint(spec.endpoints[1]))
|
|
2785
|
-
});
|
|
2786
|
-
const writeGrants = [];
|
|
2787
|
-
for (const side of spec.declaringSides) {
|
|
2788
|
-
const updateRules = ((isPostgresCollectionConfig(side.collection) ? side.collection.securityRules : void 0) ?? []).filter(coversUpdate);
|
|
2789
|
-
const permissive = updateRules.filter((r) => r.mode !== "restrictive");
|
|
2790
|
-
const restrictive = updateRules.filter((r) => r.mode === "restrictive");
|
|
2791
|
-
const embeddedGates = [];
|
|
2792
|
-
let gatesEmbeddable = true;
|
|
2793
|
-
for (const gate of restrictive) {
|
|
2794
|
-
const using = securityRuleToConditions(gate).usingExpr;
|
|
2795
|
-
const embedded = using ? embedParentExpression(using) : null;
|
|
2796
|
-
if (!embedded) {
|
|
2797
|
-
gatesEmbeddable = false;
|
|
2798
|
-
break;
|
|
2799
|
-
}
|
|
2800
|
-
embeddedGates.push(embedded);
|
|
2801
|
-
}
|
|
2802
|
-
if (!gatesEmbeddable) continue;
|
|
2803
|
-
const grants = [];
|
|
2804
|
-
for (const rule of permissive) {
|
|
2805
|
-
const using = securityRuleToConditions(rule).usingExpr;
|
|
2806
|
-
const embedded = using ? embedParentExpression(using) : null;
|
|
2807
|
-
if (embedded) grants.push(embedded);
|
|
2808
|
-
}
|
|
2809
|
-
if (grants.length === 0) continue;
|
|
2810
|
-
const condition = embeddedGates.length > 0 ? policy.and(policy.or(...grants), ...embeddedGates) : policy.or(...grants);
|
|
2811
|
-
writeGrants.push(existsEndpoint(side, condition));
|
|
2812
|
-
}
|
|
2813
|
-
if (writeGrants.length > 0) rules.push({
|
|
2814
|
-
name: `${spec.table}_default_edge_write`,
|
|
2815
|
-
operations: [
|
|
2816
|
-
"insert",
|
|
2817
|
-
"update",
|
|
2818
|
-
"delete"
|
|
2819
|
-
],
|
|
2820
|
-
condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),
|
|
2821
|
-
check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)
|
|
2822
|
-
});
|
|
2823
|
-
return rules;
|
|
2824
|
-
}
|
|
2825
2197
|
(/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
2826
2198
|
(function(root, factory) {
|
|
2827
2199
|
if (typeof define === "function" && define.amd) define(factory);
|
|
@@ -4091,45 +3463,18 @@ function deserializeFilter(query) {
|
|
|
4091
3463
|
}
|
|
4092
3464
|
//#endregion
|
|
4093
3465
|
//#region ../common/src/data/buildRebaseData.ts
|
|
4094
|
-
function createPrimaryKeyResolver(options) {
|
|
4095
|
-
const cache = /* @__PURE__ */ new Map();
|
|
4096
|
-
const warned = /* @__PURE__ */ new Set();
|
|
4097
|
-
return function primaryKeysFor(slug) {
|
|
4098
|
-
const cached = cache.get(slug);
|
|
4099
|
-
if (cached) return cached;
|
|
4100
|
-
const collection = options?.resolveCollection?.(slug);
|
|
4101
|
-
if (!collection) return [];
|
|
4102
|
-
const keys = resolvePrimaryKeys(collection);
|
|
4103
|
-
if (keys.length > 0) {
|
|
4104
|
-
cache.set(slug, keys);
|
|
4105
|
-
return keys;
|
|
4106
|
-
}
|
|
4107
|
-
if (!warned.has(slug)) {
|
|
4108
|
-
warned.add(slug);
|
|
4109
|
-
console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
|
|
4110
|
-
}
|
|
4111
|
-
return keys;
|
|
4112
|
-
};
|
|
4113
|
-
}
|
|
4114
3466
|
/**
|
|
4115
|
-
*
|
|
4116
|
-
*
|
|
4117
|
-
* The address is *derived here* — it is not a column, and the row it came from
|
|
4118
|
-
* does not contain one. Rows carry exactly what the table has, with the types
|
|
4119
|
-
* Postgres returned; the id is this layer's invention, and this is the only
|
|
4120
|
-
* place it is minted.
|
|
4121
|
-
*
|
|
4122
|
-
* `primaryKeys` empty falls back to a literal `id` on the row: drivers other
|
|
4123
|
-
* than postgres still serve rows with one, and this keeps them working.
|
|
3467
|
+
* Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
|
|
3468
|
+
* Mirrors the client SDK's rowToEntity conversion.
|
|
4124
3469
|
*/
|
|
4125
|
-
function rowToEntity(row, slug
|
|
3470
|
+
function rowToEntity(row, slug) {
|
|
4126
3471
|
return {
|
|
4127
|
-
id:
|
|
3472
|
+
id: row.id,
|
|
4128
3473
|
path: slug,
|
|
4129
3474
|
values: row
|
|
4130
3475
|
};
|
|
4131
3476
|
}
|
|
4132
|
-
function createDriverAccessor(driver, slug
|
|
3477
|
+
function createDriverAccessor(driver, slug) {
|
|
4133
3478
|
const accessor = {
|
|
4134
3479
|
async find(params) {
|
|
4135
3480
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
@@ -4162,7 +3507,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4162
3507
|
hasMore = offset + rows.length < total;
|
|
4163
3508
|
}
|
|
4164
3509
|
return {
|
|
4165
|
-
data: rows.map((row) => rowToEntity(row, slug
|
|
3510
|
+
data: rows.map((row) => rowToEntity(row, slug)),
|
|
4166
3511
|
meta: {
|
|
4167
3512
|
total,
|
|
4168
3513
|
limit,
|
|
@@ -4176,7 +3521,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4176
3521
|
path: slug,
|
|
4177
3522
|
id
|
|
4178
3523
|
});
|
|
4179
|
-
return row ? rowToEntity(row, slug
|
|
3524
|
+
return row ? rowToEntity(row, slug) : void 0;
|
|
4180
3525
|
},
|
|
4181
3526
|
async create(data, id) {
|
|
4182
3527
|
return rowToEntity(await driver.save({
|
|
@@ -4184,22 +3529,15 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4184
3529
|
values: data,
|
|
4185
3530
|
id,
|
|
4186
3531
|
status: "new"
|
|
4187
|
-
}), slug
|
|
3532
|
+
}), slug);
|
|
4188
3533
|
},
|
|
4189
|
-
createMany: driver.saveMany ? async (data, options) => {
|
|
4190
|
-
return (await driver.saveMany({
|
|
4191
|
-
path: slug,
|
|
4192
|
-
rows: data,
|
|
4193
|
-
upsert: options?.upsert
|
|
4194
|
-
})).map((row) => rowToEntity(row, slug, getPks()));
|
|
4195
|
-
} : void 0,
|
|
4196
3534
|
async update(id, data) {
|
|
4197
3535
|
return rowToEntity(await driver.save({
|
|
4198
3536
|
path: slug,
|
|
4199
3537
|
values: data,
|
|
4200
3538
|
id,
|
|
4201
3539
|
status: "existing"
|
|
4202
|
-
}), slug
|
|
3540
|
+
}), slug);
|
|
4203
3541
|
},
|
|
4204
3542
|
async delete(id) {
|
|
4205
3543
|
return driver.delete({ row: {
|
|
@@ -4228,7 +3566,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4228
3566
|
searchString: params?.searchString,
|
|
4229
3567
|
onUpdate: (entities) => {
|
|
4230
3568
|
onUpdate({
|
|
4231
|
-
data: entities.map((row) => rowToEntity(row, slug
|
|
3569
|
+
data: entities.map((row) => rowToEntity(row, slug)),
|
|
4232
3570
|
meta: {
|
|
4233
3571
|
total: entities.length,
|
|
4234
3572
|
limit,
|
|
@@ -4244,7 +3582,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4244
3582
|
return driver.listenOne({
|
|
4245
3583
|
path: slug,
|
|
4246
3584
|
id,
|
|
4247
|
-
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug
|
|
3585
|
+
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
|
|
4248
3586
|
onError
|
|
4249
3587
|
});
|
|
4250
3588
|
} : void 0,
|
|
@@ -4283,13 +3621,12 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4283
3621
|
* await data.products.create({ name: "Camera", price: 299 });
|
|
4284
3622
|
* const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
|
|
4285
3623
|
*/
|
|
4286
|
-
function buildRebaseData(driver
|
|
3624
|
+
function buildRebaseData(driver) {
|
|
4287
3625
|
const cache = /* @__PURE__ */ new Map();
|
|
4288
|
-
const primaryKeysFor = createPrimaryKeyResolver(options);
|
|
4289
3626
|
function getAccessor(slug) {
|
|
4290
3627
|
let accessor = cache.get(slug);
|
|
4291
3628
|
if (!accessor) {
|
|
4292
|
-
accessor = createDriverAccessor(driver, slug
|
|
3629
|
+
accessor = createDriverAccessor(driver, slug);
|
|
4293
3630
|
cache.set(slug, accessor);
|
|
4294
3631
|
}
|
|
4295
3632
|
return accessor;
|
|
@@ -4302,9 +3639,8 @@ function buildRebaseData(driver, options) {
|
|
|
4302
3639
|
} });
|
|
4303
3640
|
}
|
|
4304
3641
|
/**
|
|
4305
|
-
* Unwrap a Entity
|
|
4306
|
-
*
|
|
4307
|
-
* the wrapper is the whole operation — the address was never part of the row.
|
|
3642
|
+
* Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
|
|
3643
|
+
* (id included) under `.values`, so this is just that payload.
|
|
4308
3644
|
*/
|
|
4309
3645
|
function entityToRow(entity) {
|
|
4310
3646
|
return entity.values;
|
|
@@ -4391,12 +3727,6 @@ function toSdkCollectionClient(snap) {
|
|
|
4391
3727
|
async create(data, id) {
|
|
4392
3728
|
return entityToRow(await snap.create(data, id));
|
|
4393
3729
|
},
|
|
4394
|
-
async createMany(data, options) {
|
|
4395
|
-
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
4396
|
-
if (data.length === 0) return [];
|
|
4397
|
-
if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
|
|
4398
|
-
return (await snap.createMany(data, options)).map(entityToRow);
|
|
4399
|
-
},
|
|
4400
3730
|
async update(id, data) {
|
|
4401
3731
|
return entityToRow(await snap.update(id, data));
|
|
4402
3732
|
},
|
|
@@ -4584,86 +3914,37 @@ function getPrimaryKeys(collection, registry) {
|
|
|
4584
3914
|
isUUID
|
|
4585
3915
|
});
|
|
4586
3916
|
}
|
|
4587
|
-
return keys;
|
|
4588
|
-
}
|
|
4589
|
-
/**
|
|
4590
|
-
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
4591
|
-
* instead.
|
|
4592
|
-
*
|
|
4593
|
-
* The two sides resolve keys from different evidence. This driver reads, in
|
|
4594
|
-
* order: properties marked `isId`, the primary keys of the Drizzle schema, then
|
|
4595
|
-
* a column literally named `id`. The admin shares the `CollectionConfig` — it
|
|
4596
|
-
* compiles the same collection files into its bundle — but never the Drizzle
|
|
4597
|
-
* schema, so the middle tier is invisible to it.
|
|
4598
|
-
*
|
|
4599
|
-
* Nothing can normalize this at runtime: the server does not serve the admin
|
|
4600
|
-
* its collections, so a key resolved here cannot be handed over there. The
|
|
4601
|
-
* config files are the only thing both sides read, so the fix is an edit to
|
|
4602
|
-
* them, and the most this can do is say exactly which edit.
|
|
4603
|
-
*
|
|
4604
|
-
* Two shapes, and the second is the dangerous one:
|
|
4605
|
-
*
|
|
4606
|
-
* - No `isId`, no `id` property → the admin resolves no address, warns in the
|
|
4607
|
-
* console, and rows cannot be opened or linked.
|
|
4608
|
-
* - No `isId`, but an `id` property that is *not* the key → the admin addresses
|
|
4609
|
-
* rows by `id` while this driver reads the address as the real key. Nothing
|
|
4610
|
-
* errors: the addresses look right and route wrong.
|
|
4611
|
-
*/
|
|
4612
|
-
function findUnresolvableKeyCollections(collections, registry) {
|
|
4613
|
-
const findings = [];
|
|
4614
|
-
for (const collection of collections) {
|
|
4615
|
-
if (getDeclaredPrimaryKeys(collection).length > 0) continue;
|
|
4616
|
-
let keys;
|
|
4617
|
-
try {
|
|
4618
|
-
keys = getPrimaryKeys(collection, registry);
|
|
4619
|
-
} catch {
|
|
4620
|
-
continue;
|
|
4621
|
-
}
|
|
4622
|
-
if (keys.length === 0) continue;
|
|
4623
|
-
if (keys.length === 1 && keys[0].fieldName === "id") continue;
|
|
4624
|
-
findings.push({
|
|
4625
|
-
collection,
|
|
4626
|
-
keys,
|
|
4627
|
-
shadowedByIdProperty: Boolean(collection.properties?.id)
|
|
4628
|
-
});
|
|
4629
|
-
}
|
|
4630
|
-
return findings;
|
|
3917
|
+
return keys;
|
|
4631
3918
|
}
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
const
|
|
4645
|
-
if (
|
|
4646
|
-
|
|
3919
|
+
function parseIdValues(idValue, primaryKeys) {
|
|
3920
|
+
const result = {};
|
|
3921
|
+
if (primaryKeys.length === 0) return result;
|
|
3922
|
+
if (primaryKeys.length === 1) {
|
|
3923
|
+
const pk = primaryKeys[0];
|
|
3924
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
3925
|
+
const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
|
|
3926
|
+
if (isNaN(parsed)) throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
3927
|
+
result[pk.fieldName] = parsed;
|
|
3928
|
+
} else result[pk.fieldName] = String(idValue);
|
|
3929
|
+
return result;
|
|
3930
|
+
}
|
|
3931
|
+
const parts = String(idValue).split(":::");
|
|
3932
|
+
if (parts.length !== primaryKeys.length) throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
|
|
3933
|
+
for (let i = 0; i < primaryKeys.length; i++) {
|
|
3934
|
+
const pk = primaryKeys[i];
|
|
3935
|
+
const val = parts[i];
|
|
3936
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
3937
|
+
const parsed = parseInt(val, 10);
|
|
3938
|
+
if (isNaN(parsed)) throw new Error(`Invalid numeric ID component: ${val}`);
|
|
3939
|
+
result[pk.fieldName] = parsed;
|
|
3940
|
+
} else result[pk.fieldName] = val;
|
|
3941
|
+
}
|
|
3942
|
+
return result;
|
|
4647
3943
|
}
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
* Falls back to a literal `id` column, which covers the two cases where the
|
|
4653
|
-
* keys cannot be resolved: a row that reached us from somewhere other than the
|
|
4654
|
-
* postgres driver, and a collection whose table the registry cannot look up
|
|
4655
|
-
* (`getPrimaryKeys` throws for an unregistered table rather than returning
|
|
4656
|
-
* nothing). Returns `""` when there is no key and no `id` — callers decide what
|
|
4657
|
-
* that means, since "unaddressable" is a different answer in a notification
|
|
4658
|
-
* (broadcast a wildcard) than in a save (fail).
|
|
4659
|
-
*/
|
|
4660
|
-
function deriveRowAddress(row, collection, registry) {
|
|
4661
|
-
try {
|
|
4662
|
-
const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
|
|
4663
|
-
if (composite && composite.split(":::").some((part) => part !== "")) return composite;
|
|
4664
|
-
} catch {}
|
|
4665
|
-
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
4666
|
-
return "";
|
|
3944
|
+
function buildCompositeId(values, primaryKeys) {
|
|
3945
|
+
if (primaryKeys.length === 0) return "";
|
|
3946
|
+
if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
|
|
3947
|
+
return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
|
|
4667
3948
|
}
|
|
4668
3949
|
//#endregion
|
|
4669
3950
|
//#region src/utils/drizzle-conditions.ts
|
|
@@ -5286,10 +4567,12 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
5286
4567
|
continue;
|
|
5287
4568
|
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
5288
4569
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
4570
|
+
const pks = getPrimaryKeys(collection, registry);
|
|
5289
4571
|
inverseRelationUpdates.push({
|
|
5290
4572
|
relationKey: key,
|
|
5291
4573
|
relation,
|
|
5292
|
-
newValue: serializedValue
|
|
4574
|
+
newValue: serializedValue,
|
|
4575
|
+
currentId: row.id || buildCompositeId(row, pks)
|
|
5293
4576
|
});
|
|
5294
4577
|
continue;
|
|
5295
4578
|
} else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
@@ -5299,11 +4582,15 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
5299
4582
|
relation,
|
|
5300
4583
|
newTargetId: serializedValue
|
|
5301
4584
|
});
|
|
5302
|
-
else
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
4585
|
+
else {
|
|
4586
|
+
const pks = getPrimaryKeys(collection, registry);
|
|
4587
|
+
inverseRelationUpdates.push({
|
|
4588
|
+
relationKey: key,
|
|
4589
|
+
relation,
|
|
4590
|
+
newValue: serializedValue,
|
|
4591
|
+
currentId: row.id || buildCompositeId(row, pks)
|
|
4592
|
+
});
|
|
4593
|
+
}
|
|
5307
4594
|
continue;
|
|
5308
4595
|
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
5309
4596
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
@@ -5755,9 +5042,10 @@ var RelationService = class {
|
|
|
5755
5042
|
const rows = [];
|
|
5756
5043
|
for (const row of results) {
|
|
5757
5044
|
const targetRow = row[targetTableName] || row;
|
|
5045
|
+
const id = targetRow[idInfo[0].fieldName];
|
|
5758
5046
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
5759
5047
|
rows.push({
|
|
5760
|
-
id:
|
|
5048
|
+
id: id?.toString() || "",
|
|
5761
5049
|
path: targetCollection.slug,
|
|
5762
5050
|
values: parsedValues
|
|
5763
5051
|
});
|
|
@@ -5779,9 +5067,10 @@ var RelationService = class {
|
|
|
5779
5067
|
const rows = [];
|
|
5780
5068
|
for (const row of results) {
|
|
5781
5069
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
5070
|
+
const id = targetRow[idInfo[0].fieldName];
|
|
5782
5071
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
5783
5072
|
rows.push({
|
|
5784
|
-
id:
|
|
5073
|
+
id: id?.toString() || "",
|
|
5785
5074
|
path: targetCollection.slug,
|
|
5786
5075
|
values: parsedValues
|
|
5787
5076
|
});
|
|
@@ -5821,8 +5110,7 @@ var RelationService = class {
|
|
|
5821
5110
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5822
5111
|
const targetCollection = relation.target();
|
|
5823
5112
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5824
|
-
const
|
|
5825
|
-
const targetIdInfo = targetPks[0];
|
|
5113
|
+
const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
|
|
5826
5114
|
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
5827
5115
|
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
5828
5116
|
const parentIdInfo = parentPks[0];
|
|
@@ -5859,7 +5147,7 @@ var RelationService = class {
|
|
|
5859
5147
|
const parentId = parentRow[parentIdInfo.fieldName];
|
|
5860
5148
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5861
5149
|
resultMap.set(String(parentId), {
|
|
5862
|
-
id:
|
|
5150
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5863
5151
|
path: targetCollection.slug,
|
|
5864
5152
|
values: parsedValues
|
|
5865
5153
|
});
|
|
@@ -5898,7 +5186,7 @@ var RelationService = class {
|
|
|
5898
5186
|
if (targetRow) {
|
|
5899
5187
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5900
5188
|
resultMap.set(parentIdStr, {
|
|
5901
|
-
id:
|
|
5189
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5902
5190
|
path: targetCollection.slug,
|
|
5903
5191
|
values: parsedValues
|
|
5904
5192
|
});
|
|
@@ -5919,7 +5207,7 @@ var RelationService = class {
|
|
|
5919
5207
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
|
|
5920
5208
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5921
5209
|
resultMap.set(String(parentId), {
|
|
5922
|
-
id:
|
|
5210
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5923
5211
|
path: targetCollection.slug,
|
|
5924
5212
|
values: parsedValues
|
|
5925
5213
|
});
|
|
@@ -5937,8 +5225,8 @@ var RelationService = class {
|
|
|
5937
5225
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5938
5226
|
const targetCollection = relation.target();
|
|
5939
5227
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5940
|
-
const
|
|
5941
|
-
const targetIdField = targetTable[
|
|
5228
|
+
const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
|
|
5229
|
+
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
5942
5230
|
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
5943
5231
|
const parentIdInfo = parentPks[0];
|
|
5944
5232
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5973,7 +5261,7 @@ var RelationService = class {
|
|
|
5973
5261
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5974
5262
|
const arr = resultMap.get(parentId) || [];
|
|
5975
5263
|
arr.push({
|
|
5976
|
-
id:
|
|
5264
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5977
5265
|
path: targetCollection.slug,
|
|
5978
5266
|
values: parsedValues
|
|
5979
5267
|
});
|
|
@@ -6003,7 +5291,7 @@ var RelationService = class {
|
|
|
6003
5291
|
const parsedValues = await parseDataFromServer(targetData, targetCollection);
|
|
6004
5292
|
const arr = resultMap.get(parentId) || [];
|
|
6005
5293
|
arr.push({
|
|
6006
|
-
id:
|
|
5294
|
+
id: String(targetData[targetIdInfo.fieldName]),
|
|
6007
5295
|
path: targetCollection.slug,
|
|
6008
5296
|
values: parsedValues
|
|
6009
5297
|
});
|
|
@@ -6027,7 +5315,7 @@ var RelationService = class {
|
|
|
6027
5315
|
const key = String(parentId);
|
|
6028
5316
|
const arr = resultMap.get(key) || [];
|
|
6029
5317
|
arr.push({
|
|
6030
|
-
id:
|
|
5318
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
6031
5319
|
path: targetCollection.slug,
|
|
6032
5320
|
values: parsedValues
|
|
6033
5321
|
});
|
|
@@ -6475,32 +5763,14 @@ var FetchService = class {
|
|
|
6475
5763
|
return null;
|
|
6476
5764
|
}
|
|
6477
5765
|
/**
|
|
6478
|
-
* The address a relation ref points at.
|
|
6479
|
-
*
|
|
6480
|
-
* The whole key, not its first column: a composite-keyed target addressed
|
|
6481
|
-
* by `tenant_id` alone points at every row that shares it. And a target
|
|
6482
|
-
* whose key cannot be resolved at all used to throw here — reading
|
|
6483
|
-
* `targetPks[0]` of an empty array — taking down the parent's fetch over a
|
|
6484
|
-
* relation it may not even have asked for; the first column is a guess, but
|
|
6485
|
-
* a ref that resolves to nothing beats no rows at all.
|
|
6486
|
-
*/
|
|
6487
|
-
relationTargetAddress(targetRow, targetCollection) {
|
|
6488
|
-
const address = deriveRowAddress(targetRow, targetCollection, this.registry);
|
|
6489
|
-
if (address) return address;
|
|
6490
|
-
const firstColumn = targetRow[Object.keys(targetRow)[0]];
|
|
6491
|
-
return String(firstColumn ?? "");
|
|
6492
|
-
}
|
|
6493
|
-
/**
|
|
6494
5766
|
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
6495
5767
|
* Handles:
|
|
5768
|
+
* - Placing `id` at the top level as a string
|
|
6496
5769
|
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
6497
5770
|
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
6498
5771
|
* - Flattening junction-table many-to-many results
|
|
6499
|
-
*
|
|
6500
|
-
* The row's own address is not among them: it is derived by the consumer
|
|
6501
|
-
* from the collection's primary keys.
|
|
6502
5772
|
*/
|
|
6503
|
-
drizzleResultToRow(row, collection) {
|
|
5773
|
+
drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
|
|
6504
5774
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6505
5775
|
const normalizedValues = normalizeDbValues(row, collection);
|
|
6506
5776
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
@@ -6509,13 +5779,14 @@ var FetchService = class {
|
|
|
6509
5779
|
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
6510
5780
|
const targetCollection = relation.target();
|
|
6511
5781
|
const targetPath = targetCollection.slug;
|
|
5782
|
+
const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
|
|
6512
5783
|
normalizedValues[key] = relData.map((item) => {
|
|
6513
5784
|
let targetRow = item;
|
|
6514
5785
|
if (this.isJunctionRelation(relation, collection)) {
|
|
6515
5786
|
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
6516
5787
|
if (nestedKey) targetRow = item[nestedKey];
|
|
6517
5788
|
}
|
|
6518
|
-
const relId =
|
|
5789
|
+
const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
|
|
6519
5790
|
return createRelationRefWithData(relId, targetPath, {
|
|
6520
5791
|
id: relId,
|
|
6521
5792
|
path: targetPath,
|
|
@@ -6525,8 +5796,9 @@ var FetchService = class {
|
|
|
6525
5796
|
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
6526
5797
|
const targetCollection = relation.target();
|
|
6527
5798
|
const targetPath = targetCollection.slug;
|
|
5799
|
+
const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
|
|
6528
5800
|
const relObj = relData;
|
|
6529
|
-
const relId =
|
|
5801
|
+
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
6530
5802
|
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
6531
5803
|
id: relId,
|
|
6532
5804
|
path: targetPath,
|
|
@@ -6534,7 +5806,10 @@ var FetchService = class {
|
|
|
6534
5806
|
});
|
|
6535
5807
|
}
|
|
6536
5808
|
}
|
|
6537
|
-
return
|
|
5809
|
+
return {
|
|
5810
|
+
...normalizedValues,
|
|
5811
|
+
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
5812
|
+
};
|
|
6538
5813
|
}
|
|
6539
5814
|
/**
|
|
6540
5815
|
* Post-fetch joinPath relations for a single flat row.
|
|
@@ -6557,6 +5832,31 @@ var FetchService = class {
|
|
|
6557
5832
|
await Promise.all(promises);
|
|
6558
5833
|
}
|
|
6559
5834
|
/**
|
|
5835
|
+
* Post-fetch joinPath relations for a batch of flat rows.
|
|
5836
|
+
* Uses batch fetching to avoid N+1 queries for list views.
|
|
5837
|
+
*/
|
|
5838
|
+
async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
|
|
5839
|
+
if (rows.length === 0) return;
|
|
5840
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5841
|
+
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
|
|
5842
|
+
if (joinPathRelations.length === 0) return;
|
|
5843
|
+
for (const [key, relation] of joinPathRelations) try {
|
|
5844
|
+
const rowIds = rows.map((r) => {
|
|
5845
|
+
return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
|
|
5846
|
+
});
|
|
5847
|
+
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
5848
|
+
for (const row of rows) {
|
|
5849
|
+
const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
|
|
5850
|
+
const relatedRow = resultMap.get(String(id));
|
|
5851
|
+
if (relatedRow) {
|
|
5852
|
+
if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
5853
|
+
}
|
|
5854
|
+
}
|
|
5855
|
+
} catch (e) {
|
|
5856
|
+
logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
|
|
5857
|
+
}
|
|
5858
|
+
}
|
|
5859
|
+
/**
|
|
6560
5860
|
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
6561
5861
|
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
6562
5862
|
*/
|
|
@@ -6568,48 +5868,67 @@ var FetchService = class {
|
|
|
6568
5868
|
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
|
|
6569
5869
|
if (joinPathRelations.length === 0) return;
|
|
6570
5870
|
const idInfo = idInfoArray[0];
|
|
6571
|
-
const parentIdOf = (row) => row[idInfo.fieldName];
|
|
6572
5871
|
for (const [key, relation] of joinPathRelations) try {
|
|
6573
|
-
const
|
|
6574
|
-
|
|
6575
|
-
|
|
5872
|
+
const rowIds = rows.map((r) => {
|
|
5873
|
+
return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
|
|
5874
|
+
});
|
|
6576
5875
|
if (relation.cardinality === "one") {
|
|
6577
5876
|
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
6578
|
-
for (const row of
|
|
6579
|
-
const
|
|
6580
|
-
|
|
5877
|
+
for (const row of rows) {
|
|
5878
|
+
const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
|
|
5879
|
+
const relatedRow = resultMap.get(String(id));
|
|
5880
|
+
if (relatedRow) row[key] = {
|
|
5881
|
+
...relatedRow.values,
|
|
5882
|
+
id: relatedRow.id
|
|
5883
|
+
};
|
|
5884
|
+
else row[key] = null;
|
|
6581
5885
|
}
|
|
6582
5886
|
} else if (relation.cardinality === "many") {
|
|
6583
5887
|
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
|
|
6584
|
-
for (const row of
|
|
5888
|
+
for (const row of rows) {
|
|
5889
|
+
const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
|
|
5890
|
+
row[key] = (resultMap.get(String(id)) || []).map((e) => ({
|
|
5891
|
+
...e.values,
|
|
5892
|
+
id: e.id
|
|
5893
|
+
}));
|
|
5894
|
+
}
|
|
6585
5895
|
}
|
|
6586
5896
|
} catch (e) {
|
|
6587
5897
|
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
6588
5898
|
}
|
|
6589
5899
|
}
|
|
6590
5900
|
/**
|
|
6591
|
-
* Convert a db.query result row to a flat REST-style
|
|
6592
|
-
*
|
|
6593
|
-
* Every column is copied through under its own name, with the value Postgres
|
|
6594
|
-
* returned. This used to open with a synthesized `id` and then skip the key
|
|
6595
|
-
* column, which renamed it (a `sku` primary key was served as `id`, and `sku`
|
|
6596
|
-
* did not appear at all) and restringified it (`42` → `"42"`). Consumers that
|
|
6597
|
-
* need an address derive it from the collection's primary keys.
|
|
5901
|
+
* Convert a db.query result row to a flat REST-style object with populated relations.
|
|
6598
5902
|
*/
|
|
6599
|
-
drizzleResultToRestRow(row, collection) {
|
|
6600
|
-
const flat = {};
|
|
5903
|
+
drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
|
|
5904
|
+
const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
6601
5905
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6602
5906
|
for (const [k, v] of Object.entries(row)) {
|
|
5907
|
+
if (k === idInfo.fieldName) continue;
|
|
6603
5908
|
const relation = findRelation(resolvedRelations, k);
|
|
6604
5909
|
if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
|
|
6605
5910
|
if (this.isJunctionRelation(relation, collection)) {
|
|
6606
5911
|
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
6607
|
-
if (nestedKey)
|
|
5912
|
+
if (nestedKey) {
|
|
5913
|
+
const nested = item[nestedKey];
|
|
5914
|
+
return {
|
|
5915
|
+
...nested,
|
|
5916
|
+
id: String(nested.id ?? nested[Object.keys(nested)[0]])
|
|
5917
|
+
};
|
|
5918
|
+
}
|
|
6608
5919
|
}
|
|
6609
|
-
return {
|
|
5920
|
+
return {
|
|
5921
|
+
...item,
|
|
5922
|
+
id: String(item.id ?? item[Object.keys(item)[0]])
|
|
5923
|
+
};
|
|
6610
5924
|
});
|
|
6611
|
-
else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation)
|
|
6612
|
-
|
|
5925
|
+
else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
5926
|
+
const relObj = v;
|
|
5927
|
+
flat[k] = {
|
|
5928
|
+
...relObj,
|
|
5929
|
+
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
|
|
5930
|
+
};
|
|
5931
|
+
} else flat[k] = v;
|
|
6613
5932
|
}
|
|
6614
5933
|
return flat;
|
|
6615
5934
|
}
|
|
@@ -6697,7 +6016,7 @@ var FetchService = class {
|
|
|
6697
6016
|
with: withConfig
|
|
6698
6017
|
});
|
|
6699
6018
|
if (!row) return void 0;
|
|
6700
|
-
const flatRow = this.drizzleResultToRow(row, collection);
|
|
6019
|
+
const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
|
|
6701
6020
|
await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
6702
6021
|
return flatRow;
|
|
6703
6022
|
} catch (e) {
|
|
@@ -6749,7 +6068,7 @@ var FetchService = class {
|
|
|
6749
6068
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
6750
6069
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
6751
6070
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
|
|
6752
|
-
return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection));
|
|
6071
|
+
return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray));
|
|
6753
6072
|
} catch (e) {
|
|
6754
6073
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
6755
6074
|
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
@@ -6820,7 +6139,8 @@ var FetchService = class {
|
|
|
6820
6139
|
const parsedRows = await Promise.all(results.map(async (rawRow) => {
|
|
6821
6140
|
return {
|
|
6822
6141
|
rawRow,
|
|
6823
|
-
values: await parseDataFromServer(rawRow, collection)
|
|
6142
|
+
values: await parseDataFromServer(rawRow, collection),
|
|
6143
|
+
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
|
|
6824
6144
|
};
|
|
6825
6145
|
}));
|
|
6826
6146
|
if (!skipRelations) {
|
|
@@ -6860,7 +6180,10 @@ var FetchService = class {
|
|
|
6860
6180
|
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
6861
6181
|
}
|
|
6862
6182
|
}
|
|
6863
|
-
return parsedRows.map((item) =>
|
|
6183
|
+
return parsedRows.map((item) => ({
|
|
6184
|
+
...item.values,
|
|
6185
|
+
id: item.id
|
|
6186
|
+
}));
|
|
6864
6187
|
}
|
|
6865
6188
|
/**
|
|
6866
6189
|
* Fetch a collection of rows
|
|
@@ -6891,7 +6214,10 @@ var FetchService = class {
|
|
|
6891
6214
|
const relationKey = pathSegments[i];
|
|
6892
6215
|
const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
|
|
6893
6216
|
if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
6894
|
-
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
|
|
6217
|
+
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
|
|
6218
|
+
...row.values,
|
|
6219
|
+
id: row.id
|
|
6220
|
+
}));
|
|
6895
6221
|
if (i + 1 < pathSegments.length) {
|
|
6896
6222
|
const nextEntityId = pathSegments[i + 1];
|
|
6897
6223
|
currentCollection = relation.target();
|
|
@@ -6988,7 +6314,7 @@ var FetchService = class {
|
|
|
6988
6314
|
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
6989
6315
|
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
6990
6316
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
|
|
6991
|
-
const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection));
|
|
6317
|
+
const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray));
|
|
6992
6318
|
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
6993
6319
|
return restRows;
|
|
6994
6320
|
} catch (e) {
|
|
@@ -6999,7 +6325,10 @@ var FetchService = class {
|
|
|
6999
6325
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
7000
6326
|
}
|
|
7001
6327
|
const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
|
|
7002
|
-
if (!include || include.length === 0) return rows
|
|
6328
|
+
if (!include || include.length === 0) return rows.map((row) => ({
|
|
6329
|
+
...row,
|
|
6330
|
+
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6331
|
+
}));
|
|
7003
6332
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
7004
6333
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
7005
6334
|
const shouldInclude = (key) => include[0] === "*" || include.includes(key);
|
|
@@ -7011,7 +6340,10 @@ var FetchService = class {
|
|
|
7011
6340
|
for (const row of rows) {
|
|
7012
6341
|
const eid = row[idInfo.fieldName];
|
|
7013
6342
|
const related = batchResults.get(String(eid));
|
|
7014
|
-
if (related) row[key] = {
|
|
6343
|
+
if (related) row[key] = {
|
|
6344
|
+
...related.values,
|
|
6345
|
+
id: related.id
|
|
6346
|
+
};
|
|
7015
6347
|
}
|
|
7016
6348
|
} catch (e) {
|
|
7017
6349
|
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
@@ -7029,7 +6361,10 @@ var FetchService = class {
|
|
|
7029
6361
|
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
7030
6362
|
}
|
|
7031
6363
|
}
|
|
7032
|
-
return rows
|
|
6364
|
+
return rows.map((row) => ({
|
|
6365
|
+
...row,
|
|
6366
|
+
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6367
|
+
}));
|
|
7033
6368
|
}
|
|
7034
6369
|
/**
|
|
7035
6370
|
* Fetch a single row with optional relation includes for REST API.
|
|
@@ -7050,7 +6385,7 @@ var FetchService = class {
|
|
|
7050
6385
|
...withConfig ? { with: withConfig } : {}
|
|
7051
6386
|
});
|
|
7052
6387
|
if (!row) return null;
|
|
7053
|
-
const restRow = this.drizzleResultToRestRow(row, collection);
|
|
6388
|
+
const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
|
|
7054
6389
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
7055
6390
|
return restRow;
|
|
7056
6391
|
} catch (e) {
|
|
@@ -7062,7 +6397,11 @@ var FetchService = class {
|
|
|
7062
6397
|
}
|
|
7063
6398
|
const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
|
|
7064
6399
|
if (result.length === 0) return null;
|
|
7065
|
-
const
|
|
6400
|
+
const raw = result[0];
|
|
6401
|
+
const flatEntity = {
|
|
6402
|
+
...raw,
|
|
6403
|
+
id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
|
|
6404
|
+
};
|
|
7066
6405
|
if (!include || include.length === 0) return flatEntity;
|
|
7067
6406
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
7068
6407
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
@@ -7174,15 +6513,32 @@ var FetchService = class {
|
|
|
7174
6513
|
if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
7175
6514
|
}
|
|
7176
6515
|
return (await queryTarget.findMany(queryOpts)).map((row) => {
|
|
7177
|
-
const flat = {};
|
|
7178
|
-
for (const [k, v] of Object.entries(row))
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
6516
|
+
const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
6517
|
+
for (const [k, v] of Object.entries(row)) {
|
|
6518
|
+
if (k === idInfo.fieldName) continue;
|
|
6519
|
+
if (Array.isArray(v)) flat[k] = v.map((item) => {
|
|
6520
|
+
const keys = Object.keys(item);
|
|
6521
|
+
const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
6522
|
+
if (nestedObj && keys.length <= 3) {
|
|
6523
|
+
const nested = item[nestedObj];
|
|
6524
|
+
return {
|
|
6525
|
+
...nested,
|
|
6526
|
+
id: String(nested.id ?? nested[Object.keys(nested)[0]])
|
|
6527
|
+
};
|
|
6528
|
+
}
|
|
6529
|
+
return {
|
|
6530
|
+
...item,
|
|
6531
|
+
id: String(item.id ?? item[Object.keys(item)[0]])
|
|
6532
|
+
};
|
|
6533
|
+
});
|
|
6534
|
+
else if (typeof v === "object" && v !== null) {
|
|
6535
|
+
const relObj = v;
|
|
6536
|
+
flat[k] = {
|
|
6537
|
+
...relObj,
|
|
6538
|
+
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]])
|
|
6539
|
+
};
|
|
6540
|
+
} else flat[k] = v;
|
|
6541
|
+
}
|
|
7186
6542
|
return flat;
|
|
7187
6543
|
});
|
|
7188
6544
|
} catch (e) {
|
|
@@ -7398,27 +6754,6 @@ var PersistService = class {
|
|
|
7398
6754
|
this.fetchService = new FetchService(db, registry);
|
|
7399
6755
|
}
|
|
7400
6756
|
/**
|
|
7401
|
-
* Explain a write that matched no rows.
|
|
7402
|
-
*
|
|
7403
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
7404
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
7405
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
7406
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
7407
|
-
* the row is untouched.
|
|
7408
|
-
*
|
|
7409
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
7410
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
7411
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
7412
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
7413
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
7414
|
-
*
|
|
7415
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
7416
|
-
*/
|
|
7417
|
-
async explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
|
|
7418
|
-
if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(`Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`, "WRITE_DENIED");
|
|
7419
|
-
return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
|
|
7420
|
-
}
|
|
7421
|
-
/**
|
|
7422
6757
|
* Delete an row by ID
|
|
7423
6758
|
*/
|
|
7424
6759
|
async delete(collectionPath, id, _databaseId) {
|
|
@@ -7432,7 +6767,7 @@ var PersistService = class {
|
|
|
7432
6767
|
if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
7433
6768
|
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
7434
6769
|
}
|
|
7435
|
-
|
|
6770
|
+
await this.db.delete(table).where(and(...conditions));
|
|
7436
6771
|
}
|
|
7437
6772
|
/**
|
|
7438
6773
|
* Delete all rows from a collection
|
|
@@ -7443,14 +6778,8 @@ var PersistService = class {
|
|
|
7443
6778
|
}
|
|
7444
6779
|
/**
|
|
7445
6780
|
* Save an row (create or update)
|
|
7446
|
-
*
|
|
7447
|
-
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
7448
|
-
* UPDATE against the primary key rather than a plain UPDATE. That is one
|
|
7449
|
-
* statement, so it cannot lose a race the way a read-then-write can, and it
|
|
7450
|
-
* does not care whether the row already exists — which is what a re-runnable
|
|
7451
|
-
* import needs.
|
|
7452
6781
|
*/
|
|
7453
|
-
async save(collectionPath, values, id, databaseId
|
|
6782
|
+
async save(collectionPath, values, id, databaseId) {
|
|
7454
6783
|
let effectiveCollectionPath = collectionPath;
|
|
7455
6784
|
const effectiveValues = { ...values };
|
|
7456
6785
|
let junctionTableInfo;
|
|
@@ -7541,7 +6870,7 @@ var PersistService = class {
|
|
|
7541
6870
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
7542
6871
|
savedId = await this.db.transaction(async (tx) => {
|
|
7543
6872
|
let currentId;
|
|
7544
|
-
if (id
|
|
6873
|
+
if (id) {
|
|
7545
6874
|
currentId = id;
|
|
7546
6875
|
const idValues = parseIdValues(id, idInfoArray);
|
|
7547
6876
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
@@ -7552,28 +6881,13 @@ var PersistService = class {
|
|
|
7552
6881
|
const field = table[info.fieldName];
|
|
7553
6882
|
conditions.push(eq(field, idValues[info.fieldName]));
|
|
7554
6883
|
}
|
|
7555
|
-
|
|
6884
|
+
await updateQuery.where(and(...conditions));
|
|
7556
6885
|
}
|
|
7557
6886
|
} else {
|
|
7558
6887
|
const dataForInsert = { ...entityData };
|
|
7559
|
-
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
7560
6888
|
for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
|
|
7561
|
-
const
|
|
7562
|
-
|
|
7563
|
-
let result;
|
|
7564
|
-
if (options?.upsert && hasFullKey) {
|
|
7565
|
-
const target = idInfoArray.map((info) => table[info.fieldName]);
|
|
7566
|
-
const set = { ...dataForInsert };
|
|
7567
|
-
for (const info of idInfoArray) delete set[info.fieldName];
|
|
7568
|
-
result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
|
|
7569
|
-
target,
|
|
7570
|
-
set
|
|
7571
|
-
}).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
|
|
7572
|
-
} else result = await insertQuery.returning(returningKeys);
|
|
7573
|
-
const resultRow = result[0];
|
|
7574
|
-
if (!resultRow) if (id) currentId = id;
|
|
7575
|
-
else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
|
|
7576
|
-
else currentId = buildCompositeId(resultRow, idInfoArray);
|
|
6889
|
+
const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
|
|
6890
|
+
currentId = buildCompositeId(resultRow, idInfoArray);
|
|
7577
6891
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
7578
6892
|
}
|
|
7579
6893
|
if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
@@ -7604,7 +6918,6 @@ var PersistService = class {
|
|
|
7604
6918
|
* Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
|
|
7605
6919
|
*/
|
|
7606
6920
|
toUserFriendlyError(error, collectionSlug) {
|
|
7607
|
-
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
7608
6921
|
const pgError = extractPgError(error);
|
|
7609
6922
|
if (pgError) {
|
|
7610
6923
|
const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
@@ -7682,8 +6995,8 @@ var DataService = class {
|
|
|
7682
6995
|
/**
|
|
7683
6996
|
* Save an row (create or update)
|
|
7684
6997
|
*/
|
|
7685
|
-
async save(collectionPath, values, id, databaseId
|
|
7686
|
-
return this.persistService.save(collectionPath, values, id, databaseId
|
|
6998
|
+
async save(collectionPath, values, id, databaseId) {
|
|
6999
|
+
return this.persistService.save(collectionPath, values, id, databaseId);
|
|
7687
7000
|
}
|
|
7688
7001
|
/**
|
|
7689
7002
|
* Delete an row by ID
|
|
@@ -7751,26 +7064,6 @@ var DataService = class {
|
|
|
7751
7064
|
*/
|
|
7752
7065
|
/** Internal prefix applied to branch database names to avoid collisions. */
|
|
7753
7066
|
var BRANCH_DB_PREFIX = "rb_";
|
|
7754
|
-
/** `duplicate_database` — the target database name is already taken. */
|
|
7755
|
-
var PG_DUPLICATE_DATABASE = "42P04";
|
|
7756
|
-
/** `object_in_use` — the database still has connections attached. */
|
|
7757
|
-
var PG_OBJECT_IN_USE = "55006";
|
|
7758
|
-
/**
|
|
7759
|
-
* Describe a failed branch DDL statement in terms a user can act on.
|
|
7760
|
-
*
|
|
7761
|
-
* Drizzle reports failures as `Failed query: <sql> params:` and hides the real
|
|
7762
|
-
* PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
|
|
7763
|
-
* the actual problem. Match on the PG error code instead — it survives wrapping
|
|
7764
|
-
* and, unlike the message text, is not locale-dependent.
|
|
7765
|
-
*/
|
|
7766
|
-
function describeBranchDdlError(err, fallbackContext) {
|
|
7767
|
-
const pgError = extractPgError(err);
|
|
7768
|
-
if (pgError?.code === PG_DUPLICATE_DATABASE) return /* @__PURE__ */ new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
|
|
7769
|
-
if (pgError?.code === PG_OBJECT_IN_USE) return /* @__PURE__ */ new Error(`Cannot complete the operation: the database "${fallbackContext}" has active connections. Close other clients or connections and try again.`);
|
|
7770
|
-
const detail = pgError?.message ?? extractCauseMessage(err);
|
|
7771
|
-
if (detail) return new Error(detail);
|
|
7772
|
-
return err instanceof Error ? err : new Error(String(err));
|
|
7773
|
-
}
|
|
7774
7067
|
/** Fully-qualified metadata table in the rebase schema. */
|
|
7775
7068
|
var BRANCHES_TABLE = "rebase.branches";
|
|
7776
7069
|
/**
|
|
@@ -7839,8 +7132,10 @@ var BranchService = class {
|
|
|
7839
7132
|
try {
|
|
7840
7133
|
await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
|
|
7841
7134
|
} catch (err) {
|
|
7842
|
-
|
|
7843
|
-
throw
|
|
7135
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7136
|
+
if (msg.includes("already exists")) throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
|
|
7137
|
+
if (msg.includes("being accessed by other users")) throw new Error(`Cannot create branch: the source database "${sourceDb}" has active connections. Close other clients or connections and try again.`);
|
|
7138
|
+
throw err;
|
|
7844
7139
|
}
|
|
7845
7140
|
const now = /* @__PURE__ */ new Date();
|
|
7846
7141
|
await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
@@ -7865,8 +7160,8 @@ var BranchService = class {
|
|
|
7865
7160
|
try {
|
|
7866
7161
|
await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
|
|
7867
7162
|
} catch (err) {
|
|
7868
|
-
if (
|
|
7869
|
-
throw
|
|
7163
|
+
if ((err instanceof Error ? err.message : String(err)).includes("being accessed by other users")) throw new Error(`Cannot delete branch "${sanitizedName}": the database has active connections. Close other clients and try again.`);
|
|
7164
|
+
throw err;
|
|
7870
7165
|
}
|
|
7871
7166
|
await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
|
|
7872
7167
|
}
|
|
@@ -8498,7 +7793,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8498
7793
|
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
8499
7794
|
};
|
|
8500
7795
|
}
|
|
8501
|
-
async save({ path, id, values, collection, status
|
|
7796
|
+
async save({ path, id, values, collection, status }) {
|
|
8502
7797
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
8503
7798
|
let updatedValues = values;
|
|
8504
7799
|
const contextForCallback = this.buildCallContext();
|
|
@@ -8555,7 +7850,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8555
7850
|
timestampNowValue: /* @__PURE__ */ new Date()
|
|
8556
7851
|
});
|
|
8557
7852
|
try {
|
|
8558
|
-
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId
|
|
7853
|
+
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
|
|
8559
7854
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
8560
7855
|
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
8561
7856
|
collection: resolvedCollection,
|
|
@@ -8576,8 +7871,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8576
7871
|
context: contextForCallback
|
|
8577
7872
|
});
|
|
8578
7873
|
}
|
|
8579
|
-
const savedId =
|
|
8580
|
-
const savedValues = savedRow;
|
|
7874
|
+
const savedId = savedRow.id;
|
|
7875
|
+
const { id: _savedId, ...savedValues } = savedRow;
|
|
8581
7876
|
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
8582
7877
|
if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
|
|
8583
7878
|
collection: resolvedCollection,
|
|
@@ -8609,7 +7904,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8609
7904
|
}
|
|
8610
7905
|
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
8611
7906
|
tableName: path,
|
|
8612
|
-
id: savedId,
|
|
7907
|
+
id: savedId.toString(),
|
|
8613
7908
|
action: status === "new" ? "create" : "update",
|
|
8614
7909
|
values: savedValues,
|
|
8615
7910
|
previousValues: previousValuesForHistory,
|
|
@@ -8617,11 +7912,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8617
7912
|
});
|
|
8618
7913
|
if (this._deferNotifications) this._pendingNotifications.push({
|
|
8619
7914
|
path,
|
|
8620
|
-
id: savedId,
|
|
7915
|
+
id: savedId.toString(),
|
|
8621
7916
|
row: savedRow,
|
|
8622
7917
|
databaseId: resolvedCollection?.databaseId
|
|
8623
7918
|
});
|
|
8624
|
-
else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
|
|
7919
|
+
else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
|
|
8625
7920
|
return savedRow;
|
|
8626
7921
|
} catch (error) {
|
|
8627
7922
|
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
@@ -8656,52 +7951,12 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8656
7951
|
throw error;
|
|
8657
7952
|
}
|
|
8658
7953
|
}
|
|
8659
|
-
/**
|
|
8660
|
-
* Write many rows through the same pipeline as {@link save}.
|
|
8661
|
-
*
|
|
8662
|
-
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
8663
|
-
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
8664
|
-
* state. When this driver is already inside a transaction (the authenticated
|
|
8665
|
-
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
8666
|
-
* still atomic and still commits once.
|
|
8667
|
-
*
|
|
8668
|
-
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
8669
|
-
* with the last write winning, exactly as separate calls would.
|
|
8670
|
-
*/
|
|
8671
|
-
async saveMany({ path, rows, collection, upsert }) {
|
|
8672
|
-
return this.db.transaction(async (tx) => {
|
|
8673
|
-
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
8674
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
8675
|
-
txDriver.client = this.client;
|
|
8676
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
8677
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
8678
|
-
const saved = [];
|
|
8679
|
-
for (let i = 0; i < rows.length; i++) {
|
|
8680
|
-
const values = rows[i];
|
|
8681
|
-
const id = values?.id;
|
|
8682
|
-
try {
|
|
8683
|
-
saved.push(await txDriver.save({
|
|
8684
|
-
path,
|
|
8685
|
-
values,
|
|
8686
|
-
collection,
|
|
8687
|
-
status: "new",
|
|
8688
|
-
upsert
|
|
8689
|
-
}));
|
|
8690
|
-
} catch (error) {
|
|
8691
|
-
const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
|
|
8692
|
-
throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
8693
|
-
statusCode: error?.statusCode,
|
|
8694
|
-
code: error?.code,
|
|
8695
|
-
name: error?.name
|
|
8696
|
-
});
|
|
8697
|
-
}
|
|
8698
|
-
}
|
|
8699
|
-
return saved;
|
|
8700
|
-
});
|
|
8701
|
-
}
|
|
8702
7954
|
async delete({ row, collection }) {
|
|
8703
7955
|
const targetPath = row.path;
|
|
8704
|
-
const targetRow = {
|
|
7956
|
+
const targetRow = {
|
|
7957
|
+
id: row.id,
|
|
7958
|
+
...row.values ?? {}
|
|
7959
|
+
};
|
|
8705
7960
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
|
|
8706
7961
|
const contextForCallback = this.buildCallContext();
|
|
8707
7962
|
if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
|
|
@@ -9060,18 +8315,6 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
9060
8315
|
async save(props) {
|
|
9061
8316
|
return this.withTransaction((delegate) => delegate.save(props));
|
|
9062
8317
|
}
|
|
9063
|
-
/**
|
|
9064
|
-
* One transaction for the whole batch, rather than one per row.
|
|
9065
|
-
*
|
|
9066
|
-
* This is the point of the method: `save` opens a transaction per call, so
|
|
9067
|
-
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
9068
|
-
* round trips). Here the RLS context is established once and every row lands
|
|
9069
|
-
* or none does. Realtime notifications are already deferred to commit by
|
|
9070
|
-
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
9071
|
-
*/
|
|
9072
|
-
async saveMany(props) {
|
|
9073
|
-
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
9074
|
-
}
|
|
9075
8318
|
async delete(props) {
|
|
9076
8319
|
return this.withTransaction((delegate) => delegate.delete(props));
|
|
9077
8320
|
}
|
|
@@ -9318,6 +8561,105 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
|
|
|
9318
8561
|
references: [users.id]
|
|
9319
8562
|
}) }));
|
|
9320
8563
|
//#endregion
|
|
8564
|
+
//#region src/schema/auth-default-policies.ts
|
|
8565
|
+
/**
|
|
8566
|
+
* Default RLS policies injected by the schema generator.
|
|
8567
|
+
*
|
|
8568
|
+
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
8569
|
+
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
8570
|
+
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
8571
|
+
* authorization model. The server context (auth flows, migrations,
|
|
8572
|
+
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
8573
|
+
*
|
|
8574
|
+
* Because RLS default-denies, every collection is **locked by default**: with
|
|
8575
|
+
* no rules, only the server context and admins can touch it. The generator
|
|
8576
|
+
* injects that safe baseline:
|
|
8577
|
+
*
|
|
8578
|
+
* **For every collection**
|
|
8579
|
+
* 1. A permissive **server-or-admin SELECT** grant.
|
|
8580
|
+
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
8581
|
+
*
|
|
8582
|
+
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
8583
|
+
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
8584
|
+
* rows").
|
|
8585
|
+
*
|
|
8586
|
+
* **For auth collections additionally**
|
|
8587
|
+
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
8588
|
+
* their own row (profile, session bootstrap) without every app re-declaring
|
|
8589
|
+
* it.
|
|
8590
|
+
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
8591
|
+
* every other policy, so a write is rejected unless the caller is an admin
|
|
8592
|
+
* (or the server context) — even if the author also wrote a permissive rule
|
|
8593
|
+
* such as "a user may edit their own row". Without this, a permissive owner
|
|
8594
|
+
* rule would let a user change their own `roles`.
|
|
8595
|
+
*
|
|
8596
|
+
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
8597
|
+
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
8598
|
+
* GUC — which also lets the owner connection satisfy these policies even under
|
|
8599
|
+
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
8600
|
+
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
8601
|
+
*
|
|
8602
|
+
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
8603
|
+
* the collection's RLS.
|
|
8604
|
+
*/
|
|
8605
|
+
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
8606
|
+
/** Write operations that must be admin-gated by default on auth collections. */
|
|
8607
|
+
var DEFAULT_GUARDED_OPS = [
|
|
8608
|
+
"insert",
|
|
8609
|
+
"update",
|
|
8610
|
+
"delete"
|
|
8611
|
+
];
|
|
8612
|
+
/** Whether a collection is flagged as an authentication collection. */
|
|
8613
|
+
function isAuthCollection(collection) {
|
|
8614
|
+
const auth = collection.auth;
|
|
8615
|
+
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
8616
|
+
}
|
|
8617
|
+
/** The property marked as the row id (falls back to `id`). */
|
|
8618
|
+
function getIdPropertyName(collection) {
|
|
8619
|
+
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
8620
|
+
return "id";
|
|
8621
|
+
}
|
|
8622
|
+
/**
|
|
8623
|
+
* Returns the security rules that should be applied to a collection: the
|
|
8624
|
+
* author's explicit `securityRules` plus the framework defaults described in
|
|
8625
|
+
* the module doc (baseline server/admin read for all collections; self-read
|
|
8626
|
+
* and the admin write gate for auth collections).
|
|
8627
|
+
*
|
|
8628
|
+
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
8629
|
+
*/
|
|
8630
|
+
function getEffectiveSecurityRules(collection) {
|
|
8631
|
+
const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
|
|
8632
|
+
if (collection.disableDefaultPolicies) return explicit;
|
|
8633
|
+
const tableName = getTableName$1(collection);
|
|
8634
|
+
const injected = [];
|
|
8635
|
+
injected.push({
|
|
8636
|
+
name: `${tableName}_default_admin_read`,
|
|
8637
|
+
operations: ["select"],
|
|
8638
|
+
condition: SERVER_OR_ADMIN_EXPR
|
|
8639
|
+
});
|
|
8640
|
+
injected.push({
|
|
8641
|
+
name: `${tableName}_default_admin_write`,
|
|
8642
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
8643
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
8644
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
8645
|
+
});
|
|
8646
|
+
if (isAuthCollection(collection)) {
|
|
8647
|
+
injected.push({
|
|
8648
|
+
name: `${tableName}_default_self_read`,
|
|
8649
|
+
operations: ["select"],
|
|
8650
|
+
condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
|
|
8651
|
+
});
|
|
8652
|
+
injected.push({
|
|
8653
|
+
name: `${tableName}_require_admin_write`,
|
|
8654
|
+
mode: "restrictive",
|
|
8655
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
8656
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
8657
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
8658
|
+
});
|
|
8659
|
+
}
|
|
8660
|
+
return [...explicit, ...injected];
|
|
8661
|
+
}
|
|
8662
|
+
//#endregion
|
|
9321
8663
|
//#region src/schema/generate-drizzle-schema-logic.ts
|
|
9322
8664
|
/**
|
|
9323
8665
|
* Resolve the SQL column name for a property.
|
|
@@ -9515,12 +8857,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
9515
8857
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
9516
8858
|
*/
|
|
9517
8859
|
var wrapSql = (clause) => `sql\`${clause}\``;
|
|
8860
|
+
/**
|
|
8861
|
+
* Generates a deterministic hash based on the rule configuration.
|
|
8862
|
+
*/
|
|
8863
|
+
var getPolicyNameHash = (rule) => {
|
|
8864
|
+
const data = JSON.stringify({
|
|
8865
|
+
a: rule.access,
|
|
8866
|
+
m: rule.mode,
|
|
8867
|
+
op: rule.operation,
|
|
8868
|
+
ops: rule.operations?.slice().sort(),
|
|
8869
|
+
own: rule.ownerField,
|
|
8870
|
+
rol: rule.roles?.slice().sort(),
|
|
8871
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
8872
|
+
u: rule.using,
|
|
8873
|
+
w: rule.withCheck,
|
|
8874
|
+
c: rule.condition,
|
|
8875
|
+
ch: rule.check
|
|
8876
|
+
});
|
|
8877
|
+
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
8878
|
+
};
|
|
9518
8879
|
var generatePolicyCode = (collection, rule, index, resolveCollection) => {
|
|
9519
8880
|
const tableName = getTableName$1(collection);
|
|
9520
8881
|
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
9521
|
-
const
|
|
8882
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
9522
8883
|
return ops.map((op, opIdx) => {
|
|
9523
|
-
return generateSinglePolicyCode(collection, rule, op,
|
|
8884
|
+
return generateSinglePolicyCode(collection, rule, op, rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`, resolveCollection);
|
|
9524
8885
|
}).join("");
|
|
9525
8886
|
};
|
|
9526
8887
|
/**
|
|
@@ -9649,7 +9010,6 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9649
9010
|
});
|
|
9650
9011
|
});
|
|
9651
9012
|
schemaContent += "\n";
|
|
9652
|
-
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
9653
9013
|
for (const collection of collections) {
|
|
9654
9014
|
const tableName = getTableName$1(collection);
|
|
9655
9015
|
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
@@ -9683,17 +9043,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9683
9043
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
9684
9044
|
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
9685
9045
|
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
9686
|
-
schemaContent += "}, (table) => (
|
|
9687
|
-
schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })
|
|
9688
|
-
|
|
9689
|
-
if (!stripPolicies && junctionSpec) {
|
|
9690
|
-
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
9691
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
9692
|
-
getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
|
|
9693
|
-
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
9694
|
-
});
|
|
9695
|
-
}
|
|
9696
|
-
schemaContent += "])).enableRLS();\n\n";
|
|
9046
|
+
schemaContent += "}, (table) => ({\n";
|
|
9047
|
+
schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
|
|
9048
|
+
schemaContent += "}));\n\n";
|
|
9697
9049
|
} else if (!isJunction) {
|
|
9698
9050
|
const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
|
|
9699
9051
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
@@ -10384,7 +9736,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10384
9736
|
startAfter: request.startAfter,
|
|
10385
9737
|
searchString: request.searchString
|
|
10386
9738
|
}, authContext);
|
|
10387
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows
|
|
9739
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows);
|
|
10388
9740
|
} catch (error) {
|
|
10389
9741
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
10390
9742
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -10485,7 +9837,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10485
9837
|
if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
10486
9838
|
else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
|
|
10487
9839
|
else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
10488
|
-
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row
|
|
9840
|
+
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
|
|
10489
9841
|
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
10490
9842
|
}
|
|
10491
9843
|
} catch (error) {
|
|
@@ -10515,7 +9867,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10515
9867
|
if (!this._subscriptions.has(subscriptionId)) return;
|
|
10516
9868
|
try {
|
|
10517
9869
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
|
|
10518
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows
|
|
9870
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
|
|
10519
9871
|
} catch (error) {
|
|
10520
9872
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
10521
9873
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -10729,12 +10081,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10729
10081
|
}
|
|
10730
10082
|
return await this.dataService.fetchOne(notifyPath, id);
|
|
10731
10083
|
}
|
|
10732
|
-
sendCollectionUpdate(clientId, subscriptionId, rows
|
|
10084
|
+
sendCollectionUpdate(clientId, subscriptionId, rows) {
|
|
10733
10085
|
const message = {
|
|
10734
10086
|
type: "collection_update",
|
|
10735
10087
|
subscriptionId,
|
|
10736
|
-
rows
|
|
10737
|
-
pks: this.primaryKeysForPath(path)
|
|
10088
|
+
rows
|
|
10738
10089
|
};
|
|
10739
10090
|
this.sendMessage(clientId, message);
|
|
10740
10091
|
}
|
|
@@ -10749,33 +10100,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10749
10100
|
/**
|
|
10750
10101
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
10751
10102
|
* The client can merge this into its cached data for instant feedback.
|
|
10752
|
-
*
|
|
10753
|
-
* The key columns ride along: the patch names a row by address, and the
|
|
10754
|
-
* client has to find that row among the ones it cached — which carry
|
|
10755
|
-
* columns and no address. The SDK holds no collection config to derive one
|
|
10756
|
-
* from, so this is the only place the mapping can come from.
|
|
10757
10103
|
*/
|
|
10758
|
-
sendCollectionPatch(clientId, subscriptionId, id, row
|
|
10104
|
+
sendCollectionPatch(clientId, subscriptionId, id, row) {
|
|
10759
10105
|
const message = {
|
|
10760
10106
|
type: "collection_patch",
|
|
10761
10107
|
subscriptionId,
|
|
10762
10108
|
id,
|
|
10763
|
-
row
|
|
10764
|
-
pks: this.primaryKeysForPath(notifyPath)
|
|
10109
|
+
row
|
|
10765
10110
|
};
|
|
10766
10111
|
this.sendMessage(clientId, message);
|
|
10767
10112
|
}
|
|
10768
|
-
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
10769
|
-
primaryKeysForPath(path) {
|
|
10770
|
-
try {
|
|
10771
|
-
const collection = this.registry.getCollectionByPath(path);
|
|
10772
|
-
if (!collection) return void 0;
|
|
10773
|
-
const keys = getPrimaryKeys(collection, this.registry);
|
|
10774
|
-
return keys.length > 0 ? keys : void 0;
|
|
10775
|
-
} catch {
|
|
10776
|
-
return;
|
|
10777
|
-
}
|
|
10778
|
-
}
|
|
10779
10113
|
sendError(clientId, error, subscriptionId, code) {
|
|
10780
10114
|
const message = {
|
|
10781
10115
|
type: "error",
|
|
@@ -11023,7 +10357,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11023
10357
|
}
|
|
11024
10358
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
11025
10359
|
extractIdFromCdcRow(collection, row) {
|
|
11026
|
-
|
|
10360
|
+
try {
|
|
10361
|
+
const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
|
|
10362
|
+
if (composite && composite !== ":::") return composite;
|
|
10363
|
+
} catch {}
|
|
10364
|
+
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
10365
|
+
return "*";
|
|
11027
10366
|
}
|
|
11028
10367
|
dedupKey(path, id, databaseId) {
|
|
11029
10368
|
return `${databaseId ?? ""}::${path}::${id}`;
|
|
@@ -11639,7 +10978,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11639
10978
|
} catch (error) {
|
|
11640
10979
|
logger.error("💥 [WebSocket Server] Error handling message", { error });
|
|
11641
10980
|
if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
|
|
11642
|
-
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" :
|
|
10981
|
+
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : error instanceof Error ? error.message : "An unexpected error occurred";
|
|
11643
10982
|
const errorResponse = {
|
|
11644
10983
|
type: "ERROR",
|
|
11645
10984
|
requestId,
|
|
@@ -20483,33 +19822,6 @@ function formatBytes(bytes) {
|
|
|
20483
19822
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
20484
19823
|
}
|
|
20485
19824
|
//#endregion
|
|
20486
|
-
//#region src/collections/buildRegistry.ts
|
|
20487
|
-
/**
|
|
20488
|
-
* Build the collection registry for a driver.
|
|
20489
|
-
*
|
|
20490
|
-
* The order matters and is the reason this is one function rather than a run of
|
|
20491
|
-
* statements in the bootstrapper. Keys are resolved from the drizzle schema, so
|
|
20492
|
-
* anything that inspects them has to run *after* the tables are registered —
|
|
20493
|
-
* and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
|
|
20494
|
-
* collection whose table it cannot look up is one it has nothing to say about.
|
|
20495
|
-
* Warned too early, it would skip every collection and report nothing, which
|
|
20496
|
-
* reads exactly like having nothing to report.
|
|
20497
|
-
*/
|
|
20498
|
-
function buildCollectionRegistry(schema) {
|
|
20499
|
-
const registry = new PostgresCollectionRegistry();
|
|
20500
|
-
if (schema.collections) {
|
|
20501
|
-
registry.registerMultiple(schema.collections);
|
|
20502
|
-
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
20503
|
-
}
|
|
20504
|
-
if (schema.tables) Object.values(schema.tables).forEach((table) => {
|
|
20505
|
-
if (isTable(table)) registry.registerTable(table, getTableName(table));
|
|
20506
|
-
});
|
|
20507
|
-
if (schema.enums) registry.registerEnums(schema.enums);
|
|
20508
|
-
if (schema.relations) registry.registerRelations(schema.relations);
|
|
20509
|
-
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
20510
|
-
return registry;
|
|
20511
|
-
}
|
|
20512
|
-
//#endregion
|
|
20513
19825
|
//#region src/auth/ensure-tables.ts
|
|
20514
19826
|
/**
|
|
20515
19827
|
* Auto-create auth tables if they don't exist.
|
|
@@ -22492,14 +21804,21 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22492
21804
|
logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
|
|
22493
21805
|
}
|
|
22494
21806
|
const activeCollections = introspectedCollections ?? collections;
|
|
21807
|
+
const registry = new PostgresCollectionRegistry();
|
|
21808
|
+
if (activeCollections) {
|
|
21809
|
+
registry.registerMultiple(activeCollections);
|
|
21810
|
+
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
21811
|
+
}
|
|
22495
21812
|
const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
|
|
22496
|
-
|
|
22497
|
-
|
|
22498
|
-
|
|
22499
|
-
|
|
22500
|
-
|
|
22501
|
-
relations: schemaRelations
|
|
21813
|
+
if (schemaTables) Object.values(schemaTables).forEach((table) => {
|
|
21814
|
+
if (isTable(table)) {
|
|
21815
|
+
const tableName = getTableName(table);
|
|
21816
|
+
registry.registerTable(table, tableName);
|
|
21817
|
+
}
|
|
22502
21818
|
});
|
|
21819
|
+
if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
|
|
21820
|
+
const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
|
|
21821
|
+
if (schemaRelations) registry.registerRelations(schemaRelations);
|
|
22503
21822
|
if (schemaTables) patchPgArrayNullSafety(schemaTables);
|
|
22504
21823
|
const mergedSchema = {
|
|
22505
21824
|
...schemaTables,
|