@rebasepro/server-postgres 0.9.1-canary.ad25bc0 → 0.9.1-canary.ad870eb
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/index.es.js +275 -656
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/services/FetchService.d.ts +2 -10
- package/dist/services/PersistService.d.ts +1 -27
- package/dist/services/collection-helpers.d.ts +15 -4
- package/dist/services/dataService.d.ts +1 -3
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +2 -88
- package/src/schema/auth-default-policies.ts +132 -0
- package/src/schema/generate-drizzle-schema-logic.ts +26 -4
- package/src/schema/generate-postgres-ddl-logic.ts +26 -14
- package/src/services/BranchService.ts +10 -42
- package/src/services/FetchService.ts +67 -41
- package/src/services/PersistService.ts +12 -121
- package/src/services/collection-helpers.ts +55 -9
- package/src/services/dataService.ts +2 -3
- package/src/websocket.ts +1 -4
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,98 +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
|
-
* The driver can also infer keys from the Drizzle schema, which the browser
|
|
1812
|
-
* cannot see — so the server normalizes what it resolved onto the config it
|
|
1813
|
-
* serves (see `stampPrimaryKeys`), and this reads that back. Returns an empty
|
|
1814
|
-
* array when a collection declares none, which callers must treat as "not
|
|
1815
|
-
* addressable" rather than defaulting to `id`: guessing a key that is not the
|
|
1816
|
-
* real one produces confidently wrong addresses.
|
|
1817
|
-
*/
|
|
1818
|
-
function getDeclaredPrimaryKeys(collection) {
|
|
1819
|
-
const properties = collection.properties;
|
|
1820
|
-
if (!properties) return [];
|
|
1821
|
-
const keys = [];
|
|
1822
|
-
for (const [fieldName, propRaw] of Object.entries(properties)) {
|
|
1823
|
-
const prop = propRaw;
|
|
1824
|
-
if (!prop || typeof prop !== "object") continue;
|
|
1825
|
-
if (!("isId" in prop) || !prop.isId) continue;
|
|
1826
|
-
keys.push({
|
|
1827
|
-
fieldName,
|
|
1828
|
-
type: prop.type === "number" ? "number" : "string",
|
|
1829
|
-
isUUID: prop.isId === "uuid"
|
|
1830
|
-
});
|
|
1831
|
-
}
|
|
1832
|
-
return keys;
|
|
1833
|
-
}
|
|
1834
|
-
/**
|
|
1835
|
-
* The keys to address a collection's rows with, resolved the way the driver
|
|
1836
|
-
* resolves them — minus the tier the browser cannot reach.
|
|
1837
|
-
*
|
|
1838
|
-
* The postgres driver tries, in order: properties marked `isId`; the primary
|
|
1839
|
-
* keys of the Drizzle schema; and finally a column literally named `id`. Only
|
|
1840
|
-
* the first and last are visible in a `CollectionConfig`, which is what both
|
|
1841
|
-
* sides share — so a collection whose key is *only* known to Drizzle, and is
|
|
1842
|
-
* not named `id`, resolves to nothing here. That is reported rather than
|
|
1843
|
-
* guessed: inventing a key produces addresses that look right and route wrong.
|
|
1844
|
-
*/
|
|
1845
|
-
function resolvePrimaryKeys(collection) {
|
|
1846
|
-
const declared = getDeclaredPrimaryKeys(collection);
|
|
1847
|
-
if (declared.length > 0) return declared;
|
|
1848
|
-
const idProp = collection.properties?.id;
|
|
1849
|
-
if (idProp && typeof idProp === "object") return [{
|
|
1850
|
-
fieldName: "id",
|
|
1851
|
-
type: idProp.type === "number" ? "number" : "string"
|
|
1852
|
-
}];
|
|
1853
|
-
return [];
|
|
1854
|
-
}
|
|
1855
1629
|
//#endregion
|
|
1856
1630
|
//#region ../common/src/util/enums.ts
|
|
1857
1631
|
function enumToObjectEntries(enumValues) {
|
|
@@ -2086,109 +1860,15 @@ function getSubcollections(collection) {
|
|
|
2086
1860
|
* - `field = 'literal'`
|
|
2087
1861
|
* - `field != 'literal'`
|
|
2088
1862
|
* - `field = current_setting('app.user_id')`
|
|
2089
|
-
* - `A AND B
|
|
1863
|
+
* - `A AND B`
|
|
2090
1864
|
* - `true`
|
|
2091
1865
|
* - `IN (...)` (as optimistic true)
|
|
2092
1866
|
*
|
|
2093
1867
|
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
2094
1868
|
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
2095
|
-
*
|
|
2096
|
-
* **This output also round-trips back into DDL** via `policyToPostgres` (the
|
|
2097
|
-
* schema/policy generators), so decomposing a clause the parser only partly
|
|
2098
|
-
* understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
|
|
2099
|
-
* prefer `raw`: it is reproduced verbatim.
|
|
2100
|
-
*/
|
|
2101
|
-
/** True when `keyword` starts at `i` as a standalone word. */
|
|
2102
|
-
function isKeywordAt(upper, i, keyword) {
|
|
2103
|
-
if (!upper.startsWith(keyword, i)) return false;
|
|
2104
|
-
const before = i === 0 ? " " : upper[i - 1];
|
|
2105
|
-
const after = upper[i + keyword.length] ?? " ";
|
|
2106
|
-
return /[\s()]/.test(before) && /[\s()]/.test(after);
|
|
2107
|
-
}
|
|
2108
|
-
/**
|
|
2109
|
-
* Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
|
|
2110
|
-
* outside a string literal. Returns null when it never does, so the caller
|
|
2111
|
-
* leaves the clause alone.
|
|
2112
|
-
*
|
|
2113
|
-
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
2114
|
-
* `AND` inside
|
|
2115
|
-
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
|
|
2116
|
-
* split the expression, and re-emitting the halves produced
|
|
2117
|
-
* `(EXISTS (...) AND m.user_id = auth.uid())`
|
|
2118
|
-
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
2119
|
-
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
2120
|
-
* clause as a `raw` expression, which round-trips verbatim.
|
|
2121
1869
|
*/
|
|
2122
|
-
function splitTopLevel(sql, keyword) {
|
|
2123
|
-
const upper = sql.toUpperCase();
|
|
2124
|
-
const parts = [];
|
|
2125
|
-
let depth = 0;
|
|
2126
|
-
let inString = false;
|
|
2127
|
-
let start = 0;
|
|
2128
|
-
for (let i = 0; i < sql.length; i++) {
|
|
2129
|
-
const ch = sql[i];
|
|
2130
|
-
if (inString) {
|
|
2131
|
-
if (ch === "'") if (sql[i + 1] === "'") i++;
|
|
2132
|
-
else inString = false;
|
|
2133
|
-
continue;
|
|
2134
|
-
}
|
|
2135
|
-
if (ch === "'") {
|
|
2136
|
-
inString = true;
|
|
2137
|
-
continue;
|
|
2138
|
-
}
|
|
2139
|
-
if (ch === "(") {
|
|
2140
|
-
depth++;
|
|
2141
|
-
continue;
|
|
2142
|
-
}
|
|
2143
|
-
if (ch === ")") {
|
|
2144
|
-
depth--;
|
|
2145
|
-
continue;
|
|
2146
|
-
}
|
|
2147
|
-
if (depth === 0 && isKeywordAt(upper, i, keyword)) {
|
|
2148
|
-
parts.push(sql.slice(start, i));
|
|
2149
|
-
i += keyword.length - 1;
|
|
2150
|
-
start = i + 1;
|
|
2151
|
-
}
|
|
2152
|
-
}
|
|
2153
|
-
if (parts.length === 0) return null;
|
|
2154
|
-
parts.push(sql.slice(start));
|
|
2155
|
-
const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
2156
|
-
return trimmedParts.length > 1 ? trimmedParts : null;
|
|
2157
|
-
}
|
|
2158
|
-
/** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
|
|
2159
|
-
function stripOuterParens(sql) {
|
|
2160
|
-
let s = sql.trim();
|
|
2161
|
-
for (;;) {
|
|
2162
|
-
if (!s.startsWith("(") || !s.endsWith(")")) return s;
|
|
2163
|
-
let depth = 0;
|
|
2164
|
-
let inString = false;
|
|
2165
|
-
let wraps = true;
|
|
2166
|
-
for (let i = 0; i < s.length; i++) {
|
|
2167
|
-
const ch = s[i];
|
|
2168
|
-
if (inString) {
|
|
2169
|
-
if (ch === "'") if (s[i + 1] === "'") i++;
|
|
2170
|
-
else inString = false;
|
|
2171
|
-
continue;
|
|
2172
|
-
}
|
|
2173
|
-
if (ch === "'") {
|
|
2174
|
-
inString = true;
|
|
2175
|
-
continue;
|
|
2176
|
-
}
|
|
2177
|
-
if (ch === "(") depth++;
|
|
2178
|
-
else if (ch === ")") {
|
|
2179
|
-
depth--;
|
|
2180
|
-
if (depth === 0 && i < s.length - 1) {
|
|
2181
|
-
wraps = false;
|
|
2182
|
-
break;
|
|
2183
|
-
}
|
|
2184
|
-
}
|
|
2185
|
-
}
|
|
2186
|
-
if (!wraps) return s;
|
|
2187
|
-
s = s.slice(1, -1).trim();
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
1870
|
function sqlToPolicy(sql) {
|
|
2191
|
-
const trimmed =
|
|
1871
|
+
const trimmed = sql.trim();
|
|
2192
1872
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
2193
1873
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
2194
1874
|
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
@@ -2201,10 +1881,14 @@ function sqlToPolicy(sql) {
|
|
|
2201
1881
|
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
2202
1882
|
return policy.rolesContain(roles);
|
|
2203
1883
|
}
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
+
}
|
|
2208
1892
|
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
2209
1893
|
if (match) {
|
|
2210
1894
|
const [, leftStr, op, rightStr] = match;
|
|
@@ -2510,105 +2194,6 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
2510
2194
|
};
|
|
2511
2195
|
return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
|
|
2512
2196
|
};
|
|
2513
|
-
//#endregion
|
|
2514
|
-
//#region ../common/src/util/auth-default-policies.ts
|
|
2515
|
-
/**
|
|
2516
|
-
* Default RLS policies injected by the schema generator.
|
|
2517
|
-
*
|
|
2518
|
-
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
2519
|
-
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
2520
|
-
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
2521
|
-
* authorization model. The server context (auth flows, migrations,
|
|
2522
|
-
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
2523
|
-
*
|
|
2524
|
-
* Because RLS default-denies, every collection is **locked by default**: with
|
|
2525
|
-
* no rules, only the server context and admins can touch it. The generator
|
|
2526
|
-
* injects that safe baseline:
|
|
2527
|
-
*
|
|
2528
|
-
* **For every collection**
|
|
2529
|
-
* 1. A permissive **server-or-admin SELECT** grant.
|
|
2530
|
-
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
2531
|
-
*
|
|
2532
|
-
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
2533
|
-
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
2534
|
-
* rows").
|
|
2535
|
-
*
|
|
2536
|
-
* **For auth collections additionally**
|
|
2537
|
-
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
2538
|
-
* their own row (profile, session bootstrap) without every app re-declaring
|
|
2539
|
-
* it.
|
|
2540
|
-
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
2541
|
-
* every other policy, so a write is rejected unless the caller is an admin
|
|
2542
|
-
* (or the server context) — even if the author also wrote a permissive rule
|
|
2543
|
-
* such as "a user may edit their own row". Without this, a permissive owner
|
|
2544
|
-
* rule would let a user change their own `roles`.
|
|
2545
|
-
*
|
|
2546
|
-
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
2547
|
-
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
2548
|
-
* GUC — which also lets the owner connection satisfy these policies even under
|
|
2549
|
-
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
2550
|
-
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
2551
|
-
*
|
|
2552
|
-
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
2553
|
-
* the collection's RLS.
|
|
2554
|
-
*/
|
|
2555
|
-
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2556
|
-
/** Write operations that must be admin-gated by default on auth collections. */
|
|
2557
|
-
var DEFAULT_GUARDED_OPS = [
|
|
2558
|
-
"insert",
|
|
2559
|
-
"update",
|
|
2560
|
-
"delete"
|
|
2561
|
-
];
|
|
2562
|
-
/** Whether a collection is flagged as an authentication collection. */
|
|
2563
|
-
function isAuthCollection(collection) {
|
|
2564
|
-
const auth = collection.auth;
|
|
2565
|
-
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
2566
|
-
}
|
|
2567
|
-
/** The property marked as the row id (falls back to `id`). */
|
|
2568
|
-
function getIdPropertyName(collection) {
|
|
2569
|
-
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2570
|
-
return "id";
|
|
2571
|
-
}
|
|
2572
|
-
/**
|
|
2573
|
-
* Returns the security rules that should be applied to a collection: the
|
|
2574
|
-
* author's explicit `securityRules` plus the framework defaults described in
|
|
2575
|
-
* the module doc (baseline server/admin read for all collections; self-read
|
|
2576
|
-
* and the admin write gate for auth collections).
|
|
2577
|
-
*
|
|
2578
|
-
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
2579
|
-
*/
|
|
2580
|
-
function getEffectiveSecurityRules(collection) {
|
|
2581
|
-
const explicit = [...(isPostgresCollectionConfig(collection) ? collection.securityRules : void 0) ?? []];
|
|
2582
|
-
if (collection.disableDefaultPolicies) return explicit;
|
|
2583
|
-
const tableName = getTableName$1(collection);
|
|
2584
|
-
const injected = [];
|
|
2585
|
-
injected.push({
|
|
2586
|
-
name: `${tableName}_default_admin_read`,
|
|
2587
|
-
operations: ["select"],
|
|
2588
|
-
condition: SERVER_OR_ADMIN_EXPR
|
|
2589
|
-
});
|
|
2590
|
-
injected.push({
|
|
2591
|
-
name: `${tableName}_default_admin_write`,
|
|
2592
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
2593
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
2594
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
2595
|
-
});
|
|
2596
|
-
if (isAuthCollection(collection)) {
|
|
2597
|
-
injected.push({
|
|
2598
|
-
name: `${tableName}_default_self_read`,
|
|
2599
|
-
operations: ["select"],
|
|
2600
|
-
condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
|
|
2601
|
-
});
|
|
2602
|
-
injected.push({
|
|
2603
|
-
name: `${tableName}_require_admin_write`,
|
|
2604
|
-
mode: "restrictive",
|
|
2605
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
2606
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
2607
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
2608
|
-
});
|
|
2609
|
-
}
|
|
2610
|
-
return [...explicit, ...injected];
|
|
2611
|
-
}
|
|
2612
2197
|
(/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
2613
2198
|
(function(root, factory) {
|
|
2614
2199
|
if (typeof define === "function" && define.amd) define(factory);
|
|
@@ -3878,45 +3463,18 @@ function deserializeFilter(query) {
|
|
|
3878
3463
|
}
|
|
3879
3464
|
//#endregion
|
|
3880
3465
|
//#region ../common/src/data/buildRebaseData.ts
|
|
3881
|
-
function createPrimaryKeyResolver(options) {
|
|
3882
|
-
const cache = /* @__PURE__ */ new Map();
|
|
3883
|
-
const warned = /* @__PURE__ */ new Set();
|
|
3884
|
-
return function primaryKeysFor(slug) {
|
|
3885
|
-
const cached = cache.get(slug);
|
|
3886
|
-
if (cached) return cached;
|
|
3887
|
-
const collection = options?.resolveCollection?.(slug);
|
|
3888
|
-
if (!collection) return [];
|
|
3889
|
-
const keys = resolvePrimaryKeys(collection);
|
|
3890
|
-
if (keys.length > 0) {
|
|
3891
|
-
cache.set(slug, keys);
|
|
3892
|
-
return keys;
|
|
3893
|
-
}
|
|
3894
|
-
if (!warned.has(slug)) {
|
|
3895
|
-
warned.add(slug);
|
|
3896
|
-
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.`);
|
|
3897
|
-
}
|
|
3898
|
-
return keys;
|
|
3899
|
-
};
|
|
3900
|
-
}
|
|
3901
3466
|
/**
|
|
3902
|
-
*
|
|
3903
|
-
*
|
|
3904
|
-
* The address is *derived here* — it is not a column, and the row it came from
|
|
3905
|
-
* does not contain one. Rows carry exactly what the table has, with the types
|
|
3906
|
-
* Postgres returned; the id is this layer's invention, and this is the only
|
|
3907
|
-
* place it is minted.
|
|
3908
|
-
*
|
|
3909
|
-
* `primaryKeys` empty falls back to a literal `id` on the row: drivers other
|
|
3910
|
-
* 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.
|
|
3911
3469
|
*/
|
|
3912
|
-
function rowToEntity(row, slug
|
|
3470
|
+
function rowToEntity(row, slug) {
|
|
3913
3471
|
return {
|
|
3914
|
-
id:
|
|
3472
|
+
id: row.id,
|
|
3915
3473
|
path: slug,
|
|
3916
3474
|
values: row
|
|
3917
3475
|
};
|
|
3918
3476
|
}
|
|
3919
|
-
function createDriverAccessor(driver, slug
|
|
3477
|
+
function createDriverAccessor(driver, slug) {
|
|
3920
3478
|
const accessor = {
|
|
3921
3479
|
async find(params) {
|
|
3922
3480
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
@@ -3949,7 +3507,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
3949
3507
|
hasMore = offset + rows.length < total;
|
|
3950
3508
|
}
|
|
3951
3509
|
return {
|
|
3952
|
-
data: rows.map((row) => rowToEntity(row, slug
|
|
3510
|
+
data: rows.map((row) => rowToEntity(row, slug)),
|
|
3953
3511
|
meta: {
|
|
3954
3512
|
total,
|
|
3955
3513
|
limit,
|
|
@@ -3963,7 +3521,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
3963
3521
|
path: slug,
|
|
3964
3522
|
id
|
|
3965
3523
|
});
|
|
3966
|
-
return row ? rowToEntity(row, slug
|
|
3524
|
+
return row ? rowToEntity(row, slug) : void 0;
|
|
3967
3525
|
},
|
|
3968
3526
|
async create(data, id) {
|
|
3969
3527
|
return rowToEntity(await driver.save({
|
|
@@ -3971,22 +3529,15 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
3971
3529
|
values: data,
|
|
3972
3530
|
id,
|
|
3973
3531
|
status: "new"
|
|
3974
|
-
}), slug
|
|
3532
|
+
}), slug);
|
|
3975
3533
|
},
|
|
3976
|
-
createMany: driver.saveMany ? async (data, options) => {
|
|
3977
|
-
return (await driver.saveMany({
|
|
3978
|
-
path: slug,
|
|
3979
|
-
rows: data,
|
|
3980
|
-
upsert: options?.upsert
|
|
3981
|
-
})).map((row) => rowToEntity(row, slug, getPks()));
|
|
3982
|
-
} : void 0,
|
|
3983
3534
|
async update(id, data) {
|
|
3984
3535
|
return rowToEntity(await driver.save({
|
|
3985
3536
|
path: slug,
|
|
3986
3537
|
values: data,
|
|
3987
3538
|
id,
|
|
3988
3539
|
status: "existing"
|
|
3989
|
-
}), slug
|
|
3540
|
+
}), slug);
|
|
3990
3541
|
},
|
|
3991
3542
|
async delete(id) {
|
|
3992
3543
|
return driver.delete({ row: {
|
|
@@ -4015,7 +3566,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4015
3566
|
searchString: params?.searchString,
|
|
4016
3567
|
onUpdate: (entities) => {
|
|
4017
3568
|
onUpdate({
|
|
4018
|
-
data: entities.map((row) => rowToEntity(row, slug
|
|
3569
|
+
data: entities.map((row) => rowToEntity(row, slug)),
|
|
4019
3570
|
meta: {
|
|
4020
3571
|
total: entities.length,
|
|
4021
3572
|
limit,
|
|
@@ -4031,7 +3582,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4031
3582
|
return driver.listenOne({
|
|
4032
3583
|
path: slug,
|
|
4033
3584
|
id,
|
|
4034
|
-
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug
|
|
3585
|
+
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
|
|
4035
3586
|
onError
|
|
4036
3587
|
});
|
|
4037
3588
|
} : void 0,
|
|
@@ -4070,13 +3621,12 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4070
3621
|
* await data.products.create({ name: "Camera", price: 299 });
|
|
4071
3622
|
* const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
|
|
4072
3623
|
*/
|
|
4073
|
-
function buildRebaseData(driver
|
|
3624
|
+
function buildRebaseData(driver) {
|
|
4074
3625
|
const cache = /* @__PURE__ */ new Map();
|
|
4075
|
-
const primaryKeysFor = createPrimaryKeyResolver(options);
|
|
4076
3626
|
function getAccessor(slug) {
|
|
4077
3627
|
let accessor = cache.get(slug);
|
|
4078
3628
|
if (!accessor) {
|
|
4079
|
-
accessor = createDriverAccessor(driver, slug
|
|
3629
|
+
accessor = createDriverAccessor(driver, slug);
|
|
4080
3630
|
cache.set(slug, accessor);
|
|
4081
3631
|
}
|
|
4082
3632
|
return accessor;
|
|
@@ -4089,9 +3639,8 @@ function buildRebaseData(driver, options) {
|
|
|
4089
3639
|
} });
|
|
4090
3640
|
}
|
|
4091
3641
|
/**
|
|
4092
|
-
* Unwrap a Entity
|
|
4093
|
-
*
|
|
4094
|
-
* 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.
|
|
4095
3644
|
*/
|
|
4096
3645
|
function entityToRow(entity) {
|
|
4097
3646
|
return entity.values;
|
|
@@ -4178,12 +3727,6 @@ function toSdkCollectionClient(snap) {
|
|
|
4178
3727
|
async create(data, id) {
|
|
4179
3728
|
return entityToRow(await snap.create(data, id));
|
|
4180
3729
|
},
|
|
4181
|
-
async createMany(data, options) {
|
|
4182
|
-
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
4183
|
-
if (data.length === 0) return [];
|
|
4184
|
-
if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
|
|
4185
|
-
return (await snap.createMany(data, options)).map(entityToRow);
|
|
4186
|
-
},
|
|
4187
3730
|
async update(id, data) {
|
|
4188
3731
|
return entityToRow(await snap.update(id, data));
|
|
4189
3732
|
},
|
|
@@ -4373,6 +3916,36 @@ function getPrimaryKeys(collection, registry) {
|
|
|
4373
3916
|
}
|
|
4374
3917
|
return keys;
|
|
4375
3918
|
}
|
|
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;
|
|
3943
|
+
}
|
|
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(":::");
|
|
3948
|
+
}
|
|
4376
3949
|
//#endregion
|
|
4377
3950
|
//#region src/utils/drizzle-conditions.ts
|
|
4378
3951
|
/**
|
|
@@ -6192,14 +5765,12 @@ var FetchService = class {
|
|
|
6192
5765
|
/**
|
|
6193
5766
|
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
6194
5767
|
* Handles:
|
|
5768
|
+
* - Placing `id` at the top level as a string
|
|
6195
5769
|
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
6196
5770
|
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
6197
5771
|
* - Flattening junction-table many-to-many results
|
|
6198
|
-
*
|
|
6199
|
-
* The row's own address is not among them: it is derived by the consumer
|
|
6200
|
-
* from the collection's primary keys.
|
|
6201
5772
|
*/
|
|
6202
|
-
drizzleResultToRow(row, collection) {
|
|
5773
|
+
drizzleResultToRow(row, collection, _collectionPath, idInfo, _databaseId, idInfoArray) {
|
|
6203
5774
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6204
5775
|
const normalizedValues = normalizeDbValues(row, collection);
|
|
6205
5776
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
@@ -6235,7 +5806,10 @@ var FetchService = class {
|
|
|
6235
5806
|
});
|
|
6236
5807
|
}
|
|
6237
5808
|
}
|
|
6238
|
-
return
|
|
5809
|
+
return {
|
|
5810
|
+
...normalizedValues,
|
|
5811
|
+
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
5812
|
+
};
|
|
6239
5813
|
}
|
|
6240
5814
|
/**
|
|
6241
5815
|
* Post-fetch joinPath relations for a single flat row.
|
|
@@ -6324,28 +5898,37 @@ var FetchService = class {
|
|
|
6324
5898
|
}
|
|
6325
5899
|
}
|
|
6326
5900
|
/**
|
|
6327
|
-
* Convert a db.query result row to a flat REST-style
|
|
6328
|
-
*
|
|
6329
|
-
* Every column is copied through under its own name, with the value Postgres
|
|
6330
|
-
* returned. This used to open with a synthesized `id` and then skip the key
|
|
6331
|
-
* column, which renamed it (a `sku` primary key was served as `id`, and `sku`
|
|
6332
|
-
* did not appear at all) and restringified it (`42` → `"42"`). Consumers that
|
|
6333
|
-
* 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.
|
|
6334
5902
|
*/
|
|
6335
|
-
drizzleResultToRestRow(row, collection) {
|
|
6336
|
-
const flat = {};
|
|
5903
|
+
drizzleResultToRestRow(row, collection, idInfo, idInfoArray) {
|
|
5904
|
+
const flat = { id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
6337
5905
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6338
5906
|
for (const [k, v] of Object.entries(row)) {
|
|
5907
|
+
if (k === idInfo.fieldName) continue;
|
|
6339
5908
|
const relation = findRelation(resolvedRelations, k);
|
|
6340
5909
|
if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
|
|
6341
5910
|
if (this.isJunctionRelation(relation, collection)) {
|
|
6342
5911
|
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
6343
|
-
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
|
+
}
|
|
6344
5919
|
}
|
|
6345
|
-
return {
|
|
5920
|
+
return {
|
|
5921
|
+
...item,
|
|
5922
|
+
id: String(item.id ?? item[Object.keys(item)[0]])
|
|
5923
|
+
};
|
|
6346
5924
|
});
|
|
6347
|
-
else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation)
|
|
6348
|
-
|
|
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;
|
|
6349
5932
|
}
|
|
6350
5933
|
return flat;
|
|
6351
5934
|
}
|
|
@@ -6433,7 +6016,7 @@ var FetchService = class {
|
|
|
6433
6016
|
with: withConfig
|
|
6434
6017
|
});
|
|
6435
6018
|
if (!row) return void 0;
|
|
6436
|
-
const flatRow = this.drizzleResultToRow(row, collection);
|
|
6019
|
+
const flatRow = this.drizzleResultToRow(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
|
|
6437
6020
|
await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
6438
6021
|
return flatRow;
|
|
6439
6022
|
} catch (e) {
|
|
@@ -6485,7 +6068,7 @@ var FetchService = class {
|
|
|
6485
6068
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
6486
6069
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
6487
6070
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
|
|
6488
|
-
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));
|
|
6489
6072
|
} catch (e) {
|
|
6490
6073
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
6491
6074
|
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
@@ -6556,7 +6139,8 @@ var FetchService = class {
|
|
|
6556
6139
|
const parsedRows = await Promise.all(results.map(async (rawRow) => {
|
|
6557
6140
|
return {
|
|
6558
6141
|
rawRow,
|
|
6559
|
-
values: await parseDataFromServer(rawRow, collection)
|
|
6142
|
+
values: await parseDataFromServer(rawRow, collection),
|
|
6143
|
+
id: idInfoArray && idInfoArray.length > 1 ? buildCompositeId(rawRow, idInfoArray) : String(rawRow[idInfo.fieldName])
|
|
6560
6144
|
};
|
|
6561
6145
|
}));
|
|
6562
6146
|
if (!skipRelations) {
|
|
@@ -6596,7 +6180,10 @@ var FetchService = class {
|
|
|
6596
6180
|
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
6597
6181
|
}
|
|
6598
6182
|
}
|
|
6599
|
-
return parsedRows.map((item) =>
|
|
6183
|
+
return parsedRows.map((item) => ({
|
|
6184
|
+
...item.values,
|
|
6185
|
+
id: item.id
|
|
6186
|
+
}));
|
|
6600
6187
|
}
|
|
6601
6188
|
/**
|
|
6602
6189
|
* Fetch a collection of rows
|
|
@@ -6727,7 +6314,7 @@ var FetchService = class {
|
|
|
6727
6314
|
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
6728
6315
|
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
6729
6316
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
|
|
6730
|
-
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));
|
|
6731
6318
|
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
6732
6319
|
return restRows;
|
|
6733
6320
|
} catch (e) {
|
|
@@ -6738,7 +6325,10 @@ var FetchService = class {
|
|
|
6738
6325
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
6739
6326
|
}
|
|
6740
6327
|
const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
|
|
6741
|
-
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
|
+
}));
|
|
6742
6332
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6743
6333
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
6744
6334
|
const shouldInclude = (key) => include[0] === "*" || include.includes(key);
|
|
@@ -6750,7 +6340,10 @@ var FetchService = class {
|
|
|
6750
6340
|
for (const row of rows) {
|
|
6751
6341
|
const eid = row[idInfo.fieldName];
|
|
6752
6342
|
const related = batchResults.get(String(eid));
|
|
6753
|
-
if (related) row[key] = {
|
|
6343
|
+
if (related) row[key] = {
|
|
6344
|
+
...related.values,
|
|
6345
|
+
id: related.id
|
|
6346
|
+
};
|
|
6754
6347
|
}
|
|
6755
6348
|
} catch (e) {
|
|
6756
6349
|
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
@@ -6768,7 +6361,10 @@ var FetchService = class {
|
|
|
6768
6361
|
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
6769
6362
|
}
|
|
6770
6363
|
}
|
|
6771
|
-
return rows
|
|
6364
|
+
return rows.map((row) => ({
|
|
6365
|
+
...row,
|
|
6366
|
+
id: idInfoArray.length > 1 ? buildCompositeId(row, idInfoArray) : String(row[idInfo.fieldName])
|
|
6367
|
+
}));
|
|
6772
6368
|
}
|
|
6773
6369
|
/**
|
|
6774
6370
|
* Fetch a single row with optional relation includes for REST API.
|
|
@@ -6789,7 +6385,7 @@ var FetchService = class {
|
|
|
6789
6385
|
...withConfig ? { with: withConfig } : {}
|
|
6790
6386
|
});
|
|
6791
6387
|
if (!row) return null;
|
|
6792
|
-
const restRow = this.drizzleResultToRestRow(row, collection);
|
|
6388
|
+
const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
|
|
6793
6389
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
6794
6390
|
return restRow;
|
|
6795
6391
|
} catch (e) {
|
|
@@ -6801,7 +6397,11 @@ var FetchService = class {
|
|
|
6801
6397
|
}
|
|
6802
6398
|
const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
|
|
6803
6399
|
if (result.length === 0) return null;
|
|
6804
|
-
const
|
|
6400
|
+
const raw = result[0];
|
|
6401
|
+
const flatEntity = {
|
|
6402
|
+
...raw,
|
|
6403
|
+
id: idInfoArray.length > 1 ? buildCompositeId(raw, idInfoArray) : String(raw[idInfo.fieldName])
|
|
6404
|
+
};
|
|
6805
6405
|
if (!include || include.length === 0) return flatEntity;
|
|
6806
6406
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6807
6407
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
@@ -6913,15 +6513,32 @@ var FetchService = class {
|
|
|
6913
6513
|
if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
6914
6514
|
}
|
|
6915
6515
|
return (await queryTarget.findMany(queryOpts)).map((row) => {
|
|
6916
|
-
const flat = {};
|
|
6917
|
-
for (const [k, v] of Object.entries(row))
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
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
|
+
}
|
|
6925
6542
|
return flat;
|
|
6926
6543
|
});
|
|
6927
6544
|
} catch (e) {
|
|
@@ -7137,27 +6754,6 @@ var PersistService = class {
|
|
|
7137
6754
|
this.fetchService = new FetchService(db, registry);
|
|
7138
6755
|
}
|
|
7139
6756
|
/**
|
|
7140
|
-
* Explain a write that matched no rows.
|
|
7141
|
-
*
|
|
7142
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
7143
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
7144
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
7145
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
7146
|
-
* the row is untouched.
|
|
7147
|
-
*
|
|
7148
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
7149
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
7150
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
7151
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
7152
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
7153
|
-
*
|
|
7154
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
7155
|
-
*/
|
|
7156
|
-
async explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
|
|
7157
|
-
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");
|
|
7158
|
-
return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
|
|
7159
|
-
}
|
|
7160
|
-
/**
|
|
7161
6757
|
* Delete an row by ID
|
|
7162
6758
|
*/
|
|
7163
6759
|
async delete(collectionPath, id, _databaseId) {
|
|
@@ -7171,7 +6767,7 @@ var PersistService = class {
|
|
|
7171
6767
|
if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
7172
6768
|
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
7173
6769
|
}
|
|
7174
|
-
|
|
6770
|
+
await this.db.delete(table).where(and(...conditions));
|
|
7175
6771
|
}
|
|
7176
6772
|
/**
|
|
7177
6773
|
* Delete all rows from a collection
|
|
@@ -7182,14 +6778,8 @@ var PersistService = class {
|
|
|
7182
6778
|
}
|
|
7183
6779
|
/**
|
|
7184
6780
|
* Save an row (create or update)
|
|
7185
|
-
*
|
|
7186
|
-
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
7187
|
-
* UPDATE against the primary key rather than a plain UPDATE. That is one
|
|
7188
|
-
* statement, so it cannot lose a race the way a read-then-write can, and it
|
|
7189
|
-
* does not care whether the row already exists — which is what a re-runnable
|
|
7190
|
-
* import needs.
|
|
7191
6781
|
*/
|
|
7192
|
-
async save(collectionPath, values, id, databaseId
|
|
6782
|
+
async save(collectionPath, values, id, databaseId) {
|
|
7193
6783
|
let effectiveCollectionPath = collectionPath;
|
|
7194
6784
|
const effectiveValues = { ...values };
|
|
7195
6785
|
let junctionTableInfo;
|
|
@@ -7280,7 +6870,7 @@ var PersistService = class {
|
|
|
7280
6870
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
7281
6871
|
savedId = await this.db.transaction(async (tx) => {
|
|
7282
6872
|
let currentId;
|
|
7283
|
-
if (id
|
|
6873
|
+
if (id) {
|
|
7284
6874
|
currentId = id;
|
|
7285
6875
|
const idValues = parseIdValues(id, idInfoArray);
|
|
7286
6876
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
@@ -7291,28 +6881,13 @@ var PersistService = class {
|
|
|
7291
6881
|
const field = table[info.fieldName];
|
|
7292
6882
|
conditions.push(eq(field, idValues[info.fieldName]));
|
|
7293
6883
|
}
|
|
7294
|
-
|
|
6884
|
+
await updateQuery.where(and(...conditions));
|
|
7295
6885
|
}
|
|
7296
6886
|
} else {
|
|
7297
6887
|
const dataForInsert = { ...entityData };
|
|
7298
|
-
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
7299
6888
|
for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
|
|
7300
|
-
const
|
|
7301
|
-
|
|
7302
|
-
let result;
|
|
7303
|
-
if (options?.upsert && hasFullKey) {
|
|
7304
|
-
const target = idInfoArray.map((info) => table[info.fieldName]);
|
|
7305
|
-
const set = { ...dataForInsert };
|
|
7306
|
-
for (const info of idInfoArray) delete set[info.fieldName];
|
|
7307
|
-
result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
|
|
7308
|
-
target,
|
|
7309
|
-
set
|
|
7310
|
-
}).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
|
|
7311
|
-
} else result = await insertQuery.returning(returningKeys);
|
|
7312
|
-
const resultRow = result[0];
|
|
7313
|
-
if (!resultRow) if (id) currentId = id;
|
|
7314
|
-
else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
|
|
7315
|
-
else currentId = buildCompositeId(resultRow, idInfoArray);
|
|
6889
|
+
const resultRow = (await tx.insert(table).values(dataForInsert).returning(returningKeys))[0];
|
|
6890
|
+
currentId = buildCompositeId(resultRow, idInfoArray);
|
|
7316
6891
|
if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
7317
6892
|
}
|
|
7318
6893
|
if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
@@ -7343,7 +6918,6 @@ var PersistService = class {
|
|
|
7343
6918
|
* Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
|
|
7344
6919
|
*/
|
|
7345
6920
|
toUserFriendlyError(error, collectionSlug) {
|
|
7346
|
-
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
7347
6921
|
const pgError = extractPgError(error);
|
|
7348
6922
|
if (pgError) {
|
|
7349
6923
|
const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
@@ -7421,8 +6995,8 @@ var DataService = class {
|
|
|
7421
6995
|
/**
|
|
7422
6996
|
* Save an row (create or update)
|
|
7423
6997
|
*/
|
|
7424
|
-
async save(collectionPath, values, id, databaseId
|
|
7425
|
-
return this.persistService.save(collectionPath, values, id, databaseId
|
|
6998
|
+
async save(collectionPath, values, id, databaseId) {
|
|
6999
|
+
return this.persistService.save(collectionPath, values, id, databaseId);
|
|
7426
7000
|
}
|
|
7427
7001
|
/**
|
|
7428
7002
|
* Delete an row by ID
|
|
@@ -7490,26 +7064,6 @@ var DataService = class {
|
|
|
7490
7064
|
*/
|
|
7491
7065
|
/** Internal prefix applied to branch database names to avoid collisions. */
|
|
7492
7066
|
var BRANCH_DB_PREFIX = "rb_";
|
|
7493
|
-
/** `duplicate_database` — the target database name is already taken. */
|
|
7494
|
-
var PG_DUPLICATE_DATABASE = "42P04";
|
|
7495
|
-
/** `object_in_use` — the database still has connections attached. */
|
|
7496
|
-
var PG_OBJECT_IN_USE = "55006";
|
|
7497
|
-
/**
|
|
7498
|
-
* Describe a failed branch DDL statement in terms a user can act on.
|
|
7499
|
-
*
|
|
7500
|
-
* Drizzle reports failures as `Failed query: <sql> params:` and hides the real
|
|
7501
|
-
* PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
|
|
7502
|
-
* the actual problem. Match on the PG error code instead — it survives wrapping
|
|
7503
|
-
* and, unlike the message text, is not locale-dependent.
|
|
7504
|
-
*/
|
|
7505
|
-
function describeBranchDdlError(err, fallbackContext) {
|
|
7506
|
-
const pgError = extractPgError(err);
|
|
7507
|
-
if (pgError?.code === PG_DUPLICATE_DATABASE) return /* @__PURE__ */ new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
|
|
7508
|
-
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.`);
|
|
7509
|
-
const detail = pgError?.message ?? extractCauseMessage(err);
|
|
7510
|
-
if (detail) return new Error(detail);
|
|
7511
|
-
return err instanceof Error ? err : new Error(String(err));
|
|
7512
|
-
}
|
|
7513
7067
|
/** Fully-qualified metadata table in the rebase schema. */
|
|
7514
7068
|
var BRANCHES_TABLE = "rebase.branches";
|
|
7515
7069
|
/**
|
|
@@ -7578,8 +7132,10 @@ var BranchService = class {
|
|
|
7578
7132
|
try {
|
|
7579
7133
|
await this.db.execute(sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`));
|
|
7580
7134
|
} catch (err) {
|
|
7581
|
-
|
|
7582
|
-
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;
|
|
7583
7139
|
}
|
|
7584
7140
|
const now = /* @__PURE__ */ new Date();
|
|
7585
7141
|
await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
@@ -7604,8 +7160,8 @@ var BranchService = class {
|
|
|
7604
7160
|
try {
|
|
7605
7161
|
await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
|
|
7606
7162
|
} catch (err) {
|
|
7607
|
-
if (
|
|
7608
|
-
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;
|
|
7609
7165
|
}
|
|
7610
7166
|
await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
|
|
7611
7167
|
}
|
|
@@ -8237,7 +7793,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8237
7793
|
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
8238
7794
|
};
|
|
8239
7795
|
}
|
|
8240
|
-
async save({ path, id, values, collection, status
|
|
7796
|
+
async save({ path, id, values, collection, status }) {
|
|
8241
7797
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
8242
7798
|
let updatedValues = values;
|
|
8243
7799
|
const contextForCallback = this.buildCallContext();
|
|
@@ -8294,7 +7850,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8294
7850
|
timestampNowValue: /* @__PURE__ */ new Date()
|
|
8295
7851
|
});
|
|
8296
7852
|
try {
|
|
8297
|
-
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId
|
|
7853
|
+
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
|
|
8298
7854
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
8299
7855
|
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
8300
7856
|
collection: resolvedCollection,
|
|
@@ -8395,49 +7951,6 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8395
7951
|
throw error;
|
|
8396
7952
|
}
|
|
8397
7953
|
}
|
|
8398
|
-
/**
|
|
8399
|
-
* Write many rows through the same pipeline as {@link save}.
|
|
8400
|
-
*
|
|
8401
|
-
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
8402
|
-
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
8403
|
-
* state. When this driver is already inside a transaction (the authenticated
|
|
8404
|
-
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
8405
|
-
* still atomic and still commits once.
|
|
8406
|
-
*
|
|
8407
|
-
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
8408
|
-
* with the last write winning, exactly as separate calls would.
|
|
8409
|
-
*/
|
|
8410
|
-
async saveMany({ path, rows, collection, upsert }) {
|
|
8411
|
-
return this.db.transaction(async (tx) => {
|
|
8412
|
-
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
8413
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
8414
|
-
txDriver.client = this.client;
|
|
8415
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
8416
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
8417
|
-
const saved = [];
|
|
8418
|
-
for (let i = 0; i < rows.length; i++) {
|
|
8419
|
-
const values = rows[i];
|
|
8420
|
-
const id = values?.id;
|
|
8421
|
-
try {
|
|
8422
|
-
saved.push(await txDriver.save({
|
|
8423
|
-
path,
|
|
8424
|
-
values,
|
|
8425
|
-
collection,
|
|
8426
|
-
status: "new",
|
|
8427
|
-
upsert
|
|
8428
|
-
}));
|
|
8429
|
-
} catch (error) {
|
|
8430
|
-
const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
|
|
8431
|
-
throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
8432
|
-
statusCode: error?.statusCode,
|
|
8433
|
-
code: error?.code,
|
|
8434
|
-
name: error?.name
|
|
8435
|
-
});
|
|
8436
|
-
}
|
|
8437
|
-
}
|
|
8438
|
-
return saved;
|
|
8439
|
-
});
|
|
8440
|
-
}
|
|
8441
7954
|
async delete({ row, collection }) {
|
|
8442
7955
|
const targetPath = row.path;
|
|
8443
7956
|
const targetRow = {
|
|
@@ -8802,18 +8315,6 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
8802
8315
|
async save(props) {
|
|
8803
8316
|
return this.withTransaction((delegate) => delegate.save(props));
|
|
8804
8317
|
}
|
|
8805
|
-
/**
|
|
8806
|
-
* One transaction for the whole batch, rather than one per row.
|
|
8807
|
-
*
|
|
8808
|
-
* This is the point of the method: `save` opens a transaction per call, so
|
|
8809
|
-
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
8810
|
-
* round trips). Here the RLS context is established once and every row lands
|
|
8811
|
-
* or none does. Realtime notifications are already deferred to commit by
|
|
8812
|
-
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
8813
|
-
*/
|
|
8814
|
-
async saveMany(props) {
|
|
8815
|
-
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
8816
|
-
}
|
|
8817
8318
|
async delete(props) {
|
|
8818
8319
|
return this.withTransaction((delegate) => delegate.delete(props));
|
|
8819
8320
|
}
|
|
@@ -9060,6 +8561,105 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
|
|
|
9060
8561
|
references: [users.id]
|
|
9061
8562
|
}) }));
|
|
9062
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
|
|
9063
8663
|
//#region src/schema/generate-drizzle-schema-logic.ts
|
|
9064
8664
|
/**
|
|
9065
8665
|
* Resolve the SQL column name for a property.
|
|
@@ -9257,12 +8857,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
9257
8857
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
9258
8858
|
*/
|
|
9259
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
|
+
};
|
|
9260
8879
|
var generatePolicyCode = (collection, rule, index, resolveCollection) => {
|
|
9261
8880
|
const tableName = getTableName$1(collection);
|
|
9262
8881
|
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
9263
|
-
const
|
|
8882
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
9264
8883
|
return ops.map((op, opIdx) => {
|
|
9265
|
-
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);
|
|
9266
8885
|
}).join("");
|
|
9267
8886
|
};
|
|
9268
8887
|
/**
|
|
@@ -11359,7 +10978,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11359
10978
|
} catch (error) {
|
|
11360
10979
|
logger.error("💥 [WebSocket Server] Error handling message", { error });
|
|
11361
10980
|
if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
|
|
11362
|
-
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";
|
|
11363
10982
|
const errorResponse = {
|
|
11364
10983
|
type: "ERROR",
|
|
11365
10984
|
requestId,
|