@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/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
- // The client-side read timeout MUST be comfortably above the server-side
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;
@@ -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" | "sdk_not_generated";
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 === "uuid") return "uuid";
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
- // The generator passes any columnType straight through to drizzle,
76
- // so mirror that rather than enumerating a subset (which reported
77
- // drift for anything unlisted, e.g. smallint). Serial types are
78
- // integers with a sequence default; information_schema reports the
79
- // underlying width.
80
- const serialWidths: Record<string, string> = {
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
- // The typed SDK is opt-in — nothing in a scaffolded project imports it until
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: "info",
221
- category: "sdk_not_generated",
222
- message: "Typed SDK not generated (optional).",
223
- fix: "Run `rebase generate-sdk` if you want typed collection access"
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: true,
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
- const errorCount = issues.filter((i) => i.severity === "error").length;
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
- // Notes render as a quiet one-liner, not a full drift box.
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["--no-data-inference"]) {
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
- // --force replaces collections derived from the database, but the
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
- requirePrimaryKeys,
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
- // These rows carry their key columns verbatim, so the parent's address
211
- // is derived from them. It used to be parsed back out of a synthesized
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 = requirePrimaryKeys(collection, this.registry);
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 = toCmsRow(row, collection, this.registry);
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 = requirePrimaryKeys(collection, this.registry);
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
- toCmsRow(row, collection, this.registry)
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
- * The primary path runs the results through `toCmsRow`, which maps
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 = requirePrimaryKeys(collection, this.registry);
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 = requirePrimaryKeys(collection, this.registry);
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
- toRestRow(row, collection, this.registry)
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 = requirePrimaryKeys(collection, this.registry);
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 = toRestRow(row, collection, this.registry);
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 = requirePrimaryKeys(collection, this.registry);
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 saved row back through the same walk `GET /:id` serves, so a
387
- // write's answer is the read that follows it. This used to be `fetchOne`
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
  }