@rebasepro/server-postgres 0.9.1-canary.5809e39 → 0.9.1-canary.58368ce
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/index.es.js +243 -455
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/services/FetchService.d.ts +38 -4
- package/dist/services/RelationService.d.ts +0 -34
- package/dist/services/collection-helpers.d.ts +7 -34
- package/package.json +7 -10
- package/src/PostgresBackendDriver.ts +5 -18
- package/src/PostgresBootstrapper.ts +2 -51
- package/src/auth/ensure-tables.ts +11 -73
- package/src/auth/services.ts +19 -49
- package/src/connection.ts +1 -61
- package/src/databasePoolManager.ts +0 -2
- package/src/schema/doctor.ts +20 -45
- package/src/schema/introspect-db.ts +2 -19
- package/src/services/FetchService.ts +163 -29
- package/src/services/PersistService.ts +2 -9
- package/src/services/RelationService.ts +93 -153
- package/src/services/collection-helpers.ts +27 -59
- package/src/services/realtimeService.ts +2 -4
- package/src/utils/drizzle-conditions.ts +0 -13
- package/dist/services/row-pipeline.d.ts +0 -63
- package/src/services/row-pipeline.ts +0 -239
package/src/connection.ts
CHANGED
|
@@ -27,68 +27,11 @@ const DEFAULT_POOL: Required<PostgresPoolConfig> = {
|
|
|
27
27
|
max: 20,
|
|
28
28
|
idleTimeoutMillis: 30_000,
|
|
29
29
|
connectionTimeoutMillis: 10_000,
|
|
30
|
-
|
|
31
|
-
// statement_timeout. When the client timer fires first, node-postgres
|
|
32
|
-
// abandons the in-flight statement but keeps the connection — inside a
|
|
33
|
-
// transaction that leaves the tx open (and any pending ROLLBACK is
|
|
34
|
-
// spliced out of the client queue before it ever reaches the wire), so
|
|
35
|
-
// the pooled connection is returned still in-transaction with its RLS
|
|
36
|
-
// GUCs set. The server abort (SQLSTATE 57014) is the clean path; the
|
|
37
|
-
// client timeout is only a backstop for a dead network.
|
|
38
|
-
queryTimeout: 60_000,
|
|
30
|
+
queryTimeout: 30_000,
|
|
39
31
|
statementTimeout: 30_000,
|
|
40
32
|
keepAlive: true
|
|
41
33
|
};
|
|
42
34
|
|
|
43
|
-
/** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
|
|
44
|
-
const TX_IDLE = "I";
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Destroy pool clients that are released while still inside a transaction.
|
|
48
|
-
*
|
|
49
|
-
* pg-pool returns a client to the idle list whenever `release()` is called
|
|
50
|
-
* without an error — even if the connection is still mid-transaction (status
|
|
51
|
-
* `T`/`E`). That happens in practice: drizzle's pool transaction releases in
|
|
52
|
-
* a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
|
|
53
|
-
* (e.g. it was queued behind a statement that hit the client-side
|
|
54
|
-
* query_timeout), the client goes back dirty. The next checkout then runs
|
|
55
|
-
* its statements inside the zombie transaction — with the previous request's
|
|
56
|
-
* `app.*` RLS GUCs still applied, which turns unrelated queries into
|
|
57
|
-
* RLS-scoped ones (observed in production as registration failing with
|
|
58
|
-
* SQLSTATE 42501 under a leaked anonymous context).
|
|
59
|
-
*
|
|
60
|
-
* pg-pool emits `release` before it consults its private `_expired` set, so
|
|
61
|
-
* marking the client expired here makes `_release()` destroy it instead of
|
|
62
|
-
* pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
|
|
63
|
-
* private APIs — feature-detect and fall back to loud logging so an upstream
|
|
64
|
-
* change degrades to observability, never to silent corruption.
|
|
65
|
-
*/
|
|
66
|
-
export function guardPoolAgainstDirtyRelease(pool: Pool, label: string): void {
|
|
67
|
-
pool.on("release", (err: Error | undefined, client: unknown) => {
|
|
68
|
-
if (err) return; // errored clients are already destroyed by pg-pool
|
|
69
|
-
const txStatus = (client as { _txStatus?: string | null })?._txStatus;
|
|
70
|
-
if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
|
|
71
|
-
|
|
72
|
-
// pg-pool keeps expired clients in a WeakSet (a plain object works too
|
|
73
|
-
// if upstream ever changes it — duck-type on add/has).
|
|
74
|
-
const expired = (pool as unknown as { _expired?: { add(c: object): unknown; has(c: object): boolean } })._expired;
|
|
75
|
-
if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
|
|
76
|
-
expired.add(client);
|
|
77
|
-
logger.error(
|
|
78
|
-
`[${label}] Client released back to the pool while still in a transaction ` +
|
|
79
|
-
`(status '${txStatus}') — destroying it so the open transaction and its ` +
|
|
80
|
-
`session state (RLS GUCs) cannot leak into the next request.`
|
|
81
|
-
);
|
|
82
|
-
} else {
|
|
83
|
-
logger.error(
|
|
84
|
-
`[${label}] Client released mid-transaction (status '${txStatus}') but the ` +
|
|
85
|
-
`pool's internal expiry set is unavailable (pg-pool internals changed?). ` +
|
|
86
|
-
`The connection may leak its open transaction into subsequent requests.`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
35
|
/**
|
|
93
36
|
* Create a Drizzle-backed Postgres connection with a production-grade
|
|
94
37
|
* connection pool.
|
|
@@ -132,7 +75,6 @@ export function createPostgresDatabaseConnection(
|
|
|
132
75
|
logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
|
|
133
76
|
}
|
|
134
77
|
});
|
|
135
|
-
guardPoolAgainstDirtyRelease(pool, "pg-pool");
|
|
136
78
|
|
|
137
79
|
// Create drizzle instance — pass schema when available to enable db.query relational API
|
|
138
80
|
const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
|
|
@@ -176,7 +118,6 @@ export function createDirectDatabaseConnection(
|
|
|
176
118
|
pool.on("error", (err) => {
|
|
177
119
|
logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
|
|
178
120
|
});
|
|
179
|
-
guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
|
|
180
121
|
|
|
181
122
|
const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
|
|
182
123
|
|
|
@@ -216,7 +157,6 @@ export function createReadReplicaConnection(
|
|
|
216
157
|
pool.on("error", (err) => {
|
|
217
158
|
logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
|
|
218
159
|
});
|
|
219
|
-
guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
|
|
220
160
|
|
|
221
161
|
const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
|
|
222
162
|
|
|
@@ -2,7 +2,6 @@ import { Pool } from "pg";
|
|
|
2
2
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { logger } from "@rebasepro/server";
|
|
5
|
-
import { guardPoolAgainstDirtyRelease } from "./connection";
|
|
6
5
|
|
|
7
6
|
export class DatabasePoolManager {
|
|
8
7
|
private pools: Map<string, Pool> = new Map();
|
|
@@ -51,7 +50,6 @@ export class DatabasePoolManager {
|
|
|
51
50
|
pool.on("error", (err) => {
|
|
52
51
|
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
53
52
|
});
|
|
54
|
-
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
55
53
|
|
|
56
54
|
this.pools.set(databaseName, pool);
|
|
57
55
|
return pool;
|
package/src/schema/doctor.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type IssueSeverity = "error" | "warning" | "info";
|
|
|
37
37
|
|
|
38
38
|
export interface DoctorIssue {
|
|
39
39
|
severity: IssueSeverity;
|
|
40
|
-
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale"
|
|
40
|
+
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale";
|
|
41
41
|
table?: string;
|
|
42
42
|
column?: string;
|
|
43
43
|
expected?: string;
|
|
@@ -61,29 +61,19 @@ export function getExpectedColumnType(prop: Property): string | null {
|
|
|
61
61
|
const sp = prop as StringProperty;
|
|
62
62
|
if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
|
|
63
63
|
if ("isId" in sp && sp.isId === "uuid") return "uuid";
|
|
64
|
-
if (sp.columnType === "
|
|
65
|
-
// A markdown/multiline string compiles to `text`, not varchar — the
|
|
66
|
-
// generator treats those UI hints as column-type signals, so the
|
|
67
|
-
// expectation here must too or every such column reads as drift.
|
|
68
|
-
if (sp.columnType === "text" || sp.ui?.markdown || sp.ui?.multiline) return "text";
|
|
64
|
+
if (sp.columnType === "text") return "text";
|
|
69
65
|
if (sp.columnType === "char") return "character";
|
|
70
66
|
return "character varying";
|
|
71
67
|
}
|
|
72
68
|
case "number": {
|
|
73
69
|
const np = prop as NumberProperty;
|
|
74
|
-
if (np.columnType)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
serial: "integer",
|
|
82
|
-
bigserial: "bigint",
|
|
83
|
-
smallserial: "smallint"
|
|
84
|
-
};
|
|
85
|
-
return serialWidths[np.columnType] ?? np.columnType;
|
|
86
|
-
}
|
|
70
|
+
if (np.columnType === "double precision") return "double precision";
|
|
71
|
+
if (np.columnType === "real") return "real";
|
|
72
|
+
if (np.columnType === "bigint") return "bigint";
|
|
73
|
+
if (np.columnType === "serial") return "integer"; // serial is integer under the hood
|
|
74
|
+
if (np.columnType === "bigserial") return "bigint";
|
|
75
|
+
if (np.columnType === "integer") return "integer";
|
|
76
|
+
if (np.columnType === "numeric") return "numeric";
|
|
87
77
|
if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
|
|
88
78
|
return "numeric";
|
|
89
79
|
}
|
|
@@ -211,18 +201,15 @@ export async function checkCollectionsVsSdk(
|
|
|
211
201
|
): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
|
|
212
202
|
const issues: DoctorIssue[] = [];
|
|
213
203
|
|
|
214
|
-
//
|
|
215
|
-
// you choose to. A project that never generated one isn't drifting, so report
|
|
216
|
-
// it as information rather than a warning; otherwise `doctor` can never come
|
|
217
|
-
// back clean on a fresh project and users learn to ignore its output.
|
|
204
|
+
// Check if SDK file exists
|
|
218
205
|
if (!fs.existsSync(sdkFilePath)) {
|
|
219
206
|
issues.push({
|
|
220
|
-
severity: "
|
|
221
|
-
category: "
|
|
222
|
-
message:
|
|
223
|
-
fix: "Run `rebase generate-sdk`
|
|
207
|
+
severity: "warning",
|
|
208
|
+
category: "sdk_stale",
|
|
209
|
+
message: `Generated SDK typedefs file does not exist at "${sdkFilePath}".`,
|
|
210
|
+
fix: "Run `rebase generate-sdk`"
|
|
224
211
|
});
|
|
225
|
-
return { passed:
|
|
212
|
+
return { passed: false,
|
|
226
213
|
issues };
|
|
227
214
|
}
|
|
228
215
|
|
|
@@ -644,15 +631,11 @@ export function renderReport(report: DoctorReport): void {
|
|
|
644
631
|
}
|
|
645
632
|
|
|
646
633
|
function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
|
|
647
|
-
|
|
648
|
-
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
649
|
-
const infoIssues = issues.filter((i) => i.severity === "info");
|
|
650
|
-
|
|
651
|
-
// Informational notes don't make a phase unhealthy, so key the header off
|
|
652
|
-
// real problems rather than `passed` alone.
|
|
653
|
-
if (errorCount === 0 && warnCount === 0) {
|
|
634
|
+
if (passed) {
|
|
654
635
|
logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
655
636
|
} else {
|
|
637
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
638
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
656
639
|
const parts: string[] = [];
|
|
657
640
|
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
658
641
|
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
@@ -660,14 +643,7 @@ function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): voi
|
|
|
660
643
|
}
|
|
661
644
|
logger.info("");
|
|
662
645
|
|
|
663
|
-
|
|
664
|
-
for (const issue of infoIssues) {
|
|
665
|
-
const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
|
|
666
|
-
logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
|
|
667
|
-
logger.info("");
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
for (const issue of issues.filter((i) => i.severity !== "info")) {
|
|
646
|
+
for (const issue of issues) {
|
|
671
647
|
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
672
648
|
const categoryLabel = formatCategory(issue.category);
|
|
673
649
|
logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
@@ -698,8 +674,7 @@ function formatCategory(cat: DoctorIssue["category"]): string {
|
|
|
698
674
|
missing_enum: "Missing Enum",
|
|
699
675
|
enum_value_mismatch: "Enum Value Mismatch",
|
|
700
676
|
missing_foreign_key: "Missing Foreign Key",
|
|
701
|
-
sdk_stale: "Stale SDK Types"
|
|
702
|
-
sdk_not_generated: "SDK Types Not Generated"
|
|
677
|
+
sdk_stale: "Stale SDK Types"
|
|
703
678
|
};
|
|
704
679
|
return labels[cat];
|
|
705
680
|
}
|
|
@@ -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}`));
|
|
@@ -7,18 +7,16 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
9
9
|
getTableForCollection,
|
|
10
|
-
|
|
10
|
+
getPrimaryKeys,
|
|
11
11
|
deriveRowAddress,
|
|
12
12
|
parseIdValues,
|
|
13
|
-
buildCompositeId
|
|
14
|
-
COMPOSITE_ID_SEPARATOR
|
|
13
|
+
buildCompositeId
|
|
15
14
|
} from "./collection-helpers";
|
|
16
15
|
import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
|
|
17
16
|
import { RelationService } from "./RelationService";
|
|
18
17
|
import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
|
|
19
18
|
import { DrizzleClient } from "../interfaces";
|
|
20
19
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
21
|
-
import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
22
20
|
import { logger } from "@rebasepro/server";
|
|
23
21
|
|
|
24
22
|
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
@@ -112,7 +110,7 @@ export class FetchService {
|
|
|
112
110
|
// Detect many-to-many junction tables:
|
|
113
111
|
// If the relation goes through a junction table (relation.through exists or
|
|
114
112
|
// the Drizzle schema maps to a junction table), we need two-level with.
|
|
115
|
-
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
113
|
+
if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
|
|
116
114
|
// The Drizzle relation points to the junction table.
|
|
117
115
|
// We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
|
|
118
116
|
// The target FK name is the relation on the junction table that points to the actual target.
|
|
@@ -130,6 +128,17 @@ export class FetchService {
|
|
|
130
128
|
return withConfig;
|
|
131
129
|
}
|
|
132
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
133
|
+
*/
|
|
134
|
+
private isJunctionRelation(relation: Relation, _collection: CollectionConfig): boolean {
|
|
135
|
+
// If `through` is defined, it's explicitly a junction relation
|
|
136
|
+
if (relation.through) return true;
|
|
137
|
+
// If joinPath has an intermediate table, it's likely junction-based
|
|
138
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
133
142
|
/**
|
|
134
143
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
135
144
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
@@ -143,6 +152,98 @@ export class FetchService {
|
|
|
143
152
|
return null;
|
|
144
153
|
}
|
|
145
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The address a relation ref points at.
|
|
157
|
+
*
|
|
158
|
+
* The whole key, not its first column: a composite-keyed target addressed
|
|
159
|
+
* by `tenant_id` alone points at every row that shares it. And a target
|
|
160
|
+
* whose key cannot be resolved at all used to throw here — reading
|
|
161
|
+
* `targetPks[0]` of an empty array — taking down the parent's fetch over a
|
|
162
|
+
* relation it may not even have asked for; the first column is a guess, but
|
|
163
|
+
* a ref that resolves to nothing beats no rows at all.
|
|
164
|
+
*/
|
|
165
|
+
private relationTargetAddress(targetRow: Record<string, unknown>, targetCollection: CollectionConfig): string {
|
|
166
|
+
const address = deriveRowAddress(targetRow, targetCollection, this.registry);
|
|
167
|
+
if (address) return address;
|
|
168
|
+
const firstColumn = targetRow[Object.keys(targetRow)[0]];
|
|
169
|
+
return String(firstColumn ?? "");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
174
|
+
* Handles:
|
|
175
|
+
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
176
|
+
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
177
|
+
* - Flattening junction-table many-to-many results
|
|
178
|
+
*
|
|
179
|
+
* The row's own address is not among them: it is derived by the consumer
|
|
180
|
+
* from the collection's primary keys.
|
|
181
|
+
*/
|
|
182
|
+
private drizzleResultToRow<M extends Record<string, unknown>>(
|
|
183
|
+
row: Record<string, unknown>,
|
|
184
|
+
collection: CollectionConfig
|
|
185
|
+
): Record<string, unknown> {
|
|
186
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
187
|
+
|
|
188
|
+
// Normalize non-relation values (dates, numbers, etc.)
|
|
189
|
+
const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
|
|
190
|
+
|
|
191
|
+
// Convert nested relation objects to CMS-style { id, path, __type: "relation" }
|
|
192
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
193
|
+
const drizzleRelName = relation.relationName || key;
|
|
194
|
+
const relData = row[drizzleRelName];
|
|
195
|
+
|
|
196
|
+
if (relData === undefined || relData === null) continue;
|
|
197
|
+
|
|
198
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
199
|
+
const targetCollection = relation.target();
|
|
200
|
+
const targetPath = targetCollection.slug;
|
|
201
|
+
|
|
202
|
+
normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
|
|
203
|
+
// Handle junction table flattening:
|
|
204
|
+
// Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
|
|
205
|
+
let targetRow = item;
|
|
206
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
207
|
+
// Find the nested target object in the junction row
|
|
208
|
+
const nestedKey = Object.keys(item).find(
|
|
209
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
210
|
+
);
|
|
211
|
+
if (nestedKey) {
|
|
212
|
+
targetRow = item[nestedKey] as Record<string, unknown>;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const relId = this.relationTargetAddress(targetRow, targetCollection);
|
|
217
|
+
const targetValues = normalizeDbValues(targetRow, targetCollection);
|
|
218
|
+
|
|
219
|
+
return createRelationRefWithData(relId, targetPath, {
|
|
220
|
+
id: relId,
|
|
221
|
+
path: targetPath,
|
|
222
|
+
values: targetValues
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
226
|
+
const targetCollection = relation.target();
|
|
227
|
+
const targetPath = targetCollection.slug;
|
|
228
|
+
const relObj = relData as Record<string, unknown>;
|
|
229
|
+
|
|
230
|
+
const relId = this.relationTargetAddress(relObj, targetCollection);
|
|
231
|
+
const targetValues = normalizeDbValues(relObj, targetCollection);
|
|
232
|
+
|
|
233
|
+
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
234
|
+
id: relId,
|
|
235
|
+
path: targetPath,
|
|
236
|
+
values: targetValues
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// A row is exactly its columns. The address is derived by the consumer
|
|
242
|
+
// from the collection's primary keys — writing it here would rename the
|
|
243
|
+
// key column (`sku` → `id`) and restringify it (`42` → `"42"`).
|
|
244
|
+
return normalizedValues;
|
|
245
|
+
}
|
|
246
|
+
|
|
146
247
|
/**
|
|
147
248
|
* Post-fetch joinPath relations for a single flat row.
|
|
148
249
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
@@ -207,19 +308,13 @@ export class FetchService {
|
|
|
207
308
|
|
|
208
309
|
if (joinPathRelations.length === 0) return;
|
|
209
310
|
|
|
210
|
-
|
|
211
|
-
//
|
|
311
|
+
const idInfo = idInfoArray[0];
|
|
312
|
+
// These rows carry their key columns verbatim, so the parent id is read
|
|
313
|
+
// straight off them. It used to be parsed back out of a synthesized
|
|
212
314
|
// `id` — which no longer exists on a row, and threw (composite: parts
|
|
213
315
|
// mismatch; numeric: NaN) into the catch below, where a warning is all
|
|
214
316
|
// 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
|
-
};
|
|
317
|
+
const parentIdOf = (row: Record<string, unknown>) => row[idInfo.fieldName] as string | number | undefined;
|
|
223
318
|
|
|
224
319
|
for (const [key, relation] of joinPathRelations) {
|
|
225
320
|
try {
|
|
@@ -260,6 +355,47 @@ export class FetchService {
|
|
|
260
355
|
}
|
|
261
356
|
}
|
|
262
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Convert a db.query result row to a flat REST-style row with populated relations.
|
|
360
|
+
*
|
|
361
|
+
* Every column is copied through under its own name, with the value Postgres
|
|
362
|
+
* returned. This used to open with a synthesized `id` and then skip the key
|
|
363
|
+
* column, which renamed it (a `sku` primary key was served as `id`, and `sku`
|
|
364
|
+
* did not appear at all) and restringified it (`42` → `"42"`). Consumers that
|
|
365
|
+
* need an address derive it from the collection's primary keys.
|
|
366
|
+
*/
|
|
367
|
+
private drizzleResultToRestRow(
|
|
368
|
+
row: Record<string, unknown>,
|
|
369
|
+
collection: CollectionConfig
|
|
370
|
+
): Record<string, unknown> {
|
|
371
|
+
const flat: Record<string, unknown> = {};
|
|
372
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
373
|
+
|
|
374
|
+
for (const [k, v] of Object.entries(row)) {
|
|
375
|
+
const relation = findRelation(resolvedRelations, k);
|
|
376
|
+
if (Array.isArray(v) && relation) {
|
|
377
|
+
// Many relation — inline each nested row, unwrapping junction rows
|
|
378
|
+
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
379
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
380
|
+
const nestedKey = Object.keys(item).find(
|
|
381
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
382
|
+
);
|
|
383
|
+
if (nestedKey) {
|
|
384
|
+
return { ...(item[nestedKey] as Record<string, unknown>) };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { ...item };
|
|
388
|
+
});
|
|
389
|
+
} else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
390
|
+
// One-to-one relation — inline the target's columns
|
|
391
|
+
flat[k] = { ...(v as Record<string, unknown>) };
|
|
392
|
+
} else {
|
|
393
|
+
flat[k] = v;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return flat;
|
|
397
|
+
}
|
|
398
|
+
|
|
263
399
|
/**
|
|
264
400
|
* Build db.query-compatible options from standard fetch options.
|
|
265
401
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
@@ -401,7 +537,7 @@ export class FetchService {
|
|
|
401
537
|
): Promise<Record<string, unknown> | undefined> {
|
|
402
538
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
403
539
|
const table = getTableForCollection(collection, this.registry);
|
|
404
|
-
const idInfoArray =
|
|
540
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
405
541
|
const idInfo = idInfoArray[0];
|
|
406
542
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
407
543
|
|
|
@@ -428,7 +564,7 @@ export class FetchService {
|
|
|
428
564
|
|
|
429
565
|
if (!row) return undefined;
|
|
430
566
|
|
|
431
|
-
const flatRow =
|
|
567
|
+
const flatRow = this.drizzleResultToRow<M>(row, collection);
|
|
432
568
|
|
|
433
569
|
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
434
570
|
await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
@@ -520,7 +656,7 @@ export class FetchService {
|
|
|
520
656
|
): Promise<Record<string, unknown>[]> {
|
|
521
657
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
522
658
|
const table = getTableForCollection(collection, this.registry);
|
|
523
|
-
const idInfoArray =
|
|
659
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
524
660
|
const idInfo = idInfoArray[0];
|
|
525
661
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
526
662
|
|
|
@@ -552,7 +688,7 @@ export class FetchService {
|
|
|
552
688
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
553
689
|
|
|
554
690
|
const rows = (results as Record<string, unknown>[]).map(row =>
|
|
555
|
-
|
|
691
|
+
this.drizzleResultToRow<M>(row, collection)
|
|
556
692
|
);
|
|
557
693
|
|
|
558
694
|
return rows;
|
|
@@ -652,10 +788,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
652
788
|
|
|
653
789
|
/**
|
|
654
790
|
* 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.
|
|
791
|
+
* The primary path uses drizzleResultToRow which handles relation
|
|
792
|
+
* mapping without N+1 queries.
|
|
659
793
|
*
|
|
660
794
|
* Process raw database results into flat rows with relations.
|
|
661
795
|
*/
|
|
@@ -959,7 +1093,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
959
1093
|
|
|
960
1094
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
961
1095
|
const table = getTableForCollection(collection, this.registry);
|
|
962
|
-
const idInfoArray =
|
|
1096
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
963
1097
|
const idInfo = idInfoArray[0];
|
|
964
1098
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
965
1099
|
const field = table[fieldName as keyof typeof table] as AnyPgColumn;
|
|
@@ -1019,7 +1153,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1019
1153
|
): Promise<Record<string, unknown>[]> {
|
|
1020
1154
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1021
1155
|
const table = getTableForCollection(collection, this.registry);
|
|
1022
|
-
const idInfoArray =
|
|
1156
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1023
1157
|
const idInfo = idInfoArray[0];
|
|
1024
1158
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1025
1159
|
|
|
@@ -1046,7 +1180,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1046
1180
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
1047
1181
|
|
|
1048
1182
|
const restRows = (results as Record<string, unknown>[]).map(row =>
|
|
1049
|
-
|
|
1183
|
+
this.drizzleResultToRestRow(row, collection)
|
|
1050
1184
|
);
|
|
1051
1185
|
|
|
1052
1186
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
@@ -1125,7 +1259,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1125
1259
|
): Promise<Record<string, unknown> | null> {
|
|
1126
1260
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1127
1261
|
const table = getTableForCollection(collection, this.registry);
|
|
1128
|
-
const idInfoArray =
|
|
1262
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1129
1263
|
const idInfo = idInfoArray[0];
|
|
1130
1264
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1131
1265
|
|
|
@@ -1151,7 +1285,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1151
1285
|
|
|
1152
1286
|
if (!row) return null;
|
|
1153
1287
|
|
|
1154
|
-
const restRow =
|
|
1288
|
+
const restRow = this.drizzleResultToRestRow(row, collection);
|
|
1155
1289
|
|
|
1156
1290
|
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1157
1291
|
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
@@ -1233,7 +1367,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1233
1367
|
): Promise<Record<string, unknown>[]> {
|
|
1234
1368
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1235
1369
|
const table = getTableForCollection(collection, this.registry);
|
|
1236
|
-
const idInfoArray =
|
|
1370
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1237
1371
|
const idInfo = idInfoArray[0];
|
|
1238
1372
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1239
1373
|
|
|
@@ -383,15 +383,8 @@ export class PersistService {
|
|
|
383
383
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
-
// Fetch the
|
|
387
|
-
|
|
388
|
-
// — the admin view-model walk — whose `__type: "relation"` refs then
|
|
389
|
-
// leaked into REST responses, afterSave callbacks, history records and
|
|
390
|
-
// app-level realtime payloads, none of which see that shape from any
|
|
391
|
-
// read path. The admin does not need them here either: its rows arrive
|
|
392
|
-
// over the realtime subscription refetch, which still serves the
|
|
393
|
-
// view-model walk.
|
|
394
|
-
const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, undefined, databaseId);
|
|
386
|
+
// Fetch the updated/created row to return with proper relation objects
|
|
387
|
+
const finalEntity = await this.fetchService.fetchOne<M>(collection.slug, savedId, databaseId);
|
|
395
388
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
396
389
|
return finalEntity;
|
|
397
390
|
}
|