@rebasepro/server-postgres 0.9.1-canary.ad25bc0 → 0.9.1-canary.b10dcdf
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBootstrapper.d.ts +10 -0
- package/dist/auth/services.d.ts +16 -0
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +822 -329
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +4 -32
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/collection-helpers.d.ts +76 -0
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +7 -0
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +10 -7
- package/src/PostgresBackendDriver.ts +39 -11
- package/src/PostgresBootstrapper.ts +62 -25
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/generate-drizzle-schema-logic.ts +21 -4
- package/src/schema/generate-postgres-ddl-logic.ts +63 -3
- package/src/schema/introspect-db.ts +19 -2
- package/src/services/FetchService.ts +50 -229
- package/src/services/PersistService.ts +9 -2
- package/src/services/RelationService.ts +153 -94
- package/src/services/collection-helpers.ts +166 -3
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +40 -19
- package/src/services/row-pipeline.ts +215 -0
- package/src/utils/drizzle-conditions.ts +13 -0
package/dist/index.es.js
CHANGED
|
@@ -71,16 +71,51 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
71
71
|
var connection_exports = /* @__PURE__ */ __exportAll({
|
|
72
72
|
createDirectDatabaseConnection: () => createDirectDatabaseConnection,
|
|
73
73
|
createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
|
|
74
|
-
createReadReplicaConnection: () => createReadReplicaConnection
|
|
74
|
+
createReadReplicaConnection: () => createReadReplicaConnection,
|
|
75
|
+
guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
|
|
75
76
|
});
|
|
76
77
|
var DEFAULT_POOL = {
|
|
77
78
|
max: 20,
|
|
78
79
|
idleTimeoutMillis: 3e4,
|
|
79
80
|
connectionTimeoutMillis: 1e4,
|
|
80
|
-
queryTimeout:
|
|
81
|
+
queryTimeout: 6e4,
|
|
81
82
|
statementTimeout: 3e4,
|
|
82
83
|
keepAlive: true
|
|
83
84
|
};
|
|
85
|
+
/** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
|
|
86
|
+
var TX_IDLE = "I";
|
|
87
|
+
/**
|
|
88
|
+
* Destroy pool clients that are released while still inside a transaction.
|
|
89
|
+
*
|
|
90
|
+
* pg-pool returns a client to the idle list whenever `release()` is called
|
|
91
|
+
* without an error — even if the connection is still mid-transaction (status
|
|
92
|
+
* `T`/`E`). That happens in practice: drizzle's pool transaction releases in
|
|
93
|
+
* a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
|
|
94
|
+
* (e.g. it was queued behind a statement that hit the client-side
|
|
95
|
+
* query_timeout), the client goes back dirty. The next checkout then runs
|
|
96
|
+
* its statements inside the zombie transaction — with the previous request's
|
|
97
|
+
* `app.*` RLS GUCs still applied, which turns unrelated queries into
|
|
98
|
+
* RLS-scoped ones (observed in production as registration failing with
|
|
99
|
+
* SQLSTATE 42501 under a leaked anonymous context).
|
|
100
|
+
*
|
|
101
|
+
* pg-pool emits `release` before it consults its private `_expired` set, so
|
|
102
|
+
* marking the client expired here makes `_release()` destroy it instead of
|
|
103
|
+
* pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
|
|
104
|
+
* private APIs — feature-detect and fall back to loud logging so an upstream
|
|
105
|
+
* change degrades to observability, never to silent corruption.
|
|
106
|
+
*/
|
|
107
|
+
function guardPoolAgainstDirtyRelease(pool, label) {
|
|
108
|
+
pool.on("release", (err, client) => {
|
|
109
|
+
if (err) return;
|
|
110
|
+
const txStatus = client?._txStatus;
|
|
111
|
+
if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
|
|
112
|
+
const expired = pool._expired;
|
|
113
|
+
if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
|
|
114
|
+
expired.add(client);
|
|
115
|
+
logger.error(`[${label}] Client released back to the pool while still in a transaction (status '${txStatus}') — destroying it so the open transaction and its session state (RLS GUCs) cannot leak into the next request.`);
|
|
116
|
+
} else logger.error(`[${label}] Client released mid-transaction (status '${txStatus}') but the pool's internal expiry set is unavailable (pg-pool internals changed?). The connection may leak its open transaction into subsequent requests.`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
84
119
|
/**
|
|
85
120
|
* Create a Drizzle-backed Postgres connection with a production-grade
|
|
86
121
|
* connection pool.
|
|
@@ -112,6 +147,7 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
|
|
|
112
147
|
logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
|
|
113
148
|
if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
|
|
114
149
|
});
|
|
150
|
+
guardPoolAgainstDirtyRelease(pool, "pg-pool");
|
|
115
151
|
return {
|
|
116
152
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
117
153
|
pool,
|
|
@@ -144,6 +180,7 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
|
|
|
144
180
|
pool.on("error", (err) => {
|
|
145
181
|
logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
|
|
146
182
|
});
|
|
183
|
+
guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
|
|
147
184
|
return {
|
|
148
185
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
149
186
|
pool,
|
|
@@ -173,6 +210,7 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
|
|
|
173
210
|
pool.on("error", (err) => {
|
|
174
211
|
logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
|
|
175
212
|
});
|
|
213
|
+
guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
|
|
176
214
|
return {
|
|
177
215
|
db: schema ? drizzle(pool, { schema }) : drizzle(pool),
|
|
178
216
|
pool,
|
|
@@ -1808,12 +1846,17 @@ function parseIdValues(idValue, primaryKeys) {
|
|
|
1808
1846
|
/**
|
|
1809
1847
|
* The primary keys of a collection, as declared by its properties.
|
|
1810
1848
|
*
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
1813
|
-
*
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1849
|
+
* This is the only tier both sides can read, because it is the only one written
|
|
1850
|
+
* in the config: the postgres driver can also infer keys from the Drizzle
|
|
1851
|
+
* schema, which the browser never sees and is never sent — the admin compiles
|
|
1852
|
+
* the collection files into its own bundle rather than being served them. A key
|
|
1853
|
+
* that lives only in the Drizzle schema is therefore invisible here, and the
|
|
1854
|
+
* server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
|
|
1855
|
+
* to add.
|
|
1856
|
+
*
|
|
1857
|
+
* Returns an empty array when a collection declares none, which callers must
|
|
1858
|
+
* treat as "not addressable" rather than defaulting to `id`: guessing a key
|
|
1859
|
+
* that is not the real one produces confidently wrong addresses.
|
|
1817
1860
|
*/
|
|
1818
1861
|
function getDeclaredPrimaryKeys(collection) {
|
|
1819
1862
|
const properties = collection.properties;
|
|
@@ -1838,9 +1881,15 @@ function getDeclaredPrimaryKeys(collection) {
|
|
|
1838
1881
|
* The postgres driver tries, in order: properties marked `isId`; the primary
|
|
1839
1882
|
* keys of the Drizzle schema; and finally a column literally named `id`. Only
|
|
1840
1883
|
* the first and last are visible in a `CollectionConfig`, which is what both
|
|
1841
|
-
* sides share
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
1884
|
+
* sides share.
|
|
1885
|
+
*
|
|
1886
|
+
* So the two agree except on a collection that declares no `isId` and whose key
|
|
1887
|
+
* is known only to Drizzle. There, the driver reads the real key, and this
|
|
1888
|
+
* either resolves nothing (reported to the console by the caller) or — if the
|
|
1889
|
+
* table happens to have an unrelated `id` property — resolves `id`, which is
|
|
1890
|
+
* the wrong key and cannot be detected from here: the addresses look right and
|
|
1891
|
+
* route wrong. Only the config can settle it, so the server names both cases
|
|
1892
|
+
* at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
|
|
1844
1893
|
*/
|
|
1845
1894
|
function resolvePrimaryKeys(collection) {
|
|
1846
1895
|
const declared = getDeclaredPrimaryKeys(collection);
|
|
@@ -2552,7 +2601,7 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
2552
2601
|
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
2553
2602
|
* the collection's RLS.
|
|
2554
2603
|
*/
|
|
2555
|
-
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2604
|
+
var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2556
2605
|
/** Write operations that must be admin-gated by default on auth collections. */
|
|
2557
2606
|
var DEFAULT_GUARDED_OPS = [
|
|
2558
2607
|
"insert",
|
|
@@ -2565,7 +2614,7 @@ function isAuthCollection(collection) {
|
|
|
2565
2614
|
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
2566
2615
|
}
|
|
2567
2616
|
/** The property marked as the row id (falls back to `id`). */
|
|
2568
|
-
function getIdPropertyName(collection) {
|
|
2617
|
+
function getIdPropertyName$1(collection) {
|
|
2569
2618
|
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2570
2619
|
return "id";
|
|
2571
2620
|
}
|
|
@@ -2585,30 +2634,232 @@ function getEffectiveSecurityRules(collection) {
|
|
|
2585
2634
|
injected.push({
|
|
2586
2635
|
name: `${tableName}_default_admin_read`,
|
|
2587
2636
|
operations: ["select"],
|
|
2588
|
-
condition: SERVER_OR_ADMIN_EXPR
|
|
2637
|
+
condition: SERVER_OR_ADMIN_EXPR$1
|
|
2589
2638
|
});
|
|
2590
2639
|
injected.push({
|
|
2591
2640
|
name: `${tableName}_default_admin_write`,
|
|
2592
2641
|
operations: [...DEFAULT_GUARDED_OPS],
|
|
2593
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
2594
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
2642
|
+
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2643
|
+
check: SERVER_OR_ADMIN_EXPR$1
|
|
2595
2644
|
});
|
|
2596
2645
|
if (isAuthCollection(collection)) {
|
|
2597
2646
|
injected.push({
|
|
2598
2647
|
name: `${tableName}_default_self_read`,
|
|
2599
2648
|
operations: ["select"],
|
|
2600
|
-
condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
|
|
2649
|
+
condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
|
|
2601
2650
|
});
|
|
2602
2651
|
injected.push({
|
|
2603
2652
|
name: `${tableName}_require_admin_write`,
|
|
2604
2653
|
mode: "restrictive",
|
|
2605
2654
|
operations: [...DEFAULT_GUARDED_OPS],
|
|
2606
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
2607
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
2655
|
+
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2656
|
+
check: SERVER_OR_ADMIN_EXPR$1
|
|
2608
2657
|
});
|
|
2609
2658
|
}
|
|
2610
2659
|
return [...explicit, ...injected];
|
|
2611
2660
|
}
|
|
2661
|
+
//#endregion
|
|
2662
|
+
//#region ../common/src/util/junction-policies.ts
|
|
2663
|
+
var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2664
|
+
/**
|
|
2665
|
+
* Walk every collection's resolved relations and aggregate the junction tables
|
|
2666
|
+
* they declare. Two collections may declare the same junction from opposite
|
|
2667
|
+
* sides (posts→tags and tags→posts through `posts_tags`); both become
|
|
2668
|
+
* `declaringSides` of one spec, so derived write grants consider both.
|
|
2669
|
+
*/
|
|
2670
|
+
function resolveJunctionSpecs(collections) {
|
|
2671
|
+
const specs = /* @__PURE__ */ new Map();
|
|
2672
|
+
for (const collection of collections) {
|
|
2673
|
+
const resolved = resolveCollectionRelations(collection);
|
|
2674
|
+
for (const relation of Object.values(resolved)) {
|
|
2675
|
+
if (!relation.through) continue;
|
|
2676
|
+
const targetCollection = typeof relation.target === "function" ? relation.target() : void 0;
|
|
2677
|
+
if (!targetCollection) continue;
|
|
2678
|
+
const rawName = relation.through.table;
|
|
2679
|
+
const table = rawName.includes(".") ? rawName.split(".").pop() : rawName;
|
|
2680
|
+
const schema = "public";
|
|
2681
|
+
const source = {
|
|
2682
|
+
collection,
|
|
2683
|
+
junctionColumn: relation.through.sourceColumn,
|
|
2684
|
+
relation
|
|
2685
|
+
};
|
|
2686
|
+
const target = {
|
|
2687
|
+
collection: targetCollection,
|
|
2688
|
+
junctionColumn: relation.through.targetColumn
|
|
2689
|
+
};
|
|
2690
|
+
const existing = specs.get(table);
|
|
2691
|
+
if (!existing) specs.set(table, {
|
|
2692
|
+
table,
|
|
2693
|
+
schema,
|
|
2694
|
+
endpoints: [source, target],
|
|
2695
|
+
declaringSides: [source]
|
|
2696
|
+
});
|
|
2697
|
+
else if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
return specs;
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* A synthetic CollectionConfig standing in for the junction during policy
|
|
2704
|
+
* compilation and naming. Its two FK columns carry explicit `columnName`s so
|
|
2705
|
+
* `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
|
|
2706
|
+
* whatever their casing.
|
|
2707
|
+
*/
|
|
2708
|
+
function getJunctionCollectionConfig(spec) {
|
|
2709
|
+
const properties = {};
|
|
2710
|
+
for (const endpoint of spec.endpoints) properties[endpoint.junctionColumn] = {
|
|
2711
|
+
type: "string",
|
|
2712
|
+
columnName: endpoint.junctionColumn
|
|
2713
|
+
};
|
|
2714
|
+
return {
|
|
2715
|
+
slug: spec.table,
|
|
2716
|
+
name: spec.table,
|
|
2717
|
+
table: spec.table,
|
|
2718
|
+
schema: spec.schema,
|
|
2719
|
+
properties
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
/** The property marked as the row id (falls back to `id`). */
|
|
2723
|
+
function getIdPropertyName(collection) {
|
|
2724
|
+
for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
|
|
2725
|
+
return "id";
|
|
2726
|
+
}
|
|
2727
|
+
/** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */
|
|
2728
|
+
function existsEndpoint(endpoint, extra) {
|
|
2729
|
+
const correlation = policy.compare(policy.field(getIdPropertyName(endpoint.collection)), "eq", policy.outerField(endpoint.junctionColumn));
|
|
2730
|
+
return policy.existsIn({
|
|
2731
|
+
collection: endpoint.collection.slug,
|
|
2732
|
+
where: extra ? policy.and(correlation, extra) : correlation
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Whether a parent-rule expression keeps its meaning when moved inside the
|
|
2737
|
+
* junction's `EXISTS` subquery — and the re-scoped copy if it does.
|
|
2738
|
+
*
|
|
2739
|
+
* Returns `null` when the rule cannot be embedded faithfully: `raw` SQL
|
|
2740
|
+
* anywhere (its `{column}` placeholders would bind to the junction), or an
|
|
2741
|
+
* `outerField` inside a nested `existsIn` (it would bind to the junction while
|
|
2742
|
+
* the author meant the parent, and no operand can express "the middle scope").
|
|
2743
|
+
* Top-level `outerField`s are rewritten to `field`, which is what they meant.
|
|
2744
|
+
*/
|
|
2745
|
+
function embedParentExpression(expr, depth = 0) {
|
|
2746
|
+
switch (expr.kind) {
|
|
2747
|
+
case "raw": return null;
|
|
2748
|
+
case "and":
|
|
2749
|
+
case "or": {
|
|
2750
|
+
const parts = [];
|
|
2751
|
+
for (const child of expr.operands) {
|
|
2752
|
+
const embedded = embedParentExpression(child, depth);
|
|
2753
|
+
if (!embedded) return null;
|
|
2754
|
+
parts.push(embedded);
|
|
2755
|
+
}
|
|
2756
|
+
return expr.kind === "and" ? policy.and(...parts) : policy.or(...parts);
|
|
2757
|
+
}
|
|
2758
|
+
case "not": {
|
|
2759
|
+
const embedded = embedParentExpression(expr.operand, depth);
|
|
2760
|
+
return embedded ? policy.not(embedded) : null;
|
|
2761
|
+
}
|
|
2762
|
+
case "existsIn": {
|
|
2763
|
+
const where = embedParentExpression(expr.where, depth + 1);
|
|
2764
|
+
return where ? policy.existsIn({
|
|
2765
|
+
collection: expr.collection,
|
|
2766
|
+
where
|
|
2767
|
+
}) : null;
|
|
2768
|
+
}
|
|
2769
|
+
case "compare": {
|
|
2770
|
+
const left = embedOperand(expr.left, depth);
|
|
2771
|
+
const right = embedOperand(expr.right, depth);
|
|
2772
|
+
if (!left || !right) return null;
|
|
2773
|
+
return {
|
|
2774
|
+
...expr,
|
|
2775
|
+
left,
|
|
2776
|
+
right
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
default: return expr;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
/** Re-scope an operand, or return `null` if its binding cannot be preserved. */
|
|
2783
|
+
function embedOperand(operand, depth) {
|
|
2784
|
+
if (operand.kind === "outerField") {
|
|
2785
|
+
if (depth === 0) return policy.field(operand.name);
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
return operand;
|
|
2789
|
+
}
|
|
2790
|
+
/** Does the rule cover the `update` operation? */
|
|
2791
|
+
function coversUpdate(rule) {
|
|
2792
|
+
return getPolicyOperations(rule).some((op) => op === "update" || op === "all");
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* The full derived policy set for a junction table: the locked server/admin
|
|
2796
|
+
* baseline, the endpoint-visibility read grant, inherited write grants, and
|
|
2797
|
+
* inherited restrictive gates. Returns `[]` when every declaring collection set
|
|
2798
|
+
* `disableDefaultPolicies` — the junction is then the author's to police, and
|
|
2799
|
+
* stays locked (RLS is still enabled) until they write policies for it.
|
|
2800
|
+
*/
|
|
2801
|
+
function getJunctionSecurityRules(spec) {
|
|
2802
|
+
if (spec.declaringSides.every((side) => side.collection.disableDefaultPolicies)) return [];
|
|
2803
|
+
const rules = [];
|
|
2804
|
+
rules.push({
|
|
2805
|
+
name: `${spec.table}_default_admin_read`,
|
|
2806
|
+
operations: ["select"],
|
|
2807
|
+
condition: SERVER_OR_ADMIN_EXPR
|
|
2808
|
+
});
|
|
2809
|
+
rules.push({
|
|
2810
|
+
name: `${spec.table}_default_admin_write`,
|
|
2811
|
+
operations: [
|
|
2812
|
+
"insert",
|
|
2813
|
+
"update",
|
|
2814
|
+
"delete"
|
|
2815
|
+
],
|
|
2816
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
2817
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
2818
|
+
});
|
|
2819
|
+
rules.push({
|
|
2820
|
+
name: `${spec.table}_default_edge_read`,
|
|
2821
|
+
operations: ["select"],
|
|
2822
|
+
condition: policy.and(existsEndpoint(spec.endpoints[0]), existsEndpoint(spec.endpoints[1]))
|
|
2823
|
+
});
|
|
2824
|
+
const writeGrants = [];
|
|
2825
|
+
for (const side of spec.declaringSides) {
|
|
2826
|
+
const updateRules = ((isPostgresCollectionConfig(side.collection) ? side.collection.securityRules : void 0) ?? []).filter(coversUpdate);
|
|
2827
|
+
const permissive = updateRules.filter((r) => r.mode !== "restrictive");
|
|
2828
|
+
const restrictive = updateRules.filter((r) => r.mode === "restrictive");
|
|
2829
|
+
const embeddedGates = [];
|
|
2830
|
+
let gatesEmbeddable = true;
|
|
2831
|
+
for (const gate of restrictive) {
|
|
2832
|
+
const using = securityRuleToConditions(gate).usingExpr;
|
|
2833
|
+
const embedded = using ? embedParentExpression(using) : null;
|
|
2834
|
+
if (!embedded) {
|
|
2835
|
+
gatesEmbeddable = false;
|
|
2836
|
+
break;
|
|
2837
|
+
}
|
|
2838
|
+
embeddedGates.push(embedded);
|
|
2839
|
+
}
|
|
2840
|
+
if (!gatesEmbeddable) continue;
|
|
2841
|
+
const grants = [];
|
|
2842
|
+
for (const rule of permissive) {
|
|
2843
|
+
const using = securityRuleToConditions(rule).usingExpr;
|
|
2844
|
+
const embedded = using ? embedParentExpression(using) : null;
|
|
2845
|
+
if (embedded) grants.push(embedded);
|
|
2846
|
+
}
|
|
2847
|
+
if (grants.length === 0) continue;
|
|
2848
|
+
const condition = embeddedGates.length > 0 ? policy.and(policy.or(...grants), ...embeddedGates) : policy.or(...grants);
|
|
2849
|
+
writeGrants.push(existsEndpoint(side, condition));
|
|
2850
|
+
}
|
|
2851
|
+
if (writeGrants.length > 0) rules.push({
|
|
2852
|
+
name: `${spec.table}_default_edge_write`,
|
|
2853
|
+
operations: [
|
|
2854
|
+
"insert",
|
|
2855
|
+
"update",
|
|
2856
|
+
"delete"
|
|
2857
|
+
],
|
|
2858
|
+
condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),
|
|
2859
|
+
check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)
|
|
2860
|
+
});
|
|
2861
|
+
return rules;
|
|
2862
|
+
}
|
|
2612
2863
|
(/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
2613
2864
|
(function(root, factory) {
|
|
2614
2865
|
if (typeof define === "function" && define.amd) define(factory);
|
|
@@ -3893,7 +4144,7 @@ function createPrimaryKeyResolver(options) {
|
|
|
3893
4144
|
}
|
|
3894
4145
|
if (!warned.has(slug)) {
|
|
3895
4146
|
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.`);
|
|
4147
|
+
console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
|
|
3897
4148
|
}
|
|
3898
4149
|
return keys;
|
|
3899
4150
|
};
|
|
@@ -4336,8 +4587,26 @@ function getTableForCollection(collection, registry) {
|
|
|
4336
4587
|
if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
|
|
4337
4588
|
return table;
|
|
4338
4589
|
}
|
|
4590
|
+
/**
|
|
4591
|
+
* The key columns a collection's rows are addressed by.
|
|
4592
|
+
*
|
|
4593
|
+
* Three tiers, in order: properties marked `isId`, the primary keys of the
|
|
4594
|
+
* drizzle schema, and finally a column literally named `id`. Only the first is
|
|
4595
|
+
* visible to the browser, which is why a key known only to drizzle is reported
|
|
4596
|
+
* at boot — see {@link warnOnKeysTheAdminCannotResolve}.
|
|
4597
|
+
*
|
|
4598
|
+
* Returns `[]` when nothing resolves, rather than throwing. It used to open by
|
|
4599
|
+
* resolving the table, which throws when there is none — so the `isId` tier,
|
|
4600
|
+
* which needs no table at all, was unreachable for exactly the collections
|
|
4601
|
+
* most likely to have no table registered. Every caller that wanted "no keys"
|
|
4602
|
+
* to mean "no keys" had to spell that out in a try/catch.
|
|
4603
|
+
*
|
|
4604
|
+
* Callers that cannot proceed without a key must say so themselves, naming the
|
|
4605
|
+
* collection: an empty array here means "this collection has no address", which
|
|
4606
|
+
* is a different answer in a notification (broadcast a wildcard) than in a save
|
|
4607
|
+
* (fail).
|
|
4608
|
+
*/
|
|
4339
4609
|
function getPrimaryKeys(collection, registry) {
|
|
4340
|
-
const table = getTableForCollection(collection, registry);
|
|
4341
4610
|
if (collection.properties) {
|
|
4342
4611
|
const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
|
|
4343
4612
|
fieldName: key,
|
|
@@ -4346,6 +4615,8 @@ function getPrimaryKeys(collection, registry) {
|
|
|
4346
4615
|
}));
|
|
4347
4616
|
if (idProps.length > 0) return idProps;
|
|
4348
4617
|
}
|
|
4618
|
+
const table = registry.getTable(getTableName$1(collection));
|
|
4619
|
+
if (!table) return [];
|
|
4349
4620
|
const keys = [];
|
|
4350
4621
|
for (const [key, colRaw] of Object.entries(table)) {
|
|
4351
4622
|
const col = colRaw;
|
|
@@ -4373,6 +4644,90 @@ function getPrimaryKeys(collection, registry) {
|
|
|
4373
4644
|
}
|
|
4374
4645
|
return keys;
|
|
4375
4646
|
}
|
|
4647
|
+
/**
|
|
4648
|
+
* The key columns, for callers that cannot do their job without one.
|
|
4649
|
+
*
|
|
4650
|
+
* {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
|
|
4651
|
+
* collection with no address. Most of this driver, though, is building a WHERE
|
|
4652
|
+
* clause and has no meaning without a key — for those, an empty array is not an
|
|
4653
|
+
* answer, and indexing `[0]` into it produces `Cannot read properties of
|
|
4654
|
+
* undefined` three frames from where the real problem is. This says what is
|
|
4655
|
+
* wrong and which collection it is wrong about.
|
|
4656
|
+
*/
|
|
4657
|
+
function requirePrimaryKeys(collection, registry) {
|
|
4658
|
+
const keys = getPrimaryKeys(collection, registry);
|
|
4659
|
+
if (keys.length === 0) throw new Error(`Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`);
|
|
4660
|
+
return keys;
|
|
4661
|
+
}
|
|
4662
|
+
/**
|
|
4663
|
+
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
4664
|
+
* instead.
|
|
4665
|
+
*
|
|
4666
|
+
* The two sides resolve keys from different evidence. This driver reads, in
|
|
4667
|
+
* order: properties marked `isId`, the primary keys of the Drizzle schema, then
|
|
4668
|
+
* a column literally named `id`. The admin shares the `CollectionConfig` — it
|
|
4669
|
+
* compiles the same collection files into its bundle — but never the Drizzle
|
|
4670
|
+
* schema, so the middle tier is invisible to it.
|
|
4671
|
+
*
|
|
4672
|
+
* Nothing can normalize this at runtime: the server does not serve the admin
|
|
4673
|
+
* its collections, so a key resolved here cannot be handed over there. The
|
|
4674
|
+
* config files are the only thing both sides read, so the fix is an edit to
|
|
4675
|
+
* them, and the most this can do is say exactly which edit.
|
|
4676
|
+
*
|
|
4677
|
+
* Two shapes, and the second is the dangerous one:
|
|
4678
|
+
*
|
|
4679
|
+
* - No `isId`, no `id` property → the admin resolves no address, warns in the
|
|
4680
|
+
* console, and rows cannot be opened or linked.
|
|
4681
|
+
* - No `isId`, but an `id` property that is *not* the key → the admin addresses
|
|
4682
|
+
* rows by `id` while this driver reads the address as the real key. Nothing
|
|
4683
|
+
* errors: the addresses look right and route wrong.
|
|
4684
|
+
*/
|
|
4685
|
+
function findUnresolvableKeyCollections(collections, registry) {
|
|
4686
|
+
const findings = [];
|
|
4687
|
+
for (const collection of collections) {
|
|
4688
|
+
if (getDeclaredPrimaryKeys(collection).length > 0) continue;
|
|
4689
|
+
const keys = getPrimaryKeys(collection, registry);
|
|
4690
|
+
if (keys.length === 0) continue;
|
|
4691
|
+
if (keys.length === 1 && keys[0].fieldName === "id") continue;
|
|
4692
|
+
findings.push({
|
|
4693
|
+
collection,
|
|
4694
|
+
keys,
|
|
4695
|
+
shadowedByIdProperty: Boolean(collection.properties?.id)
|
|
4696
|
+
});
|
|
4697
|
+
}
|
|
4698
|
+
return findings;
|
|
4699
|
+
}
|
|
4700
|
+
/**
|
|
4701
|
+
* Report the collections from {@link findUnresolvableKeyCollections} at boot,
|
|
4702
|
+
* with the edit that fixes each one.
|
|
4703
|
+
*
|
|
4704
|
+
* Grouped by failure, not by collection: the shadowed case is a routing bug and
|
|
4705
|
+
* the silent case is a missing feature, and they deserve different urgency.
|
|
4706
|
+
*/
|
|
4707
|
+
function warnOnKeysTheAdminCannotResolve(collections, registry) {
|
|
4708
|
+
const findings = findUnresolvableKeyCollections(collections, registry);
|
|
4709
|
+
if (findings.length === 0) return;
|
|
4710
|
+
const edit = (f) => `${f.collection.slug}: mark ${f.keys.map((k) => `\`${k.fieldName}\``).join(" and ")} with \`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
|
|
4711
|
+
const shadowed = findings.filter((f) => f.shadowedByIdProperty);
|
|
4712
|
+
const silent = findings.filter((f) => !f.shadowedByIdProperty);
|
|
4713
|
+
if (shadowed.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema — but they do have a property called `id`. The admin has no way to know `id` is not the key, so it will address rows by it while this server reads the address as the real key: the links look right and route wrong. Nothing will error.\n\n" + shadowed.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
|
|
4714
|
+
if (silent.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema, which the admin never sees. It will resolve no address for their rows, so detail links, caching and relations will not work for them:\n\n" + silent.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
|
|
4715
|
+
}
|
|
4716
|
+
/**
|
|
4717
|
+
* The address of a row: derived from the collection's primary keys, because a
|
|
4718
|
+
* row does not carry one — it is exactly its columns.
|
|
4719
|
+
*
|
|
4720
|
+
* Falls back to a literal `id` column, for a row that reached us from somewhere
|
|
4721
|
+
* other than this driver. Returns `""` when there is no key and no `id` —
|
|
4722
|
+
* callers decide what that means, since "unaddressable" is a different answer
|
|
4723
|
+
* in a notification (broadcast a wildcard) than in a save (fail).
|
|
4724
|
+
*/
|
|
4725
|
+
function deriveRowAddress(row, collection, registry) {
|
|
4726
|
+
const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
|
|
4727
|
+
if (composite && composite.split(":::").some((part) => part !== "")) return composite;
|
|
4728
|
+
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
4729
|
+
return "";
|
|
4730
|
+
}
|
|
4376
4731
|
//#endregion
|
|
4377
4732
|
//#region src/utils/drizzle-conditions.ts
|
|
4378
4733
|
/**
|
|
@@ -4911,6 +5266,7 @@ var DrizzleConditionBuilder = class {
|
|
|
4911
5266
|
static buildVectorSearchConditions(table, vectorSearch) {
|
|
4912
5267
|
const column = table[vectorSearch.property];
|
|
4913
5268
|
if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
5269
|
+
if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
4914
5270
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
4915
5271
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
4916
5272
|
let operator;
|
|
@@ -4994,12 +5350,10 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
4994
5350
|
continue;
|
|
4995
5351
|
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
4996
5352
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
4997
|
-
const pks = getPrimaryKeys(collection, registry);
|
|
4998
5353
|
inverseRelationUpdates.push({
|
|
4999
5354
|
relationKey: key,
|
|
5000
5355
|
relation,
|
|
5001
|
-
newValue: serializedValue
|
|
5002
|
-
currentId: row.id || buildCompositeId(row, pks)
|
|
5356
|
+
newValue: serializedValue
|
|
5003
5357
|
});
|
|
5004
5358
|
continue;
|
|
5005
5359
|
} else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
@@ -5009,15 +5363,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
5009
5363
|
relation,
|
|
5010
5364
|
newTargetId: serializedValue
|
|
5011
5365
|
});
|
|
5012
|
-
else {
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
newValue: serializedValue,
|
|
5018
|
-
currentId: row.id || buildCompositeId(row, pks)
|
|
5019
|
-
});
|
|
5020
|
-
}
|
|
5366
|
+
else inverseRelationUpdates.push({
|
|
5367
|
+
relationKey: key,
|
|
5368
|
+
relation,
|
|
5369
|
+
newValue: serializedValue
|
|
5370
|
+
});
|
|
5021
5371
|
continue;
|
|
5022
5372
|
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
5023
5373
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
@@ -5417,6 +5767,63 @@ var RelationService = class {
|
|
|
5417
5767
|
this.registry = registry;
|
|
5418
5768
|
}
|
|
5419
5769
|
/**
|
|
5770
|
+
* One target row, as the {@link RelatedRow} everything here returns.
|
|
5771
|
+
*
|
|
5772
|
+
* Eight sites built this by hand, which is how the address came to be the
|
|
5773
|
+
* target's first key column in all eight — one edit, eight places to miss.
|
|
5774
|
+
*
|
|
5775
|
+
* `resolveNested` is the one thing they did not agree on, and the
|
|
5776
|
+
* disagreement was invisible: the single-parent fetches pass `db` and
|
|
5777
|
+
* `registry` to `parseDataFromServer`, so the target's *own* relations get
|
|
5778
|
+
* resolved too, while the batch paths deliberately do not — a query per
|
|
5779
|
+
* target row is the N+1 the batching exists to avoid. Naming the parameter
|
|
5780
|
+
* makes that a decision rather than a difference between two call sites
|
|
5781
|
+
* nobody was comparing.
|
|
5782
|
+
*/
|
|
5783
|
+
async toRelatedRow(targetRow, targetCollection, targetPks, options) {
|
|
5784
|
+
const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
|
|
5785
|
+
return {
|
|
5786
|
+
id: buildCompositeId(targetRow, targetPks),
|
|
5787
|
+
path: targetCollection.slug,
|
|
5788
|
+
values
|
|
5789
|
+
};
|
|
5790
|
+
}
|
|
5791
|
+
/**
|
|
5792
|
+
* A WHERE matching any of `parentIds`, by the whole key.
|
|
5793
|
+
*
|
|
5794
|
+
* A single key is an `IN (…)`. A composite one cannot be: matching
|
|
5795
|
+
* `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
|
|
5796
|
+
* share their first column each receive the other's relations. It becomes
|
|
5797
|
+
* an OR of ANDs — one exact address per parent — which Postgres indexes the
|
|
5798
|
+
* same way it would a multi-column key lookup.
|
|
5799
|
+
*/
|
|
5800
|
+
parentKeyCondition(parentTable, parentPks, parentIds) {
|
|
5801
|
+
const columnFor = (fieldName) => {
|
|
5802
|
+
const col = parentTable[fieldName];
|
|
5803
|
+
if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
|
|
5804
|
+
return col;
|
|
5805
|
+
};
|
|
5806
|
+
if (parentPks.length === 1) {
|
|
5807
|
+
const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
|
|
5808
|
+
return inArray(columnFor(parentPks[0].fieldName), values);
|
|
5809
|
+
}
|
|
5810
|
+
return or(...parentIds.map((id) => {
|
|
5811
|
+
const values = parseIdValues(id, parentPks);
|
|
5812
|
+
return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
|
|
5813
|
+
}));
|
|
5814
|
+
}
|
|
5815
|
+
/**
|
|
5816
|
+
* Reject a relation that cannot express a composite-keyed parent.
|
|
5817
|
+
*
|
|
5818
|
+
* `localKey` and `foreignKeyOnTarget` are single column names: one column
|
|
5819
|
+
* cannot reference a two-column key, so such a relation has no correct
|
|
5820
|
+
* reading. Left alone it would silently match on the first key column and
|
|
5821
|
+
* hand a tenant's rows to its neighbour — say so instead.
|
|
5822
|
+
*/
|
|
5823
|
+
assertSingleKeyAddressable(parentCollection, parentPks, via) {
|
|
5824
|
+
if (parentPks.length > 1) throw new Error(`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but '${parentCollection.slug}' is keyed on ${parentPks.map((k) => `'${k.fieldName}'`).join(" + ")}. One column cannot reference a composite key — express this relation with \`joinPath\`, whose \`on.from\`/\`on.to\` take every key column.`);
|
|
5825
|
+
}
|
|
5826
|
+
/**
|
|
5420
5827
|
* Fetch rows related to a parent row through a specific relation
|
|
5421
5828
|
*/
|
|
5422
5829
|
async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
|
|
@@ -5435,9 +5842,9 @@ var RelationService = class {
|
|
|
5435
5842
|
async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
|
|
5436
5843
|
const targetCollection = relation.target();
|
|
5437
5844
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5438
|
-
const idInfo =
|
|
5845
|
+
const idInfo = requirePrimaryKeys(targetCollection, this.registry);
|
|
5439
5846
|
const idField = targetTable[idInfo[0].fieldName];
|
|
5440
|
-
const parentPks =
|
|
5847
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5441
5848
|
const parentIdInfo = parentPks[0];
|
|
5442
5849
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5443
5850
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5461,7 +5868,7 @@ var RelationService = class {
|
|
|
5461
5868
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5462
5869
|
currentTable = joinTable;
|
|
5463
5870
|
}
|
|
5464
|
-
const parentIdField = parentTable[
|
|
5871
|
+
const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
|
|
5465
5872
|
query = query.where(eq(parentIdField, parsedParentId));
|
|
5466
5873
|
if (options.limit) query = query.limit(options.limit);
|
|
5467
5874
|
const results = await query;
|
|
@@ -5469,13 +5876,7 @@ var RelationService = class {
|
|
|
5469
5876
|
const rows = [];
|
|
5470
5877
|
for (const row of results) {
|
|
5471
5878
|
const targetRow = row[targetTableName] || row;
|
|
5472
|
-
|
|
5473
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
5474
|
-
rows.push({
|
|
5475
|
-
id: id?.toString() || "",
|
|
5476
|
-
path: targetCollection.slug,
|
|
5477
|
-
values: parsedValues
|
|
5478
|
-
});
|
|
5879
|
+
rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
|
|
5479
5880
|
}
|
|
5480
5881
|
return rows;
|
|
5481
5882
|
}
|
|
@@ -5494,13 +5895,7 @@ var RelationService = class {
|
|
|
5494
5895
|
const rows = [];
|
|
5495
5896
|
for (const row of results) {
|
|
5496
5897
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
5497
|
-
|
|
5498
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
5499
|
-
rows.push({
|
|
5500
|
-
id: id?.toString() || "",
|
|
5501
|
-
path: targetCollection.slug,
|
|
5502
|
-
values: parsedValues
|
|
5503
|
-
});
|
|
5898
|
+
rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
|
|
5504
5899
|
}
|
|
5505
5900
|
return rows;
|
|
5506
5901
|
}
|
|
@@ -5517,8 +5912,8 @@ var RelationService = class {
|
|
|
5517
5912
|
}
|
|
5518
5913
|
const targetCollection = relation.target();
|
|
5519
5914
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5520
|
-
const targetIdField = targetTable[
|
|
5521
|
-
const parentPks =
|
|
5915
|
+
const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
|
|
5916
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5522
5917
|
const parentIdInfo = parentPks[0];
|
|
5523
5918
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5524
5919
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
@@ -5537,9 +5932,10 @@ var RelationService = class {
|
|
|
5537
5932
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5538
5933
|
const targetCollection = relation.target();
|
|
5539
5934
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5540
|
-
const
|
|
5935
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5936
|
+
const targetIdInfo = targetPks[0];
|
|
5541
5937
|
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
5542
|
-
const parentPks =
|
|
5938
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5543
5939
|
const parentIdInfo = parentPks[0];
|
|
5544
5940
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
5545
5941
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -5563,25 +5959,19 @@ var RelationService = class {
|
|
|
5563
5959
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5564
5960
|
currentTable = joinTable;
|
|
5565
5961
|
}
|
|
5566
|
-
|
|
5567
|
-
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
5962
|
+
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
5568
5963
|
const results = await query;
|
|
5569
5964
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
5570
5965
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5571
5966
|
for (const row of results) {
|
|
5572
5967
|
const parentRow = row[getTableName$1(parentCollection)] || row;
|
|
5573
5968
|
const targetRow = row[targetTableName] || row;
|
|
5574
|
-
|
|
5575
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5576
|
-
resultMap.set(String(parentId), {
|
|
5577
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5578
|
-
path: targetCollection.slug,
|
|
5579
|
-
values: parsedValues
|
|
5580
|
-
});
|
|
5969
|
+
resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5581
5970
|
}
|
|
5582
5971
|
return resultMap;
|
|
5583
5972
|
}
|
|
5584
5973
|
if (relation.direction === "owning" && relation.localKey) {
|
|
5974
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
5585
5975
|
const localKeyCol = parentTable[relation.localKey];
|
|
5586
5976
|
if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
5587
5977
|
const fkRows = await this.db.select({
|
|
@@ -5610,17 +6000,11 @@ var RelationService = class {
|
|
|
5610
6000
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5611
6001
|
for (const [parentIdStr, fkValue] of parentToFk) {
|
|
5612
6002
|
const targetRow = targetById.get(String(fkValue));
|
|
5613
|
-
if (targetRow)
|
|
5614
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5615
|
-
resultMap.set(parentIdStr, {
|
|
5616
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5617
|
-
path: targetCollection.slug,
|
|
5618
|
-
values: parsedValues
|
|
5619
|
-
});
|
|
5620
|
-
}
|
|
6003
|
+
if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5621
6004
|
}
|
|
5622
6005
|
return resultMap;
|
|
5623
6006
|
}
|
|
6007
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
|
|
5624
6008
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
5625
6009
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
5626
6010
|
const results = await query;
|
|
@@ -5631,14 +6015,7 @@ var RelationService = class {
|
|
|
5631
6015
|
let parentId;
|
|
5632
6016
|
if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
5633
6017
|
else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
5634
|
-
if (parentId !== void 0 && parentIdSet.has(String(parentId)))
|
|
5635
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5636
|
-
resultMap.set(String(parentId), {
|
|
5637
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5638
|
-
path: targetCollection.slug,
|
|
5639
|
-
values: parsedValues
|
|
5640
|
-
});
|
|
5641
|
-
}
|
|
6018
|
+
if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5642
6019
|
}
|
|
5643
6020
|
return resultMap;
|
|
5644
6021
|
}
|
|
@@ -5652,9 +6029,9 @@ var RelationService = class {
|
|
|
5652
6029
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
5653
6030
|
const targetCollection = relation.target();
|
|
5654
6031
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5655
|
-
const
|
|
5656
|
-
const targetIdField = targetTable[
|
|
5657
|
-
const parentPks =
|
|
6032
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
6033
|
+
const targetIdField = targetTable[targetPks[0].fieldName];
|
|
6034
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5658
6035
|
const parentIdInfo = parentPks[0];
|
|
5659
6036
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
5660
6037
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -5676,27 +6053,22 @@ var RelationService = class {
|
|
|
5676
6053
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
5677
6054
|
currentTable = joinTable;
|
|
5678
6055
|
}
|
|
5679
|
-
|
|
5680
|
-
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
6056
|
+
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
5681
6057
|
const results = await query;
|
|
5682
6058
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
5683
6059
|
const resultMap = /* @__PURE__ */ new Map();
|
|
5684
6060
|
for (const row of results) {
|
|
5685
6061
|
const parentRow = row[getTableName$1(parentCollection)] || row;
|
|
5686
6062
|
const targetRow = row[targetTableName] || row;
|
|
5687
|
-
const parentId =
|
|
5688
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
6063
|
+
const parentId = buildCompositeId(parentRow, parentPks);
|
|
5689
6064
|
const arr = resultMap.get(parentId) || [];
|
|
5690
|
-
arr.push(
|
|
5691
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5692
|
-
path: targetCollection.slug,
|
|
5693
|
-
values: parsedValues
|
|
5694
|
-
});
|
|
6065
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5695
6066
|
resultMap.set(parentId, arr);
|
|
5696
6067
|
}
|
|
5697
6068
|
return resultMap;
|
|
5698
6069
|
}
|
|
5699
6070
|
if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
|
|
6071
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
5700
6072
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
5701
6073
|
if (!junctionTable) {
|
|
5702
6074
|
logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
@@ -5715,17 +6087,13 @@ var RelationService = class {
|
|
|
5715
6087
|
const junctionData = row[relation.through.table] || row;
|
|
5716
6088
|
const targetData = row[targetTableName] || row;
|
|
5717
6089
|
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
5718
|
-
const parsedValues = await parseDataFromServer(targetData, targetCollection);
|
|
5719
6090
|
const arr = resultMap.get(parentId) || [];
|
|
5720
|
-
arr.push(
|
|
5721
|
-
id: String(targetData[targetIdInfo.fieldName]),
|
|
5722
|
-
path: targetCollection.slug,
|
|
5723
|
-
values: parsedValues
|
|
5724
|
-
});
|
|
6091
|
+
arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
|
|
5725
6092
|
resultMap.set(parentId, arr);
|
|
5726
6093
|
}
|
|
5727
6094
|
return resultMap;
|
|
5728
6095
|
}
|
|
6096
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
|
|
5729
6097
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
5730
6098
|
query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
5731
6099
|
const results = await query;
|
|
@@ -5738,14 +6106,9 @@ var RelationService = class {
|
|
|
5738
6106
|
else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
|
|
5739
6107
|
else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
|
|
5740
6108
|
if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
|
|
5741
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
5742
6109
|
const key = String(parentId);
|
|
5743
6110
|
const arr = resultMap.get(key) || [];
|
|
5744
|
-
arr.push(
|
|
5745
|
-
id: String(targetRow[targetIdInfo.fieldName]),
|
|
5746
|
-
path: targetCollection.slug,
|
|
5747
|
-
values: parsedValues
|
|
5748
|
-
});
|
|
6111
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
5749
6112
|
resultMap.set(key, arr);
|
|
5750
6113
|
}
|
|
5751
6114
|
}
|
|
@@ -5793,12 +6156,12 @@ var RelationService = class {
|
|
|
5793
6156
|
logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
|
|
5794
6157
|
continue;
|
|
5795
6158
|
}
|
|
5796
|
-
const parentPks =
|
|
6159
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5797
6160
|
const parentIdInfo = parentPks[0];
|
|
5798
6161
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5799
6162
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
5800
6163
|
if (targetEntityIds.length > 0) {
|
|
5801
|
-
const targetPks =
|
|
6164
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5802
6165
|
const targetIdInfo = targetPks[0];
|
|
5803
6166
|
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5804
6167
|
[sourceJunctionColumn.name]: parsedParentId,
|
|
@@ -5818,12 +6181,12 @@ var RelationService = class {
|
|
|
5818
6181
|
logger.warn(`Junction columns not found for relation '${key}'`);
|
|
5819
6182
|
continue;
|
|
5820
6183
|
}
|
|
5821
|
-
const parentPks =
|
|
6184
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5822
6185
|
const parentIdInfo = parentPks[0];
|
|
5823
6186
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5824
6187
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
5825
6188
|
if (targetEntityIds.length > 0) {
|
|
5826
|
-
const targetPks =
|
|
6189
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5827
6190
|
const targetIdInfo = targetPks[0];
|
|
5828
6191
|
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5829
6192
|
[sourceJunctionColumn.name]: parsedParentId,
|
|
@@ -5834,7 +6197,7 @@ var RelationService = class {
|
|
|
5834
6197
|
} else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
|
|
5835
6198
|
else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
5836
6199
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5837
|
-
const targetPks =
|
|
6200
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5838
6201
|
const targetIdInfo = targetPks[0];
|
|
5839
6202
|
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
5840
6203
|
const fkCol = targetTable[relation.foreignKeyOnTarget];
|
|
@@ -5842,7 +6205,7 @@ var RelationService = class {
|
|
|
5842
6205
|
logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
5843
6206
|
continue;
|
|
5844
6207
|
}
|
|
5845
|
-
const parentPks =
|
|
6208
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
5846
6209
|
const parentIdInfo = parentPks[0];
|
|
5847
6210
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
5848
6211
|
if (targetEntityIds.length > 0) {
|
|
@@ -5862,9 +6225,9 @@ var RelationService = class {
|
|
|
5862
6225
|
try {
|
|
5863
6226
|
const targetCollection = relation.target();
|
|
5864
6227
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5865
|
-
const targetPks =
|
|
6228
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5866
6229
|
const targetIdInfo = targetPks[0];
|
|
5867
|
-
const sourcePks =
|
|
6230
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5868
6231
|
const sourceIdInfo = sourcePks[0];
|
|
5869
6232
|
if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
5870
6233
|
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
@@ -5945,12 +6308,12 @@ var RelationService = class {
|
|
|
5945
6308
|
logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
|
|
5946
6309
|
return;
|
|
5947
6310
|
}
|
|
5948
|
-
const sourcePks =
|
|
6311
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5949
6312
|
const sourceIdInfo = sourcePks[0];
|
|
5950
6313
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
5951
6314
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
5952
6315
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
5953
|
-
const targetPks =
|
|
6316
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5954
6317
|
const targetIdInfo = targetPks[0];
|
|
5955
6318
|
const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
5956
6319
|
[sourceJunctionColumn.name]: parsedSourceId,
|
|
@@ -5958,7 +6321,7 @@ var RelationService = class {
|
|
|
5958
6321
|
}));
|
|
5959
6322
|
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
5960
6323
|
} else if (newValue && !Array.isArray(newValue)) {
|
|
5961
|
-
const targetPks =
|
|
6324
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5962
6325
|
const targetIdInfo = targetPks[0];
|
|
5963
6326
|
const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
|
|
5964
6327
|
const newLink = {
|
|
@@ -5989,12 +6352,12 @@ var RelationService = class {
|
|
|
5989
6352
|
logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
|
|
5990
6353
|
return;
|
|
5991
6354
|
}
|
|
5992
|
-
const sourcePks =
|
|
6355
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
5993
6356
|
const sourceIdInfo = sourcePks[0];
|
|
5994
6357
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
5995
6358
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
5996
6359
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
5997
|
-
const targetPks =
|
|
6360
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
5998
6361
|
const targetIdInfo = targetPks[0];
|
|
5999
6362
|
const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
6000
6363
|
[sourceJunctionColumn.name]: parsedSourceId,
|
|
@@ -6015,12 +6378,12 @@ var RelationService = class {
|
|
|
6015
6378
|
const { relation, newTargetId } = upd;
|
|
6016
6379
|
const targetCollection = relation.target();
|
|
6017
6380
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
6018
|
-
const targetPks =
|
|
6381
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
6019
6382
|
const targetIdInfo = targetPks[0];
|
|
6020
6383
|
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
6021
6384
|
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
6022
6385
|
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
6023
|
-
const parentPks =
|
|
6386
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
6024
6387
|
const parentIdInfo = parentPks[0];
|
|
6025
6388
|
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
6026
6389
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
@@ -6091,7 +6454,7 @@ var RelationService = class {
|
|
|
6091
6454
|
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
6092
6455
|
return;
|
|
6093
6456
|
}
|
|
6094
|
-
const targetPks =
|
|
6457
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
6095
6458
|
const targetIdInfo = targetPks[0];
|
|
6096
6459
|
const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
|
|
6097
6460
|
const junctionData = {
|
|
@@ -6107,6 +6470,131 @@ var RelationService = class {
|
|
|
6107
6470
|
}
|
|
6108
6471
|
};
|
|
6109
6472
|
//#endregion
|
|
6473
|
+
//#region src/services/row-pipeline.ts
|
|
6474
|
+
/**
|
|
6475
|
+
* Whether a many-relation reaches its target through a junction table.
|
|
6476
|
+
*
|
|
6477
|
+
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
6478
|
+
* the query has to nest one level deeper for a junction, and the row walk has
|
|
6479
|
+
* to unwrap that same level back out.
|
|
6480
|
+
*/
|
|
6481
|
+
function isJunctionRelation(relation) {
|
|
6482
|
+
if (relation.through) return true;
|
|
6483
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
6484
|
+
return false;
|
|
6485
|
+
}
|
|
6486
|
+
/**
|
|
6487
|
+
* Reach the target row inside a junction row.
|
|
6488
|
+
*
|
|
6489
|
+
* A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
|
|
6490
|
+
* the foreign keys, and the target nested under one of them. The target is the
|
|
6491
|
+
* only object among them, so that is how it is found. A junction row that has
|
|
6492
|
+
* not been nested (no `with` on the join) has no object and is returned as-is.
|
|
6493
|
+
*/
|
|
6494
|
+
function unwrapJunctionRow(item) {
|
|
6495
|
+
const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
|
|
6496
|
+
return nestedKey ? item[nestedKey] : item;
|
|
6497
|
+
}
|
|
6498
|
+
/**
|
|
6499
|
+
* Give back the number a `number` property was declared to be.
|
|
6500
|
+
*
|
|
6501
|
+
* Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
|
|
6502
|
+
* numeric can hold more precision than a double. But the collection declared
|
|
6503
|
+
* this property `number` and the OpenAPI spec this server publishes says
|
|
6504
|
+
* `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
|
|
6505
|
+
* asymmetrically, because a create answers with the number and the read that
|
|
6506
|
+
* follows answers with the string. Any client that multiplies a price works
|
|
6507
|
+
* until the first refresh.
|
|
6508
|
+
*
|
|
6509
|
+
* Declaring a property `number` already accepts double precision — that is what
|
|
6510
|
+
* the admin has always parsed it to. Columns REST serves that no property
|
|
6511
|
+
* declares are left exactly as the database returned them.
|
|
6512
|
+
*/
|
|
6513
|
+
function coerceDeclaredNumber(value, property) {
|
|
6514
|
+
if (property?.type !== "number" || typeof value !== "string") return value;
|
|
6515
|
+
const parsed = parseFloat(value);
|
|
6516
|
+
return isNaN(parsed) ? null : parsed;
|
|
6517
|
+
}
|
|
6518
|
+
/** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
|
|
6519
|
+
function coerceDeclaredNumbers(row, collection) {
|
|
6520
|
+
const properties = collection.properties;
|
|
6521
|
+
if (!properties) return row;
|
|
6522
|
+
const out = {};
|
|
6523
|
+
for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
|
|
6524
|
+
return out;
|
|
6525
|
+
}
|
|
6526
|
+
/** Render one target row in the requested style. */
|
|
6527
|
+
function renderTarget(targetRow, targetCollection, style, registry) {
|
|
6528
|
+
if (style === "inline") return coerceDeclaredNumbers({ ...targetRow }, targetCollection);
|
|
6529
|
+
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
6530
|
+
const path = targetCollection.slug;
|
|
6531
|
+
return createRelationRefWithData(address, path, {
|
|
6532
|
+
id: address,
|
|
6533
|
+
path,
|
|
6534
|
+
values: normalizeDbValues(targetRow, targetCollection)
|
|
6535
|
+
});
|
|
6536
|
+
}
|
|
6537
|
+
/**
|
|
6538
|
+
* The address a relation ref points at.
|
|
6539
|
+
*
|
|
6540
|
+
* The whole key, not its first column: a composite-keyed target addressed by
|
|
6541
|
+
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
6542
|
+
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
6543
|
+
* array — taking down the parent's fetch over a relation it may not even have
|
|
6544
|
+
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
6545
|
+
* beats no rows at all.
|
|
6546
|
+
*/
|
|
6547
|
+
function relationTargetAddress(targetRow, targetCollection, registry) {
|
|
6548
|
+
const address = deriveRowAddress(targetRow, targetCollection, registry);
|
|
6549
|
+
if (address) return address;
|
|
6550
|
+
return String(targetRow[Object.keys(targetRow)[0]] ?? "");
|
|
6551
|
+
}
|
|
6552
|
+
/**
|
|
6553
|
+
* The row the admin renders: every column, with relations as references.
|
|
6554
|
+
*
|
|
6555
|
+
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
6556
|
+
* expects real types. The row's own address is *not* among the columns — it is
|
|
6557
|
+
* derived by the consumer from the collection's primary keys.
|
|
6558
|
+
*/
|
|
6559
|
+
function toCmsRow(row, collection, registry) {
|
|
6560
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6561
|
+
const normalized = normalizeDbValues(row, collection);
|
|
6562
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
6563
|
+
const relData = row[relation.relationName || key];
|
|
6564
|
+
if (relData === void 0 || relData === null) continue;
|
|
6565
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
6566
|
+
const targetCollection = relation.target();
|
|
6567
|
+
normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
|
|
6568
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
|
|
6569
|
+
}
|
|
6570
|
+
return normalized;
|
|
6571
|
+
}
|
|
6572
|
+
/**
|
|
6573
|
+
* The row REST serves: every column under its own name, with the value Postgres
|
|
6574
|
+
* returned, and relations inlined as the target's columns.
|
|
6575
|
+
*
|
|
6576
|
+
* Values are the ones the database returned, except where that contradicts the
|
|
6577
|
+
* declared type: a `number` property is served as a number (see
|
|
6578
|
+
* {@link coerceDeclaredNumber}). Dates stay as the database returned them —
|
|
6579
|
+
* JSON has its own opinions about dates that the admin's view-model does not
|
|
6580
|
+
* share.
|
|
6581
|
+
*
|
|
6582
|
+
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
6583
|
+
* the relations `include` asked for, so the row is the authority on which are
|
|
6584
|
+
* actually there.
|
|
6585
|
+
*/
|
|
6586
|
+
function toRestRow(row, collection, registry) {
|
|
6587
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6588
|
+
const flat = {};
|
|
6589
|
+
for (const [key, value] of Object.entries(row)) {
|
|
6590
|
+
const relation = findRelation(resolvedRelations, key);
|
|
6591
|
+
if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
|
|
6592
|
+
else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
|
|
6593
|
+
else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
|
|
6594
|
+
}
|
|
6595
|
+
return flat;
|
|
6596
|
+
}
|
|
6597
|
+
//#endregion
|
|
6110
6598
|
//#region src/services/FetchService.ts
|
|
6111
6599
|
/**
|
|
6112
6600
|
* Service for handling all row read operations.
|
|
@@ -6165,7 +6653,7 @@ var FetchService = class {
|
|
|
6165
6653
|
if (!shouldInclude(key)) continue;
|
|
6166
6654
|
const drizzleRelName = relation.relationName || key;
|
|
6167
6655
|
if (relation.joinPath && relation.joinPath.length > 0) continue;
|
|
6168
|
-
if (relation.cardinality === "many" &&
|
|
6656
|
+
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
6169
6657
|
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
6170
6658
|
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
6171
6659
|
else withConfig[drizzleRelName] = true;
|
|
@@ -6174,14 +6662,6 @@ var FetchService = class {
|
|
|
6174
6662
|
return withConfig;
|
|
6175
6663
|
}
|
|
6176
6664
|
/**
|
|
6177
|
-
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
6178
|
-
*/
|
|
6179
|
-
isJunctionRelation(relation, _collection) {
|
|
6180
|
-
if (relation.through) return true;
|
|
6181
|
-
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
6182
|
-
return false;
|
|
6183
|
-
}
|
|
6184
|
-
/**
|
|
6185
6665
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
6186
6666
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
6187
6667
|
*/
|
|
@@ -6190,54 +6670,6 @@ var FetchService = class {
|
|
|
6190
6670
|
return null;
|
|
6191
6671
|
}
|
|
6192
6672
|
/**
|
|
6193
|
-
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
6194
|
-
* Handles:
|
|
6195
|
-
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
6196
|
-
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
6197
|
-
* - 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
|
-
*/
|
|
6202
|
-
drizzleResultToRow(row, collection) {
|
|
6203
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6204
|
-
const normalizedValues = normalizeDbValues(row, collection);
|
|
6205
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
6206
|
-
const relData = row[relation.relationName || key];
|
|
6207
|
-
if (relData === void 0 || relData === null) continue;
|
|
6208
|
-
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
6209
|
-
const targetCollection = relation.target();
|
|
6210
|
-
const targetPath = targetCollection.slug;
|
|
6211
|
-
const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
|
|
6212
|
-
normalizedValues[key] = relData.map((item) => {
|
|
6213
|
-
let targetRow = item;
|
|
6214
|
-
if (this.isJunctionRelation(relation, collection)) {
|
|
6215
|
-
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
6216
|
-
if (nestedKey) targetRow = item[nestedKey];
|
|
6217
|
-
}
|
|
6218
|
-
const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
|
|
6219
|
-
return createRelationRefWithData(relId, targetPath, {
|
|
6220
|
-
id: relId,
|
|
6221
|
-
path: targetPath,
|
|
6222
|
-
values: normalizeDbValues(targetRow, targetCollection)
|
|
6223
|
-
});
|
|
6224
|
-
});
|
|
6225
|
-
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
6226
|
-
const targetCollection = relation.target();
|
|
6227
|
-
const targetPath = targetCollection.slug;
|
|
6228
|
-
const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
|
|
6229
|
-
const relObj = relData;
|
|
6230
|
-
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
6231
|
-
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
6232
|
-
id: relId,
|
|
6233
|
-
path: targetPath,
|
|
6234
|
-
values: normalizeDbValues(relObj, targetCollection)
|
|
6235
|
-
});
|
|
6236
|
-
}
|
|
6237
|
-
}
|
|
6238
|
-
return normalizedValues;
|
|
6239
|
-
}
|
|
6240
|
-
/**
|
|
6241
6673
|
* Post-fetch joinPath relations for a single flat row.
|
|
6242
6674
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
6243
6675
|
* so they must be loaded separately after the primary query.
|
|
@@ -6258,31 +6690,6 @@ var FetchService = class {
|
|
|
6258
6690
|
await Promise.all(promises);
|
|
6259
6691
|
}
|
|
6260
6692
|
/**
|
|
6261
|
-
* Post-fetch joinPath relations for a batch of flat rows.
|
|
6262
|
-
* Uses batch fetching to avoid N+1 queries for list views.
|
|
6263
|
-
*/
|
|
6264
|
-
async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
|
|
6265
|
-
if (rows.length === 0) return;
|
|
6266
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6267
|
-
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
|
|
6268
|
-
if (joinPathRelations.length === 0) return;
|
|
6269
|
-
for (const [key, relation] of joinPathRelations) try {
|
|
6270
|
-
const rowIds = rows.map((r) => {
|
|
6271
|
-
return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
|
|
6272
|
-
});
|
|
6273
|
-
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
6274
|
-
for (const row of rows) {
|
|
6275
|
-
const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
|
|
6276
|
-
const relatedRow = resultMap.get(String(id));
|
|
6277
|
-
if (relatedRow) {
|
|
6278
|
-
if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
6279
|
-
}
|
|
6280
|
-
}
|
|
6281
|
-
} catch (e) {
|
|
6282
|
-
logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
|
|
6283
|
-
}
|
|
6284
|
-
}
|
|
6285
|
-
/**
|
|
6286
6693
|
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
6287
6694
|
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
6288
6695
|
*/
|
|
@@ -6293,63 +6700,29 @@ var FetchService = class {
|
|
|
6293
6700
|
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
6294
6701
|
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
|
|
6295
6702
|
if (joinPathRelations.length === 0) return;
|
|
6296
|
-
const
|
|
6703
|
+
const parentIdOf = (row) => {
|
|
6704
|
+
const address = buildCompositeId(row, idInfoArray);
|
|
6705
|
+
return address && address.split(":::").some((part) => part !== "") ? address : void 0;
|
|
6706
|
+
};
|
|
6297
6707
|
for (const [key, relation] of joinPathRelations) try {
|
|
6298
|
-
const
|
|
6299
|
-
|
|
6300
|
-
|
|
6708
|
+
const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
|
|
6709
|
+
if (addressable.length === 0) continue;
|
|
6710
|
+
const rowIds = addressable.map((r) => parentIdOf(r));
|
|
6301
6711
|
if (relation.cardinality === "one") {
|
|
6302
6712
|
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
6303
|
-
for (const row of
|
|
6304
|
-
const
|
|
6305
|
-
|
|
6306
|
-
if (relatedRow) row[key] = {
|
|
6307
|
-
...relatedRow.values,
|
|
6308
|
-
id: relatedRow.id
|
|
6309
|
-
};
|
|
6310
|
-
else row[key] = null;
|
|
6713
|
+
for (const row of addressable) {
|
|
6714
|
+
const relatedRow = resultMap.get(String(parentIdOf(row)));
|
|
6715
|
+
row[key] = relatedRow ? { ...relatedRow.values } : null;
|
|
6311
6716
|
}
|
|
6312
6717
|
} else if (relation.cardinality === "many") {
|
|
6313
6718
|
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
|
|
6314
|
-
for (const row of
|
|
6315
|
-
const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
|
|
6316
|
-
row[key] = (resultMap.get(String(id)) || []).map((e) => ({
|
|
6317
|
-
...e.values,
|
|
6318
|
-
id: e.id
|
|
6319
|
-
}));
|
|
6320
|
-
}
|
|
6719
|
+
for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
|
|
6321
6720
|
}
|
|
6322
6721
|
} catch (e) {
|
|
6323
6722
|
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
6324
6723
|
}
|
|
6325
6724
|
}
|
|
6326
6725
|
/**
|
|
6327
|
-
* Convert a db.query result row to a flat REST-style row with populated relations.
|
|
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.
|
|
6334
|
-
*/
|
|
6335
|
-
drizzleResultToRestRow(row, collection) {
|
|
6336
|
-
const flat = {};
|
|
6337
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6338
|
-
for (const [k, v] of Object.entries(row)) {
|
|
6339
|
-
const relation = findRelation(resolvedRelations, k);
|
|
6340
|
-
if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
|
|
6341
|
-
if (this.isJunctionRelation(relation, collection)) {
|
|
6342
|
-
const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
6343
|
-
if (nestedKey) return { ...item[nestedKey] };
|
|
6344
|
-
}
|
|
6345
|
-
return { ...item };
|
|
6346
|
-
});
|
|
6347
|
-
else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) flat[k] = { ...v };
|
|
6348
|
-
else flat[k] = v;
|
|
6349
|
-
}
|
|
6350
|
-
return flat;
|
|
6351
|
-
}
|
|
6352
|
-
/**
|
|
6353
6726
|
* Build db.query-compatible options from standard fetch options.
|
|
6354
6727
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
6355
6728
|
*/
|
|
@@ -6419,7 +6792,7 @@ var FetchService = class {
|
|
|
6419
6792
|
async fetchOne(collectionPath, id, databaseId) {
|
|
6420
6793
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6421
6794
|
const table = getTableForCollection(collection, this.registry);
|
|
6422
|
-
const idInfoArray =
|
|
6795
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6423
6796
|
const idInfo = idInfoArray[0];
|
|
6424
6797
|
const idField = table[idInfo.fieldName];
|
|
6425
6798
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
@@ -6433,7 +6806,7 @@ var FetchService = class {
|
|
|
6433
6806
|
with: withConfig
|
|
6434
6807
|
});
|
|
6435
6808
|
if (!row) return void 0;
|
|
6436
|
-
const flatRow =
|
|
6809
|
+
const flatRow = toCmsRow(row, collection, this.registry);
|
|
6437
6810
|
await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
6438
6811
|
return flatRow;
|
|
6439
6812
|
} catch (e) {
|
|
@@ -6475,7 +6848,7 @@ var FetchService = class {
|
|
|
6475
6848
|
async fetchRowsWithConditions(collectionPath, options = {}) {
|
|
6476
6849
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6477
6850
|
const table = getTableForCollection(collection, this.registry);
|
|
6478
|
-
const idInfoArray =
|
|
6851
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6479
6852
|
const idInfo = idInfoArray[0];
|
|
6480
6853
|
const idField = table[idInfo.fieldName];
|
|
6481
6854
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
@@ -6485,7 +6858,7 @@ var FetchService = class {
|
|
|
6485
6858
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
6486
6859
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
6487
6860
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
|
|
6488
|
-
return (await qb.findMany(queryOpts)).map((row) =>
|
|
6861
|
+
return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
|
|
6489
6862
|
} catch (e) {
|
|
6490
6863
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
6491
6864
|
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
@@ -6546,8 +6919,10 @@ var FetchService = class {
|
|
|
6546
6919
|
}
|
|
6547
6920
|
/**
|
|
6548
6921
|
* Fallback path used when db.query is unavailable.
|
|
6549
|
-
*
|
|
6550
|
-
*
|
|
6922
|
+
*
|
|
6923
|
+
* The primary path runs the results through `toCmsRow`, which maps
|
|
6924
|
+
* relations from what drizzle already nested — no query per row. This one
|
|
6925
|
+
* has no nesting to read, so it resolves relations itself, in batches.
|
|
6551
6926
|
*
|
|
6552
6927
|
* Process raw database results into flat rows with relations.
|
|
6553
6928
|
*/
|
|
@@ -6627,10 +7002,7 @@ var FetchService = class {
|
|
|
6627
7002
|
const relationKey = pathSegments[i];
|
|
6628
7003
|
const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
|
|
6629
7004
|
if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
6630
|
-
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
|
|
6631
|
-
...row.values,
|
|
6632
|
-
id: row.id
|
|
6633
|
-
}));
|
|
7005
|
+
if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
|
|
6634
7006
|
if (i + 1 < pathSegments.length) {
|
|
6635
7007
|
const nextEntityId = pathSegments[i + 1];
|
|
6636
7008
|
currentCollection = relation.target();
|
|
@@ -6692,7 +7064,7 @@ var FetchService = class {
|
|
|
6692
7064
|
if (value === void 0 || value === null) return true;
|
|
6693
7065
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6694
7066
|
const table = getTableForCollection(collection, this.registry);
|
|
6695
|
-
const idInfoArray =
|
|
7067
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6696
7068
|
const idInfo = idInfoArray[0];
|
|
6697
7069
|
const idField = table[idInfo.fieldName];
|
|
6698
7070
|
const field = table[fieldName];
|
|
@@ -6719,7 +7091,7 @@ var FetchService = class {
|
|
|
6719
7091
|
async fetchCollectionForRest(collectionPath, options = {}, include) {
|
|
6720
7092
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6721
7093
|
const table = getTableForCollection(collection, this.registry);
|
|
6722
|
-
const idInfoArray =
|
|
7094
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6723
7095
|
const idInfo = idInfoArray[0];
|
|
6724
7096
|
const idField = table[idInfo.fieldName];
|
|
6725
7097
|
const tableName = getTableName(table);
|
|
@@ -6727,7 +7099,7 @@ var FetchService = class {
|
|
|
6727
7099
|
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
6728
7100
|
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
6729
7101
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
|
|
6730
|
-
const restRows = (await qb.findMany(queryOpts)).map((row) =>
|
|
7102
|
+
const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
|
|
6731
7103
|
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
6732
7104
|
return restRows;
|
|
6733
7105
|
} catch (e) {
|
|
@@ -6776,7 +7148,7 @@ var FetchService = class {
|
|
|
6776
7148
|
async fetchOneForRest(collectionPath, id, include, databaseId) {
|
|
6777
7149
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6778
7150
|
const table = getTableForCollection(collection, this.registry);
|
|
6779
|
-
const idInfoArray =
|
|
7151
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
6780
7152
|
const idInfo = idInfoArray[0];
|
|
6781
7153
|
const idField = table[idInfo.fieldName];
|
|
6782
7154
|
const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
|
|
@@ -6789,7 +7161,7 @@ var FetchService = class {
|
|
|
6789
7161
|
...withConfig ? { with: withConfig } : {}
|
|
6790
7162
|
});
|
|
6791
7163
|
if (!row) return null;
|
|
6792
|
-
const restRow =
|
|
7164
|
+
const restRow = toRestRow(row, collection, this.registry);
|
|
6793
7165
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
6794
7166
|
return restRow;
|
|
6795
7167
|
} catch (e) {
|
|
@@ -6834,7 +7206,7 @@ var FetchService = class {
|
|
|
6834
7206
|
async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
|
|
6835
7207
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
6836
7208
|
const table = getTableForCollection(collection, this.registry);
|
|
6837
|
-
const idField = table[
|
|
7209
|
+
const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
|
|
6838
7210
|
let vectorMeta;
|
|
6839
7211
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
6840
7212
|
let query = vectorMeta ? this.db.select({
|
|
@@ -7323,7 +7695,7 @@ var PersistService = class {
|
|
|
7323
7695
|
} catch (error) {
|
|
7324
7696
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
7325
7697
|
}
|
|
7326
|
-
const finalEntity = await this.fetchService.
|
|
7698
|
+
const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
|
|
7327
7699
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
7328
7700
|
return finalEntity;
|
|
7329
7701
|
}
|
|
@@ -8242,12 +8614,14 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8242
8614
|
let updatedValues = values;
|
|
8243
8615
|
const contextForCallback = this.buildCallContext();
|
|
8244
8616
|
let previousValuesForHistory;
|
|
8245
|
-
if (status === "existing" && id) {
|
|
8246
|
-
const existing = await this.dataService.
|
|
8617
|
+
if (status === "existing" && id) try {
|
|
8618
|
+
const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
|
|
8247
8619
|
if (existing) {
|
|
8248
8620
|
const { id: _existingId, ...existingValues } = existing;
|
|
8249
8621
|
previousValuesForHistory = existingValues;
|
|
8250
8622
|
}
|
|
8623
|
+
} catch (err) {
|
|
8624
|
+
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
8251
8625
|
}
|
|
8252
8626
|
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
8253
8627
|
if (globalCallbacks?.beforeSave) {
|
|
@@ -8315,8 +8689,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8315
8689
|
context: contextForCallback
|
|
8316
8690
|
});
|
|
8317
8691
|
}
|
|
8318
|
-
const savedId = savedRow.
|
|
8319
|
-
const
|
|
8692
|
+
const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
|
|
8693
|
+
const savedValues = savedRow;
|
|
8320
8694
|
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
8321
8695
|
if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
|
|
8322
8696
|
collection: resolvedCollection,
|
|
@@ -8348,7 +8722,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8348
8722
|
}
|
|
8349
8723
|
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
8350
8724
|
tableName: path,
|
|
8351
|
-
id: savedId
|
|
8725
|
+
id: savedId,
|
|
8352
8726
|
action: status === "new" ? "create" : "update",
|
|
8353
8727
|
values: savedValues,
|
|
8354
8728
|
previousValues: previousValuesForHistory,
|
|
@@ -8356,11 +8730,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8356
8730
|
});
|
|
8357
8731
|
if (this._deferNotifications) this._pendingNotifications.push({
|
|
8358
8732
|
path,
|
|
8359
|
-
id: savedId
|
|
8733
|
+
id: savedId,
|
|
8360
8734
|
row: savedRow,
|
|
8361
8735
|
databaseId: resolvedCollection?.databaseId
|
|
8362
8736
|
});
|
|
8363
|
-
else await this.realtimeService.notifyUpdate(path, savedId
|
|
8737
|
+
else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
|
|
8364
8738
|
return savedRow;
|
|
8365
8739
|
} catch (error) {
|
|
8366
8740
|
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
@@ -8440,10 +8814,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8440
8814
|
}
|
|
8441
8815
|
async delete({ row, collection }) {
|
|
8442
8816
|
const targetPath = row.path;
|
|
8443
|
-
const targetRow = {
|
|
8444
|
-
id: row.id,
|
|
8445
|
-
...row.values ?? {}
|
|
8446
|
-
};
|
|
8817
|
+
const targetRow = { ...row.values ?? {} };
|
|
8447
8818
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
|
|
8448
8819
|
const contextForCallback = this.buildCallContext();
|
|
8449
8820
|
if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
|
|
@@ -8863,6 +9234,7 @@ var DatabasePoolManager = class {
|
|
|
8863
9234
|
pool.on("error", (err) => {
|
|
8864
9235
|
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
8865
9236
|
});
|
|
9237
|
+
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
8866
9238
|
this.pools.set(databaseName, pool);
|
|
8867
9239
|
return pool;
|
|
8868
9240
|
}
|
|
@@ -9391,6 +9763,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9391
9763
|
});
|
|
9392
9764
|
});
|
|
9393
9765
|
schemaContent += "\n";
|
|
9766
|
+
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
9394
9767
|
for (const collection of collections) {
|
|
9395
9768
|
const tableName = getTableName$1(collection);
|
|
9396
9769
|
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
@@ -9424,9 +9797,17 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
9424
9797
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
9425
9798
|
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
9426
9799
|
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
9427
|
-
schemaContent += "}, (table) => (
|
|
9428
|
-
schemaContent += `
|
|
9429
|
-
|
|
9800
|
+
schemaContent += "}, (table) => ([\n";
|
|
9801
|
+
schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
|
|
9802
|
+
const junctionSpec = junctionSpecs.get(baseTableName);
|
|
9803
|
+
if (!stripPolicies && junctionSpec) {
|
|
9804
|
+
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
9805
|
+
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
9806
|
+
getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
|
|
9807
|
+
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
9808
|
+
});
|
|
9809
|
+
}
|
|
9810
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
9430
9811
|
} else if (!isJunction) {
|
|
9431
9812
|
const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
|
|
9432
9813
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
@@ -10117,7 +10498,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10117
10498
|
startAfter: request.startAfter,
|
|
10118
10499
|
searchString: request.searchString
|
|
10119
10500
|
}, authContext);
|
|
10120
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows);
|
|
10501
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
10121
10502
|
} catch (error) {
|
|
10122
10503
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
10123
10504
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -10218,7 +10599,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10218
10599
|
if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
10219
10600
|
else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
|
|
10220
10601
|
else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
10221
|
-
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
|
|
10602
|
+
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
|
|
10222
10603
|
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
10223
10604
|
}
|
|
10224
10605
|
} catch (error) {
|
|
@@ -10248,7 +10629,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10248
10629
|
if (!this._subscriptions.has(subscriptionId)) return;
|
|
10249
10630
|
try {
|
|
10250
10631
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
|
|
10251
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
|
|
10632
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
10252
10633
|
} catch (error) {
|
|
10253
10634
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
10254
10635
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -10462,11 +10843,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10462
10843
|
}
|
|
10463
10844
|
return await this.dataService.fetchOne(notifyPath, id);
|
|
10464
10845
|
}
|
|
10465
|
-
sendCollectionUpdate(clientId, subscriptionId, rows) {
|
|
10846
|
+
sendCollectionUpdate(clientId, subscriptionId, rows, path) {
|
|
10466
10847
|
const message = {
|
|
10467
10848
|
type: "collection_update",
|
|
10468
10849
|
subscriptionId,
|
|
10469
|
-
rows
|
|
10850
|
+
rows,
|
|
10851
|
+
pks: this.primaryKeysForPath(path)
|
|
10470
10852
|
};
|
|
10471
10853
|
this.sendMessage(clientId, message);
|
|
10472
10854
|
}
|
|
@@ -10481,16 +10863,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10481
10863
|
/**
|
|
10482
10864
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
10483
10865
|
* The client can merge this into its cached data for instant feedback.
|
|
10866
|
+
*
|
|
10867
|
+
* The key columns ride along: the patch names a row by address, and the
|
|
10868
|
+
* client has to find that row among the ones it cached — which carry
|
|
10869
|
+
* columns and no address. The SDK holds no collection config to derive one
|
|
10870
|
+
* from, so this is the only place the mapping can come from.
|
|
10484
10871
|
*/
|
|
10485
|
-
sendCollectionPatch(clientId, subscriptionId, id, row) {
|
|
10872
|
+
sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
|
|
10486
10873
|
const message = {
|
|
10487
10874
|
type: "collection_patch",
|
|
10488
10875
|
subscriptionId,
|
|
10489
10876
|
id,
|
|
10490
|
-
row
|
|
10877
|
+
row,
|
|
10878
|
+
pks: this.primaryKeysForPath(notifyPath)
|
|
10491
10879
|
};
|
|
10492
10880
|
this.sendMessage(clientId, message);
|
|
10493
10881
|
}
|
|
10882
|
+
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
10883
|
+
primaryKeysForPath(path) {
|
|
10884
|
+
try {
|
|
10885
|
+
const collection = this.registry.getCollectionByPath(path);
|
|
10886
|
+
if (!collection) return void 0;
|
|
10887
|
+
const keys = getPrimaryKeys(collection, this.registry);
|
|
10888
|
+
return keys.length > 0 ? keys : void 0;
|
|
10889
|
+
} catch {
|
|
10890
|
+
return;
|
|
10891
|
+
}
|
|
10892
|
+
}
|
|
10494
10893
|
sendError(clientId, error, subscriptionId, code) {
|
|
10495
10894
|
const message = {
|
|
10496
10895
|
type: "error",
|
|
@@ -10738,12 +11137,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10738
11137
|
}
|
|
10739
11138
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
10740
11139
|
extractIdFromCdcRow(collection, row) {
|
|
10741
|
-
|
|
10742
|
-
const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
|
|
10743
|
-
if (composite && composite !== ":::") return composite;
|
|
10744
|
-
} catch {}
|
|
10745
|
-
if (row.id !== void 0 && row.id !== null) return String(row.id);
|
|
10746
|
-
return "*";
|
|
11140
|
+
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
10747
11141
|
}
|
|
10748
11142
|
dedupKey(path, id, databaseId) {
|
|
10749
11143
|
return `${databaseId ?? ""}::${path}::${id}`;
|
|
@@ -20203,6 +20597,33 @@ function formatBytes(bytes) {
|
|
|
20203
20597
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
20204
20598
|
}
|
|
20205
20599
|
//#endregion
|
|
20600
|
+
//#region src/collections/buildRegistry.ts
|
|
20601
|
+
/**
|
|
20602
|
+
* Build the collection registry for a driver.
|
|
20603
|
+
*
|
|
20604
|
+
* The order matters and is the reason this is one function rather than a run of
|
|
20605
|
+
* statements in the bootstrapper. Keys are resolved from the drizzle schema, so
|
|
20606
|
+
* anything that inspects them has to run *after* the tables are registered —
|
|
20607
|
+
* and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
|
|
20608
|
+
* collection whose table it cannot look up is one it has nothing to say about.
|
|
20609
|
+
* Warned too early, it would skip every collection and report nothing, which
|
|
20610
|
+
* reads exactly like having nothing to report.
|
|
20611
|
+
*/
|
|
20612
|
+
function buildCollectionRegistry(schema) {
|
|
20613
|
+
const registry = new PostgresCollectionRegistry();
|
|
20614
|
+
if (schema.collections) {
|
|
20615
|
+
registry.registerMultiple(schema.collections);
|
|
20616
|
+
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
20617
|
+
}
|
|
20618
|
+
if (schema.tables) Object.values(schema.tables).forEach((table) => {
|
|
20619
|
+
if (isTable(table)) registry.registerTable(table, getTableName(table));
|
|
20620
|
+
});
|
|
20621
|
+
if (schema.enums) registry.registerEnums(schema.enums);
|
|
20622
|
+
if (schema.relations) registry.registerRelations(schema.relations);
|
|
20623
|
+
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
20624
|
+
return registry;
|
|
20625
|
+
}
|
|
20626
|
+
//#endregion
|
|
20206
20627
|
//#region src/auth/ensure-tables.ts
|
|
20207
20628
|
/**
|
|
20208
20629
|
* Auto-create auth tables if they don't exist.
|
|
@@ -20374,14 +20795,22 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20374
20795
|
$$ LANGUAGE sql STABLE
|
|
20375
20796
|
`);
|
|
20376
20797
|
});
|
|
20377
|
-
|
|
20378
|
-
|
|
20379
|
-
|
|
20380
|
-
|
|
20381
|
-
|
|
20382
|
-
|
|
20383
|
-
|
|
20384
|
-
|
|
20798
|
+
for (const columnDef of [
|
|
20799
|
+
"display_name VARCHAR(255)",
|
|
20800
|
+
"photo_url VARCHAR(500)",
|
|
20801
|
+
"roles TEXT[] DEFAULT '{}' NOT NULL",
|
|
20802
|
+
"password_hash VARCHAR(255)",
|
|
20803
|
+
"email_verified BOOLEAN DEFAULT FALSE NOT NULL",
|
|
20804
|
+
"email_verification_token VARCHAR(255)",
|
|
20805
|
+
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
20806
|
+
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
20807
|
+
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
20808
|
+
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
20809
|
+
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
20810
|
+
]) await db.execute(sql`
|
|
20811
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
20812
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
|
|
20813
|
+
`);
|
|
20385
20814
|
try {
|
|
20386
20815
|
if ((await db.execute(sql`
|
|
20387
20816
|
SELECT EXISTS (
|
|
@@ -20452,6 +20881,34 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20452
20881
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
20453
20882
|
ON ${sql.raw(recoveryCodesTableName)}(user_id)
|
|
20454
20883
|
`);
|
|
20884
|
+
try {
|
|
20885
|
+
const authTablePairs = [
|
|
20886
|
+
[usersSchema, resolvedTable],
|
|
20887
|
+
[authSchema, "user_identities"],
|
|
20888
|
+
[authSchema, "refresh_tokens"],
|
|
20889
|
+
[authSchema, "password_reset_tokens"],
|
|
20890
|
+
[authSchema, "app_config"],
|
|
20891
|
+
[authSchema, "mfa_factors"],
|
|
20892
|
+
[authSchema, "mfa_challenges"],
|
|
20893
|
+
[authSchema, "recovery_codes"]
|
|
20894
|
+
];
|
|
20895
|
+
for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
|
|
20896
|
+
SELECT 1
|
|
20897
|
+
FROM pg_class c
|
|
20898
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
20899
|
+
WHERE n.nspname = ${schemaName}
|
|
20900
|
+
AND c.relname = ${tableName}
|
|
20901
|
+
AND c.relforcerowsecurity
|
|
20902
|
+
`)).rows.length > 0) {
|
|
20903
|
+
await db.execute(sql`
|
|
20904
|
+
ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
|
|
20905
|
+
NO FORCE ROW LEVEL SECURITY
|
|
20906
|
+
`);
|
|
20907
|
+
logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
|
|
20908
|
+
}
|
|
20909
|
+
} catch (rlsReconcileError) {
|
|
20910
|
+
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
20911
|
+
}
|
|
20455
20912
|
logger.info("✅ Auth tables ready");
|
|
20456
20913
|
} catch (error) {
|
|
20457
20914
|
logger.error("❌ Failed to create auth tables", { error });
|
|
@@ -20499,6 +20956,31 @@ var UserService = class {
|
|
|
20499
20956
|
const name = getTableName(this.usersTable);
|
|
20500
20957
|
return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
|
|
20501
20958
|
}
|
|
20959
|
+
/**
|
|
20960
|
+
* Run a privileged auth write with an explicitly cleared RLS context.
|
|
20961
|
+
*
|
|
20962
|
+
* The auth services run on the base/owner connection, which by design
|
|
20963
|
+
* carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
|
|
20964
|
+
* in the default policies applies. That NULL is normally guaranteed by
|
|
20965
|
+
* `set_config(..., is_local = true)` resetting at transaction end — but a
|
|
20966
|
+
* GUC that survives on a pooled connection (or a connection role that
|
|
20967
|
+
* doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
|
|
20968
|
+
* turns the trusted write into an RLS-scoped one and denies it with
|
|
20969
|
+
* SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
|
|
20970
|
+
* single chokepoint, makes the server context deterministic instead of
|
|
20971
|
+
* trusting whatever state the pool hands us. `auth.uid()` reads '' as
|
|
20972
|
+
* NULL via NULLIF, so '' is the server context.
|
|
20973
|
+
*/
|
|
20974
|
+
async withServerContext(fn) {
|
|
20975
|
+
return await this.db.transaction(async (tx) => {
|
|
20976
|
+
await tx.execute(sql`
|
|
20977
|
+
SELECT set_config('app.user_id', '', true),
|
|
20978
|
+
set_config('app.user_roles', '', true),
|
|
20979
|
+
set_config('app.jwt', '', true)
|
|
20980
|
+
`);
|
|
20981
|
+
return await fn(tx);
|
|
20982
|
+
});
|
|
20983
|
+
}
|
|
20502
20984
|
mapRowToUser(row) {
|
|
20503
20985
|
if (!row) return row;
|
|
20504
20986
|
const id = row.id ?? row.uid;
|
|
@@ -20596,7 +21078,7 @@ var UserService = class {
|
|
|
20596
21078
|
}
|
|
20597
21079
|
async createUser(data) {
|
|
20598
21080
|
const payload = this.mapPayload(data);
|
|
20599
|
-
const [row] = await this.db.insert(this.usersTable).values(payload).returning();
|
|
21081
|
+
const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
|
|
20600
21082
|
return this.mapRowToUser(row);
|
|
20601
21083
|
}
|
|
20602
21084
|
async getUserById(id) {
|
|
@@ -20635,12 +21117,12 @@ var UserService = class {
|
|
|
20635
21117
|
}));
|
|
20636
21118
|
}
|
|
20637
21119
|
async linkUserIdentity(userId, provider, providerId, profileData) {
|
|
20638
|
-
await this.db.insert(this.userIdentitiesTable).values({
|
|
21120
|
+
await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
|
|
20639
21121
|
userId,
|
|
20640
21122
|
provider,
|
|
20641
21123
|
providerId,
|
|
20642
21124
|
profileData: profileData || null
|
|
20643
|
-
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
|
|
21125
|
+
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
|
|
20644
21126
|
}
|
|
20645
21127
|
async updateUser(id, data) {
|
|
20646
21128
|
const idCol = getColumn(this.usersTable, "id");
|
|
@@ -20648,13 +21130,13 @@ var UserService = class {
|
|
|
20648
21130
|
const payload = this.mapPayload(data);
|
|
20649
21131
|
const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20650
21132
|
payload[updatedAtKey] = /* @__PURE__ */ new Date();
|
|
20651
|
-
const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
|
|
21133
|
+
const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
|
|
20652
21134
|
return row ? this.mapRowToUser(row) : null;
|
|
20653
21135
|
}
|
|
20654
21136
|
async deleteUser(id) {
|
|
20655
21137
|
const idCol = getColumn(this.usersTable, "id");
|
|
20656
21138
|
if (!idCol) return;
|
|
20657
|
-
await this.db.delete(this.usersTable).where(eq(idCol, id));
|
|
21139
|
+
await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
|
|
20658
21140
|
}
|
|
20659
21141
|
async listUsers() {
|
|
20660
21142
|
return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
|
|
@@ -20708,10 +21190,10 @@ var UserService = class {
|
|
|
20708
21190
|
if (!idCol) return;
|
|
20709
21191
|
const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
|
|
20710
21192
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20711
|
-
await this.db.update(this.usersTable).set({
|
|
21193
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20712
21194
|
[passwordHashColKey]: passwordHash,
|
|
20713
21195
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20714
|
-
}).where(eq(idCol, id));
|
|
21196
|
+
}).where(eq(idCol, id)));
|
|
20715
21197
|
}
|
|
20716
21198
|
/**
|
|
20717
21199
|
* Set email verification status
|
|
@@ -20722,11 +21204,11 @@ var UserService = class {
|
|
|
20722
21204
|
const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
|
|
20723
21205
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
20724
21206
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20725
|
-
await this.db.update(this.usersTable).set({
|
|
21207
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20726
21208
|
[emailVerifiedColKey]: verified,
|
|
20727
21209
|
[emailVerificationTokenColKey]: null,
|
|
20728
21210
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20729
|
-
}).where(eq(idCol, id));
|
|
21211
|
+
}).where(eq(idCol, id)));
|
|
20730
21212
|
}
|
|
20731
21213
|
/**
|
|
20732
21214
|
* Set email verification token
|
|
@@ -20737,11 +21219,11 @@ var UserService = class {
|
|
|
20737
21219
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
20738
21220
|
const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
|
|
20739
21221
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20740
|
-
await this.db.update(this.usersTable).set({
|
|
21222
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
20741
21223
|
[emailVerificationTokenColKey]: token,
|
|
20742
21224
|
[emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
|
|
20743
21225
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
20744
|
-
}).where(eq(idCol, id));
|
|
21226
|
+
}).where(eq(idCol, id)));
|
|
20745
21227
|
}
|
|
20746
21228
|
/**
|
|
20747
21229
|
* Find user by email verification token
|
|
@@ -20786,22 +21268,22 @@ var UserService = class {
|
|
|
20786
21268
|
async setUserRoles(userId, roleIds) {
|
|
20787
21269
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
20788
21270
|
const rolesArray = `{${roleIds.join(",")}}`;
|
|
20789
|
-
await this.db.execute(sql`
|
|
21271
|
+
await this.withServerContext(async (db) => db.execute(sql`
|
|
20790
21272
|
UPDATE ${sql.raw(usersTableName)}
|
|
20791
21273
|
SET roles = ${rolesArray}::text[], updated_at = NOW()
|
|
20792
21274
|
WHERE id = ${userId}
|
|
20793
|
-
`);
|
|
21275
|
+
`));
|
|
20794
21276
|
}
|
|
20795
21277
|
/**
|
|
20796
21278
|
* Assign a specific role to new user (appends if not present)
|
|
20797
21279
|
*/
|
|
20798
21280
|
async assignDefaultRole(userId, roleId) {
|
|
20799
21281
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
20800
|
-
await this.db.execute(sql`
|
|
21282
|
+
await this.withServerContext(async (db) => db.execute(sql`
|
|
20801
21283
|
UPDATE ${sql.raw(usersTableName)}
|
|
20802
21284
|
SET roles = array_append(roles, ${roleId}), updated_at = NOW()
|
|
20803
21285
|
WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
|
|
20804
|
-
`);
|
|
21286
|
+
`));
|
|
20805
21287
|
}
|
|
20806
21288
|
/**
|
|
20807
21289
|
* Get user with their roles
|
|
@@ -22185,21 +22667,14 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22185
22667
|
logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
|
|
22186
22668
|
}
|
|
22187
22669
|
const activeCollections = introspectedCollections ?? collections;
|
|
22188
|
-
const registry = new PostgresCollectionRegistry();
|
|
22189
|
-
if (activeCollections) {
|
|
22190
|
-
registry.registerMultiple(activeCollections);
|
|
22191
|
-
logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
22192
|
-
}
|
|
22193
22670
|
const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
|
|
22194
|
-
if (schemaTables) Object.values(schemaTables).forEach((table) => {
|
|
22195
|
-
if (isTable(table)) {
|
|
22196
|
-
const tableName = getTableName(table);
|
|
22197
|
-
registry.registerTable(table, tableName);
|
|
22198
|
-
}
|
|
22199
|
-
});
|
|
22200
|
-
if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
|
|
22201
22671
|
const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
|
|
22202
|
-
|
|
22672
|
+
const registry = buildCollectionRegistry({
|
|
22673
|
+
collections: activeCollections,
|
|
22674
|
+
tables: schemaTables,
|
|
22675
|
+
enums: pgConfig.schema?.enums,
|
|
22676
|
+
relations: schemaRelations
|
|
22677
|
+
});
|
|
22203
22678
|
if (schemaTables) patchPgArrayNullSafety(schemaTables);
|
|
22204
22679
|
const mergedSchema = {
|
|
22205
22680
|
...schemaTables,
|
|
@@ -22278,6 +22753,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22278
22753
|
const wantsCdc = cdcMode !== "off";
|
|
22279
22754
|
const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
|
|
22280
22755
|
let cdcEnabled = false;
|
|
22756
|
+
let provisionCdcForTables;
|
|
22281
22757
|
if (wantsCdc && !directUrl) {
|
|
22282
22758
|
const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
|
|
22283
22759
|
if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
|
|
@@ -22294,6 +22770,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22294
22770
|
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
|
|
22295
22771
|
await realtimeService.enableCdc(directUrl);
|
|
22296
22772
|
cdcEnabled = true;
|
|
22773
|
+
provisionCdcForTables = async (tables) => {
|
|
22774
|
+
await provisionTriggerCdc(cdcRunSql, tables);
|
|
22775
|
+
};
|
|
22297
22776
|
logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
|
|
22298
22777
|
} catch (err) {
|
|
22299
22778
|
if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
|
|
@@ -22318,13 +22797,14 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22318
22797
|
const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
|
|
22319
22798
|
const missing = [];
|
|
22320
22799
|
for (const col of registeredCollections) {
|
|
22800
|
+
if (col.auth?.enabled) continue;
|
|
22321
22801
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
22322
22802
|
const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
|
|
22323
22803
|
const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
|
|
22324
22804
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
22325
22805
|
if (!dbTables.has(fullCheckName)) missing.push({
|
|
22326
22806
|
slug: col.slug,
|
|
22327
|
-
table:
|
|
22807
|
+
table: fullCheckName
|
|
22328
22808
|
});
|
|
22329
22809
|
}
|
|
22330
22810
|
if (missing.length > 0) {
|
|
@@ -22358,7 +22838,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22358
22838
|
registry,
|
|
22359
22839
|
realtimeService,
|
|
22360
22840
|
driver,
|
|
22361
|
-
poolManager
|
|
22841
|
+
poolManager,
|
|
22842
|
+
provisionCdcForTables
|
|
22362
22843
|
}
|
|
22363
22844
|
};
|
|
22364
22845
|
},
|
|
@@ -22370,6 +22851,18 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22370
22851
|
const registry = internals.registry;
|
|
22371
22852
|
const authCollection = authConfig.collection;
|
|
22372
22853
|
await ensureAuthTablesExist(db, authCollection);
|
|
22854
|
+
if (authCollection && internals.provisionCdcForTables) {
|
|
22855
|
+
const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
|
|
22856
|
+
const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
|
|
22857
|
+
if (authTable) try {
|
|
22858
|
+
await internals.provisionCdcForTables([{
|
|
22859
|
+
schema: authSchema,
|
|
22860
|
+
table: authTable
|
|
22861
|
+
}]);
|
|
22862
|
+
} catch (err) {
|
|
22863
|
+
logger.warn(`⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — writes to it won't emit database-level events.`, { detail: err instanceof Error ? err.message : String(err) });
|
|
22864
|
+
}
|
|
22865
|
+
}
|
|
22373
22866
|
let emailService;
|
|
22374
22867
|
if (authConfig.email) emailService = createEmailService(authConfig.email);
|
|
22375
22868
|
const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
|
|
@@ -22440,6 +22933,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
22440
22933
|
};
|
|
22441
22934
|
}
|
|
22442
22935
|
//#endregion
|
|
22443
|
-
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
|
|
22936
|
+
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
|
|
22444
22937
|
|
|
22445
22938
|
//# sourceMappingURL=index.es.js.map
|