@rebasepro/server-postgres 0.9.1-canary.baa7a6b → 0.9.1-canary.d198c11
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 +0 -10
- package/dist/auth/services.d.ts +0 -16
- package/dist/connection.d.ts +0 -21
- package/dist/data-transformer.d.ts +2 -9
- package/dist/index.es.js +318 -619
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/security/policy-drift.d.ts +0 -30
- package/dist/services/FetchService.d.ts +32 -4
- package/dist/services/RelationService.d.ts +1 -34
- package/dist/services/collection-helpers.d.ts +0 -76
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +0 -7
- package/package.json +7 -11
- package/src/PostgresBackendDriver.ts +11 -39
- package/src/PostgresBootstrapper.ts +25 -62
- package/src/auth/ensure-tables.ts +11 -73
- package/src/auth/services.ts +19 -49
- package/src/cli.ts +0 -60
- package/src/connection.ts +1 -61
- package/src/data-transformer.ts +9 -11
- package/src/databasePoolManager.ts +0 -2
- package/src/schema/doctor.ts +20 -45
- package/src/schema/introspect-db.ts +2 -19
- package/src/security/policy-drift.test.ts +1 -106
- package/src/security/policy-drift.ts +0 -56
- package/src/services/FetchService.ts +229 -50
- package/src/services/PersistService.ts +2 -9
- package/src/services/RelationService.ts +94 -153
- package/src/services/collection-helpers.ts +3 -166
- package/src/services/index.ts +0 -1
- package/src/services/realtimeService.ts +19 -40
- package/src/utils/drizzle-conditions.ts +0 -13
- package/dist/collections/buildRegistry.d.ts +0 -27
- package/dist/services/row-pipeline.d.ts +0 -63
- package/src/collections/buildRegistry.ts +0 -59
- package/src/services/row-pipeline.ts +0 -239
|
@@ -30,7 +30,6 @@ async function main() {
|
|
|
30
30
|
"--force": Boolean,
|
|
31
31
|
"--schema": String,
|
|
32
32
|
"--data-inference": Boolean,
|
|
33
|
-
"--no-data-inference": Boolean,
|
|
34
33
|
"-o": "--output",
|
|
35
34
|
"-c": "--collections",
|
|
36
35
|
"-f": "--force"
|
|
@@ -167,14 +166,8 @@ async function main() {
|
|
|
167
166
|
logger.info(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
|
|
168
167
|
|
|
169
168
|
let runDataInference = false;
|
|
170
|
-
if (args["--
|
|
171
|
-
runDataInference = false;
|
|
172
|
-
} else if (args["--data-inference"] !== undefined) {
|
|
169
|
+
if (args["--data-inference"] !== undefined) {
|
|
173
170
|
runDataInference = args["--data-inference"];
|
|
174
|
-
} else if (!process.stdin.isTTY) {
|
|
175
|
-
// No terminal to answer the question below (scaffolding scripts, CI,
|
|
176
|
-
// `rebase init --introspect`) — asking would hang forever.
|
|
177
|
-
logger.info(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
|
|
178
171
|
} else {
|
|
179
172
|
const rl = readline.createInterface({
|
|
180
173
|
input: process.stdin,
|
|
@@ -243,17 +236,7 @@ async function main() {
|
|
|
243
236
|
const merged = mergeIndexContent(existing, generatedFiles);
|
|
244
237
|
fs.writeFileSync(indexPath, merged, "utf-8");
|
|
245
238
|
} else {
|
|
246
|
-
|
|
247
|
-
// directory can also hold hand-written ones with no table in the
|
|
248
|
-
// introspected schema (the auth users collection lives in
|
|
249
|
-
// "rebase"). The backend discovers the whole directory, so an
|
|
250
|
-
// index listing only introspected tables would silently drop them
|
|
251
|
-
// from the admin UI while the API still served them.
|
|
252
|
-
const siblings = fs.readdirSync(outDir)
|
|
253
|
-
.filter(f => f.endsWith(".ts") && f !== "index.ts")
|
|
254
|
-
.map(f => f.replace(/\.ts$/, ""));
|
|
255
|
-
const allFiles = [...new Set([...generatedFiles, ...siblings])];
|
|
256
|
-
const indexContent = generateIndexContent(allFiles);
|
|
239
|
+
const indexContent = generateIndexContent(generatedFiles);
|
|
257
240
|
fs.writeFileSync(indexPath, indexContent, "utf-8");
|
|
258
241
|
}
|
|
259
242
|
logger.info(chalk.green(` ✓ ${indexPath}`));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "@jest/globals";
|
|
2
2
|
import type { CollectionConfig } from "@rebasepro/types";
|
|
3
3
|
|
|
4
|
-
import { checkPolicyDrift, parseExpectedPolicies, formatPolicyDrift, hasDrift,
|
|
4
|
+
import { checkPolicyDrift, parseExpectedPolicies, formatPolicyDrift, hasDrift, type PolicyRef, type Queryable } from "./policy-drift";
|
|
5
5
|
import { generatePostgresPoliciesDdl } from "../schema/generate-postgres-ddl-logic";
|
|
6
6
|
|
|
7
7
|
function collection(slug: string): CollectionConfig {
|
|
@@ -154,108 +154,3 @@ describe("checkPolicyDrift", () => {
|
|
|
154
154
|
expect(drift.missing.length).toBeGreaterThan(0);
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
|
-
|
|
158
|
-
describe("isGeneratedPolicyName", () => {
|
|
159
|
-
it("recognises the generator's own shape", () => {
|
|
160
|
-
expect(isGeneratedPolicyName("documents_insert_a1b2c3d", "documents")).toBe(true);
|
|
161
|
-
// One rule spanning several operations appends the operation index.
|
|
162
|
-
expect(isGeneratedPolicyName("documents_update_a1b2c3d_1", "documents")).toBe(true);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("does not claim names a human could have written", () => {
|
|
166
|
-
expect(isGeneratedPolicyName("owner_access", "documents")).toBe(false);
|
|
167
|
-
expect(isGeneratedPolicyName("documents_default_admin_read", "documents")).toBe(false);
|
|
168
|
-
// Right shape, wrong table — belongs to something else.
|
|
169
|
-
expect(isGeneratedPolicyName("teams_insert_a1b2c3d", "documents")).toBe(false);
|
|
170
|
-
// A digest is 7 lowercase hex characters, nothing else.
|
|
171
|
-
expect(isGeneratedPolicyName("documents_insert_notahex", "documents")).toBe(false);
|
|
172
|
-
expect(isGeneratedPolicyName("documents_grant_a1b2c3d", "documents")).toBe(false);
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
describe("dropOrphanedPolicies", () => {
|
|
177
|
-
/** Records the DDL issued so the test can assert on what was dropped. */
|
|
178
|
-
function recordingDb(rows: Record<string, unknown>[]) {
|
|
179
|
-
const executed: string[] = [];
|
|
180
|
-
const db: Queryable = {
|
|
181
|
-
query: async (text: string) => {
|
|
182
|
-
if (!/^SELECT/i.test(text)) executed.push(text);
|
|
183
|
-
return { rows: rows as never[] };
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
return { db, executed };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
it("drops the policy a rule edit superseded", async () => {
|
|
190
|
-
// The reported failure: tightening a rule renames its policy, and the
|
|
191
|
-
// permissive original stays live and keeps ORing itself back in.
|
|
192
|
-
const cols = [collection("documents")];
|
|
193
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
194
|
-
const stale = {
|
|
195
|
-
schemaname: "public", tablename: "documents", policyname: "documents_insert_dead1ee",
|
|
196
|
-
roles: ["public"], cmd: "INSERT", qual: null, with_check: "true"
|
|
197
|
-
};
|
|
198
|
-
const live = [...expected.map((p) => liveRow(p)), stale];
|
|
199
|
-
|
|
200
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
201
|
-
const { db, executed } = recordingDb(live);
|
|
202
|
-
const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
|
|
203
|
-
|
|
204
|
-
expect(dropped).toHaveLength(1);
|
|
205
|
-
expect(dropped[0].name).toBe("documents_insert_dead1ee");
|
|
206
|
-
expect(kept).toHaveLength(0);
|
|
207
|
-
expect(executed).toEqual([
|
|
208
|
-
'DROP POLICY IF EXISTS "documents_insert_dead1ee" ON "public"."documents"'
|
|
209
|
-
]);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("leaves a hand-written policy alone and reports it instead", async () => {
|
|
213
|
-
const cols = [collection("documents")];
|
|
214
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
215
|
-
const live = [
|
|
216
|
-
...expected.map((p) => liveRow(p)),
|
|
217
|
-
{ schemaname: "public", tablename: "documents", policyname: "ops_break_glass", roles: ["public"], cmd: "ALL", qual: "true", with_check: null }
|
|
218
|
-
];
|
|
219
|
-
|
|
220
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
221
|
-
const { db, executed } = recordingDb(live);
|
|
222
|
-
const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
|
|
223
|
-
|
|
224
|
-
expect(dropped).toHaveLength(0);
|
|
225
|
-
expect(kept.map((p) => p.name)).toEqual(["ops_break_glass"]);
|
|
226
|
-
expect(executed).toEqual([]);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("never touches a table the collections do not describe", async () => {
|
|
230
|
-
// Another application sharing the schema owns this table; a
|
|
231
|
-
// generator-shaped name there is coincidence, not our leftover.
|
|
232
|
-
const cols = [collection("documents")];
|
|
233
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
234
|
-
const live = [
|
|
235
|
-
...expected.map((p) => liveRow(p)),
|
|
236
|
-
{ schemaname: "public", tablename: "legacy", policyname: "legacy_select_a1b2c3d", roles: ["public"], cmd: "SELECT", qual: "true", with_check: null }
|
|
237
|
-
];
|
|
238
|
-
|
|
239
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
240
|
-
const { db, executed } = recordingDb(live);
|
|
241
|
-
const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
|
|
242
|
-
|
|
243
|
-
expect(dropped).toHaveLength(0);
|
|
244
|
-
expect(kept.map((p) => p.name)).toEqual(["legacy_select_a1b2c3d"]);
|
|
245
|
-
expect(executed).toEqual([]);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("does nothing when the database already matches", async () => {
|
|
249
|
-
const cols = [collection("documents")];
|
|
250
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
251
|
-
const live = expected.map((p) => liveRow(p));
|
|
252
|
-
|
|
253
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
254
|
-
const { db, executed } = recordingDb(live);
|
|
255
|
-
const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
|
|
256
|
-
|
|
257
|
-
expect(dropped).toHaveLength(0);
|
|
258
|
-
expect(kept).toHaveLength(0);
|
|
259
|
-
expect(executed).toEqual([]);
|
|
260
|
-
});
|
|
261
|
-
});
|
|
@@ -192,62 +192,6 @@ export async function checkPolicyDrift(
|
|
|
192
192
|
return drift;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
/**
|
|
196
|
-
* Does this name look like one the generator produced for this table?
|
|
197
|
-
*
|
|
198
|
-
* Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one
|
|
199
|
-
* rule spans several operations), and the hash covers the rule's semantics — so
|
|
200
|
-
* *editing* a rule renames its policy. The policy under the old name is left
|
|
201
|
-
* behind by `db push`, which only DROPs the names it is about to CREATE, and
|
|
202
|
-
* Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps
|
|
203
|
-
* granting everything no matter how tight its replacement is.
|
|
204
|
-
*
|
|
205
|
-
* Matching the shape is what makes dropping them safe. A hand-written policy
|
|
206
|
-
* would have to collide with a 7-hex digest to be mistaken for generated one;
|
|
207
|
-
* a policy named anything else is left alone and merely reported, because a
|
|
208
|
-
* custom name is indistinguishable from one someone wrote in SQL on purpose.
|
|
209
|
-
*/
|
|
210
|
-
export function isGeneratedPolicyName(name: string, table: string): boolean {
|
|
211
|
-
return new RegExp(`^${table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\d+)?$`)
|
|
212
|
-
.test(name);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export interface OrphanCleanup {
|
|
216
|
-
/** Superseded generated policies that were dropped. */
|
|
217
|
-
dropped: PolicyRef[];
|
|
218
|
-
/** Orphans left in place because their names are not generator-shaped. */
|
|
219
|
-
kept: PolicyRef[];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Drop the policies an earlier push superseded but never removed.
|
|
224
|
-
*
|
|
225
|
-
* Only touches tables the collections describe — a table with no expected
|
|
226
|
-
* policy is not ours to reconcile, and scanning by schema alone would sweep up
|
|
227
|
-
* policies belonging to something else sharing the database.
|
|
228
|
-
*/
|
|
229
|
-
export async function dropOrphanedPolicies(
|
|
230
|
-
client: Queryable,
|
|
231
|
-
drift: PolicyDrift,
|
|
232
|
-
collections: CollectionConfig[]
|
|
233
|
-
): Promise<OrphanCleanup> {
|
|
234
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));
|
|
235
|
-
const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));
|
|
236
|
-
|
|
237
|
-
const cleanup: OrphanCleanup = { dropped: [], kept: [] };
|
|
238
|
-
for (const p of drift.orphaned) {
|
|
239
|
-
if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {
|
|
240
|
-
cleanup.kept.push(p);
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
// Identifiers are quoted, and the name came from pg_policies rather than
|
|
244
|
-
// from user input, so it is already a valid identifier.
|
|
245
|
-
await client.query(`DROP POLICY IF EXISTS "${p.name}" ON "${p.schema}"."${p.table}"`);
|
|
246
|
-
cleanup.dropped.push(p);
|
|
247
|
-
}
|
|
248
|
-
return cleanup;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
195
|
export const hasDrift = (d: PolicyDrift): boolean =>
|
|
252
196
|
d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0;
|
|
253
197
|
|
|
@@ -7,18 +7,15 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
9
9
|
getTableForCollection,
|
|
10
|
-
|
|
11
|
-
deriveRowAddress,
|
|
10
|
+
getPrimaryKeys,
|
|
12
11
|
parseIdValues,
|
|
13
|
-
buildCompositeId
|
|
14
|
-
COMPOSITE_ID_SEPARATOR
|
|
12
|
+
buildCompositeId
|
|
15
13
|
} from "./collection-helpers";
|
|
16
14
|
import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
|
|
17
15
|
import { RelationService } from "./RelationService";
|
|
18
16
|
import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
|
|
19
17
|
import { DrizzleClient } from "../interfaces";
|
|
20
18
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
21
|
-
import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
22
19
|
import { logger } from "@rebasepro/server";
|
|
23
20
|
|
|
24
21
|
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
@@ -112,7 +109,7 @@ export class FetchService {
|
|
|
112
109
|
// Detect many-to-many junction tables:
|
|
113
110
|
// If the relation goes through a junction table (relation.through exists or
|
|
114
111
|
// the Drizzle schema maps to a junction table), we need two-level with.
|
|
115
|
-
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
112
|
+
if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
|
|
116
113
|
// The Drizzle relation points to the junction table.
|
|
117
114
|
// We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
|
|
118
115
|
// The target FK name is the relation on the junction table that points to the actual target.
|
|
@@ -130,6 +127,17 @@ export class FetchService {
|
|
|
130
127
|
return withConfig;
|
|
131
128
|
}
|
|
132
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
132
|
+
*/
|
|
133
|
+
private isJunctionRelation(relation: Relation, _collection: CollectionConfig): boolean {
|
|
134
|
+
// If `through` is defined, it's explicitly a junction relation
|
|
135
|
+
if (relation.through) return true;
|
|
136
|
+
// If joinPath has an intermediate table, it's likely junction-based
|
|
137
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
/**
|
|
134
142
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
135
143
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
@@ -143,6 +151,85 @@ export class FetchService {
|
|
|
143
151
|
return null;
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
156
|
+
* Handles:
|
|
157
|
+
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
158
|
+
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
159
|
+
* - Flattening junction-table many-to-many results
|
|
160
|
+
*
|
|
161
|
+
* The row's own address is not among them: it is derived by the consumer
|
|
162
|
+
* from the collection's primary keys.
|
|
163
|
+
*/
|
|
164
|
+
private drizzleResultToRow<M extends Record<string, unknown>>(
|
|
165
|
+
row: Record<string, unknown>,
|
|
166
|
+
collection: CollectionConfig
|
|
167
|
+
): Record<string, unknown> {
|
|
168
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
169
|
+
|
|
170
|
+
// Normalize non-relation values (dates, numbers, etc.)
|
|
171
|
+
const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
|
|
172
|
+
|
|
173
|
+
// Convert nested relation objects to CMS-style { id, path, __type: "relation" }
|
|
174
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
175
|
+
const drizzleRelName = relation.relationName || key;
|
|
176
|
+
const relData = row[drizzleRelName];
|
|
177
|
+
|
|
178
|
+
if (relData === undefined || relData === null) continue;
|
|
179
|
+
|
|
180
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
181
|
+
const targetCollection = relation.target();
|
|
182
|
+
const targetPath = targetCollection.slug;
|
|
183
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
184
|
+
const targetIdField = targetPks[0].fieldName;
|
|
185
|
+
|
|
186
|
+
normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
|
|
187
|
+
// Handle junction table flattening:
|
|
188
|
+
// Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
|
|
189
|
+
let targetRow = item;
|
|
190
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
191
|
+
// Find the nested target object in the junction row
|
|
192
|
+
const nestedKey = Object.keys(item).find(
|
|
193
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
194
|
+
);
|
|
195
|
+
if (nestedKey) {
|
|
196
|
+
targetRow = item[nestedKey] as Record<string, unknown>;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
|
|
201
|
+
const targetValues = normalizeDbValues(targetRow, targetCollection);
|
|
202
|
+
|
|
203
|
+
return createRelationRefWithData(relId, targetPath, {
|
|
204
|
+
id: relId,
|
|
205
|
+
path: targetPath,
|
|
206
|
+
values: targetValues
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
210
|
+
const targetCollection = relation.target();
|
|
211
|
+
const targetPath = targetCollection.slug;
|
|
212
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
213
|
+
const targetIdField = targetPks[0].fieldName;
|
|
214
|
+
const relObj = relData as Record<string, unknown>;
|
|
215
|
+
|
|
216
|
+
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
217
|
+
const targetValues = normalizeDbValues(relObj, targetCollection);
|
|
218
|
+
|
|
219
|
+
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
220
|
+
id: relId,
|
|
221
|
+
path: targetPath,
|
|
222
|
+
values: targetValues
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A row is exactly its columns. The address is derived by the consumer
|
|
228
|
+
// from the collection's primary keys — writing it here would rename the
|
|
229
|
+
// key column (`sku` → `id`) and restringify it (`42` → `"42"`).
|
|
230
|
+
return normalizedValues;
|
|
231
|
+
}
|
|
232
|
+
|
|
146
233
|
/**
|
|
147
234
|
* Post-fetch joinPath relations for a single flat row.
|
|
148
235
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
@@ -184,6 +271,57 @@ export class FetchService {
|
|
|
184
271
|
await Promise.all(promises);
|
|
185
272
|
}
|
|
186
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Post-fetch joinPath relations for a batch of flat rows.
|
|
276
|
+
* Uses batch fetching to avoid N+1 queries for list views.
|
|
277
|
+
*/
|
|
278
|
+
private async resolveJoinPathRelationsBatch<M extends Record<string, unknown>>(
|
|
279
|
+
rows: Record<string, unknown>[],
|
|
280
|
+
collection: CollectionConfig,
|
|
281
|
+
collectionPath: string,
|
|
282
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
283
|
+
_databaseId?: string
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
if (rows.length === 0) return;
|
|
286
|
+
|
|
287
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
288
|
+
|
|
289
|
+
const joinPathRelations = Object.entries(resolvedRelations)
|
|
290
|
+
.filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
|
|
291
|
+
|
|
292
|
+
if (joinPathRelations.length === 0) return;
|
|
293
|
+
|
|
294
|
+
for (const [key, relation] of joinPathRelations) {
|
|
295
|
+
try {
|
|
296
|
+
const rowIds = rows.map(r => {
|
|
297
|
+
const parsed = parseIdValues(String(r.id), [idInfo]);
|
|
298
|
+
return parsed[idInfo.fieldName] as string | number;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const resultMap = await this.relationService.batchFetchRelatedEntities(
|
|
302
|
+
collectionPath,
|
|
303
|
+
rowIds,
|
|
304
|
+
key,
|
|
305
|
+
relation
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
for (const row of rows) {
|
|
309
|
+
const parsed = parseIdValues(String(row.id), [idInfo]);
|
|
310
|
+
const id = parsed[idInfo.fieldName] as string | number;
|
|
311
|
+
const relatedRow = resultMap.get(String(id));
|
|
312
|
+
|
|
313
|
+
if (relatedRow) {
|
|
314
|
+
if (relation.cardinality === "one") {
|
|
315
|
+
row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch (e) {
|
|
320
|
+
logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
187
325
|
/**
|
|
188
326
|
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
189
327
|
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
@@ -207,25 +345,15 @@ export class FetchService {
|
|
|
207
345
|
|
|
208
346
|
if (joinPathRelations.length === 0) return;
|
|
209
347
|
|
|
210
|
-
|
|
211
|
-
// is derived from them. It used to be parsed back out of a synthesized
|
|
212
|
-
// `id` — which no longer exists on a row, and threw (composite: parts
|
|
213
|
-
// mismatch; numeric: NaN) into the catch below, where a warning is all
|
|
214
|
-
// that separates "no relations" from "relations dropped".
|
|
215
|
-
//
|
|
216
|
-
// The whole key, because that is what the batch groups its results by:
|
|
217
|
-
// both sides derive the token the same way, so they agree by
|
|
218
|
-
// construction rather than by both happening to pick column zero.
|
|
219
|
-
const parentIdOf = (row: Record<string, unknown>): string | undefined => {
|
|
220
|
-
const address = buildCompositeId(row, idInfoArray);
|
|
221
|
-
return address && address.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "") ? address : undefined;
|
|
222
|
-
};
|
|
348
|
+
const idInfo = idInfoArray[0];
|
|
223
349
|
|
|
224
350
|
for (const [key, relation] of joinPathRelations) {
|
|
225
351
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
352
|
+
// Determine the parent IDs based on the parsed string ID from the REST row
|
|
353
|
+
const rowIds = rows.map(r => {
|
|
354
|
+
const parsed = parseIdValues(String(r.id), idInfoArray);
|
|
355
|
+
return parsed[idInfo.fieldName] as string | number;
|
|
356
|
+
});
|
|
229
357
|
|
|
230
358
|
if (relation.cardinality === "one") {
|
|
231
359
|
const resultMap = await this.relationService.batchFetchRelatedEntities(
|
|
@@ -235,11 +363,19 @@ export class FetchService {
|
|
|
235
363
|
relation
|
|
236
364
|
);
|
|
237
365
|
|
|
238
|
-
for (const row of
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
366
|
+
for (const row of rows) {
|
|
367
|
+
const parsed = parseIdValues(String(row.id), idInfoArray);
|
|
368
|
+
const id = parsed[idInfo.fieldName] as string | number;
|
|
369
|
+
const relatedRow = resultMap.get(String(id));
|
|
370
|
+
|
|
371
|
+
if (relatedRow) {
|
|
372
|
+
row[key] = {
|
|
373
|
+
...relatedRow.values,
|
|
374
|
+
id: relatedRow.id
|
|
375
|
+
};
|
|
376
|
+
} else {
|
|
377
|
+
row[key] = null;
|
|
378
|
+
}
|
|
243
379
|
}
|
|
244
380
|
} else if (relation.cardinality === "many") {
|
|
245
381
|
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
|
|
@@ -249,9 +385,14 @@ export class FetchService {
|
|
|
249
385
|
relation
|
|
250
386
|
);
|
|
251
387
|
|
|
252
|
-
for (const row of
|
|
253
|
-
const
|
|
254
|
-
|
|
388
|
+
for (const row of rows) {
|
|
389
|
+
const parsed = parseIdValues(String(row.id), idInfoArray);
|
|
390
|
+
const id = parsed[idInfo.fieldName] as string | number;
|
|
391
|
+
const relatedList = resultMap.get(String(id)) || [];
|
|
392
|
+
row[key] = relatedList.map(e => ({
|
|
393
|
+
...e.values,
|
|
394
|
+
id: e.id
|
|
395
|
+
}));
|
|
255
396
|
}
|
|
256
397
|
}
|
|
257
398
|
} catch (e) {
|
|
@@ -260,6 +401,47 @@ export class FetchService {
|
|
|
260
401
|
}
|
|
261
402
|
}
|
|
262
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Convert a db.query result row to a flat REST-style row with populated relations.
|
|
406
|
+
*
|
|
407
|
+
* Every column is copied through under its own name, with the value Postgres
|
|
408
|
+
* returned. This used to open with a synthesized `id` and then skip the key
|
|
409
|
+
* column, which renamed it (a `sku` primary key was served as `id`, and `sku`
|
|
410
|
+
* did not appear at all) and restringified it (`42` → `"42"`). Consumers that
|
|
411
|
+
* need an address derive it from the collection's primary keys.
|
|
412
|
+
*/
|
|
413
|
+
private drizzleResultToRestRow(
|
|
414
|
+
row: Record<string, unknown>,
|
|
415
|
+
collection: CollectionConfig
|
|
416
|
+
): Record<string, unknown> {
|
|
417
|
+
const flat: Record<string, unknown> = {};
|
|
418
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
419
|
+
|
|
420
|
+
for (const [k, v] of Object.entries(row)) {
|
|
421
|
+
const relation = findRelation(resolvedRelations, k);
|
|
422
|
+
if (Array.isArray(v) && relation) {
|
|
423
|
+
// Many relation — inline each nested row, unwrapping junction rows
|
|
424
|
+
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
425
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
426
|
+
const nestedKey = Object.keys(item).find(
|
|
427
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
428
|
+
);
|
|
429
|
+
if (nestedKey) {
|
|
430
|
+
return { ...(item[nestedKey] as Record<string, unknown>) };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { ...item };
|
|
434
|
+
});
|
|
435
|
+
} else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
436
|
+
// One-to-one relation — inline the target's columns
|
|
437
|
+
flat[k] = { ...(v as Record<string, unknown>) };
|
|
438
|
+
} else {
|
|
439
|
+
flat[k] = v;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return flat;
|
|
443
|
+
}
|
|
444
|
+
|
|
263
445
|
/**
|
|
264
446
|
* Build db.query-compatible options from standard fetch options.
|
|
265
447
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
@@ -401,7 +583,7 @@ export class FetchService {
|
|
|
401
583
|
): Promise<Record<string, unknown> | undefined> {
|
|
402
584
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
403
585
|
const table = getTableForCollection(collection, this.registry);
|
|
404
|
-
const idInfoArray =
|
|
586
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
405
587
|
const idInfo = idInfoArray[0];
|
|
406
588
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
407
589
|
|
|
@@ -428,7 +610,7 @@ export class FetchService {
|
|
|
428
610
|
|
|
429
611
|
if (!row) return undefined;
|
|
430
612
|
|
|
431
|
-
const flatRow =
|
|
613
|
+
const flatRow = this.drizzleResultToRow<M>(row, collection);
|
|
432
614
|
|
|
433
615
|
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
434
616
|
await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
@@ -520,7 +702,7 @@ export class FetchService {
|
|
|
520
702
|
): Promise<Record<string, unknown>[]> {
|
|
521
703
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
522
704
|
const table = getTableForCollection(collection, this.registry);
|
|
523
|
-
const idInfoArray =
|
|
705
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
524
706
|
const idInfo = idInfoArray[0];
|
|
525
707
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
526
708
|
|
|
@@ -552,7 +734,7 @@ export class FetchService {
|
|
|
552
734
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
553
735
|
|
|
554
736
|
const rows = (results as Record<string, unknown>[]).map(row =>
|
|
555
|
-
|
|
737
|
+
this.drizzleResultToRow<M>(row, collection)
|
|
556
738
|
);
|
|
557
739
|
|
|
558
740
|
return rows;
|
|
@@ -652,10 +834,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
652
834
|
|
|
653
835
|
/**
|
|
654
836
|
* Fallback path used when db.query is unavailable.
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* relations from what drizzle already nested — no query per row. This one
|
|
658
|
-
* has no nesting to read, so it resolves relations itself, in batches.
|
|
837
|
+
* The primary path uses drizzleResultToRow which handles relation
|
|
838
|
+
* mapping without N+1 queries.
|
|
659
839
|
*
|
|
660
840
|
* Process raw database results into flat rows with relations.
|
|
661
841
|
*/
|
|
@@ -840,12 +1020,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
840
1020
|
relationKey,
|
|
841
1021
|
options
|
|
842
1022
|
);
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
return rows.map(row => ({ ...row.values as Record<string, unknown> }));
|
|
1023
|
+
// Convert RelatedRow[] from RelationService to flat rows
|
|
1024
|
+
return rows.map(row => ({
|
|
1025
|
+
...row.values as Record<string, unknown>,
|
|
1026
|
+
id: row.id
|
|
1027
|
+
}));
|
|
849
1028
|
}
|
|
850
1029
|
|
|
851
1030
|
if (i + 1 < pathSegments.length) {
|
|
@@ -959,7 +1138,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
959
1138
|
|
|
960
1139
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
961
1140
|
const table = getTableForCollection(collection, this.registry);
|
|
962
|
-
const idInfoArray =
|
|
1141
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
963
1142
|
const idInfo = idInfoArray[0];
|
|
964
1143
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
965
1144
|
const field = table[fieldName as keyof typeof table] as AnyPgColumn;
|
|
@@ -1019,7 +1198,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1019
1198
|
): Promise<Record<string, unknown>[]> {
|
|
1020
1199
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1021
1200
|
const table = getTableForCollection(collection, this.registry);
|
|
1022
|
-
const idInfoArray =
|
|
1201
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1023
1202
|
const idInfo = idInfoArray[0];
|
|
1024
1203
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1025
1204
|
|
|
@@ -1046,7 +1225,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1046
1225
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
1047
1226
|
|
|
1048
1227
|
const restRows = (results as Record<string, unknown>[]).map(row =>
|
|
1049
|
-
|
|
1228
|
+
this.drizzleResultToRestRow(row, collection)
|
|
1050
1229
|
);
|
|
1051
1230
|
|
|
1052
1231
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
@@ -1125,7 +1304,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1125
1304
|
): Promise<Record<string, unknown> | null> {
|
|
1126
1305
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1127
1306
|
const table = getTableForCollection(collection, this.registry);
|
|
1128
|
-
const idInfoArray =
|
|
1307
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1129
1308
|
const idInfo = idInfoArray[0];
|
|
1130
1309
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1131
1310
|
|
|
@@ -1151,7 +1330,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1151
1330
|
|
|
1152
1331
|
if (!row) return null;
|
|
1153
1332
|
|
|
1154
|
-
const restRow =
|
|
1333
|
+
const restRow = this.drizzleResultToRestRow(row, collection);
|
|
1155
1334
|
|
|
1156
1335
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1157
1336
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
@@ -1233,7 +1412,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1233
1412
|
): Promise<Record<string, unknown>[]> {
|
|
1234
1413
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1235
1414
|
const table = getTableForCollection(collection, this.registry);
|
|
1236
|
-
const idInfoArray =
|
|
1415
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1237
1416
|
const idInfo = idInfoArray[0];
|
|
1238
1417
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1239
1418
|
|