@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g18cfeb7

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.
Files changed (112) hide show
  1. package/dist/PostgresBackendDriver.d.ts +49 -2
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
  5. package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
  6. package/dist/backup/backup-service.d.ts +10 -1
  7. package/dist/backup/pg-tools.d.ts +47 -0
  8. package/dist/backup-service-Bww-Lg0s.js +8999 -0
  9. package/dist/backup-service-Bww-Lg0s.js.map +1 -0
  10. package/dist/cli-helpers.d.ts +40 -1
  11. package/dist/connection-BuZ97wsr.js +250 -0
  12. package/dist/connection-BuZ97wsr.js.map +1 -0
  13. package/dist/connection.d.ts +42 -0
  14. package/dist/ensure-collection-policies-DMUdRQdM.js +57 -0
  15. package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
  16. package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
  17. package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
  18. package/dist/index.es.js +1701 -10167
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/policy-CPkCqVTz.js +105 -0
  21. package/dist/policy-CPkCqVTz.js.map +1 -0
  22. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  24. package/dist/schema/auth-schema.d.ts +83 -144
  25. package/dist/schema/auth-users-columns.d.ts +97 -0
  26. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  27. package/dist/schema/ensure-collection-tables.d.ts +45 -3
  28. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  29. package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -3
  30. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  31. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  32. package/dist/schema/introspect-db-logic.d.ts +94 -5
  33. package/dist/schema/introspect-db-queries.d.ts +119 -0
  34. package/dist/schema/introspect-db-structure.d.ts +263 -0
  35. package/dist/schema/introspect-db-types.d.ts +11 -0
  36. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  37. package/dist/security/rls-enforcement.d.ts +53 -2
  38. package/dist/services/FetchService.d.ts +14 -8
  39. package/dist/services/RelationService.d.ts +24 -1
  40. package/dist/services/channel-bus/index.d.ts +1 -7
  41. package/dist/services/collection-helpers.d.ts +36 -2
  42. package/dist/services/dataService.d.ts +5 -1
  43. package/dist/services/realtimeService.d.ts +25 -21
  44. package/dist/services/row-pipeline.d.ts +1 -1
  45. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  46. package/dist/src-C_wvdMnl.js.map +1 -0
  47. package/dist/utils/connection-string.d.ts +29 -0
  48. package/dist/utils/drizzle-conditions.d.ts +5 -4
  49. package/dist/utils/pg-error-utils.d.ts +35 -0
  50. package/dist/websocket-D0TBU3ia.js +530 -0
  51. package/dist/websocket-D0TBU3ia.js.map +1 -0
  52. package/package.json +17 -16
  53. package/src/PostgresAdapter.ts +21 -2
  54. package/src/PostgresBackendDriver.ts +169 -3
  55. package/src/PostgresBootstrapper.ts +233 -35
  56. package/src/auth/ensure-tables.ts +335 -81
  57. package/src/auth/services.ts +24 -2
  58. package/src/backup/backup-cli.ts +41 -2
  59. package/src/backup/backup-service.ts +38 -5
  60. package/src/backup/pg-tools.ts +96 -3
  61. package/src/cli-helpers.ts +92 -9
  62. package/src/cli.ts +219 -56
  63. package/src/collections/validate-relations.ts +136 -14
  64. package/src/connection.ts +73 -0
  65. package/src/data-transformer.ts +22 -6
  66. package/src/databasePoolManager.ts +5 -2
  67. package/src/history/ensure-history-table.ts +7 -0
  68. package/src/schema/auth-schema.ts +30 -19
  69. package/src/schema/auth-users-columns.ts +131 -0
  70. package/src/schema/doctor.ts +7 -5
  71. package/src/schema/ensure-collection-policies.ts +105 -0
  72. package/src/schema/ensure-collection-tables.test.ts +105 -9
  73. package/src/schema/ensure-collection-tables.ts +301 -44
  74. package/src/schema/generate-drizzle-schema-logic.ts +49 -7
  75. package/src/schema/generate-postgres-ddl-logic.ts +474 -27
  76. package/src/schema/generated-schema-staleness.ts +169 -0
  77. package/src/schema/introspect-db-constraints.ts +385 -0
  78. package/src/schema/introspect-db-inference.ts +18 -8
  79. package/src/schema/introspect-db-logic.ts +385 -71
  80. package/src/schema/introspect-db-queries.ts +326 -0
  81. package/src/schema/introspect-db-structure.ts +670 -0
  82. package/src/schema/introspect-db-types.ts +56 -0
  83. package/src/schema/introspect-db.ts +37 -80
  84. package/src/schema/introspect-runtime.test.ts +56 -8
  85. package/src/schema/introspect-runtime.ts +31 -9
  86. package/src/schema/non-sql-collections.test.ts +131 -0
  87. package/src/schema/rls-bootstrap-sql.ts +288 -0
  88. package/src/security/anonymous-grants.test.ts +4 -2
  89. package/src/security/policy-drift.test.ts +11 -3
  90. package/src/security/rls-enforcement.ts +141 -3
  91. package/src/services/BranchService.ts +71 -28
  92. package/src/services/FetchService.ts +100 -107
  93. package/src/services/PersistService.ts +34 -7
  94. package/src/services/RelationService.ts +249 -48
  95. package/src/services/channel-bus/index.ts +0 -9
  96. package/src/services/channel-history.ts +8 -0
  97. package/src/services/channel-presence.ts +6 -0
  98. package/src/services/collection-helpers.ts +69 -3
  99. package/src/services/dataService.ts +5 -1
  100. package/src/services/realtimeService.ts +38 -35
  101. package/src/services/row-pipeline.ts +1 -1
  102. package/src/utils/connection-string.ts +58 -0
  103. package/src/utils/drizzle-conditions.ts +31 -6
  104. package/src/utils/pg-error-utils.ts +65 -0
  105. package/src/websocket.ts +18 -9
  106. package/dist/chunk-DSJWtz9O.js +0 -40
  107. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  108. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  109. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  110. package/dist/src-BbFOPJ1S.js.map +0 -1
  111. package/dist/src-Zqwaw3P5.js.map +0 -1
  112. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { sql } from "drizzle-orm";
11
11
  import { BranchInfo } from "@rebasepro/types";
12
+ import { revokeInternalTableSql } from "@rebasepro/common";
12
13
  import { DrizzleClient } from "../interfaces";
13
14
  import { DatabasePoolManager } from "../databasePoolManager";
14
15
  import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
@@ -64,20 +65,41 @@ function validateIdentifier(value: string, label: string): void {
64
65
  }
65
66
 
66
67
  /**
67
- * Sanitize a user-provided branch name to a safe PostgreSQL identifier.
68
- * Only allows alphanumeric characters and underscores.
68
+ * Postgres truncates identifiers at NAMEDATALEN-1 = 63 bytes, and does it
69
+ * silently. The prefix comes out of the same budget.
69
70
  */
70
- function sanitizeBranchName(name: string): string {
71
- return name.replace(/[^a-zA-Z0-9_]/g, "");
71
+ const MAX_BRANCH_NAME_LENGTH = 63 - BRANCH_DB_PREFIX.length;
72
+
73
+ /**
74
+ * Check a user-provided branch name, and otherwise leave it exactly as given.
75
+ *
76
+ * This used to strip everything outside [a-zA-Z0-9_], so `my-feature` was
77
+ * quietly created as `myfeature`: the name you typed was not the name `list`
78
+ * gave back. Nothing ever needed that. Every identifier this service builds is
79
+ * double-quoted (see `CREATE DATABASE` below), which is what makes a hyphen
80
+ * safe, and `validateIdentifier` has always accepted hyphens for the `--from`
81
+ * source database — the two disagreed about the same character class.
82
+ *
83
+ * Refusing a name we cannot represent is better than representing a different
84
+ * one, which is also why the length is checked here rather than left to
85
+ * Postgres, whose answer to an over-long identifier is a silent rename.
86
+ */
87
+ function assertValidBranchName(name: string): void {
88
+ validateIdentifier(name, "branch name");
89
+ if (name.length > MAX_BRANCH_NAME_LENGTH) {
90
+ throw new Error(
91
+ `Branch name "${name}" is too long: ${name.length} characters, maximum ${MAX_BRANCH_NAME_LENGTH}. ` +
92
+ "Postgres truncates identifiers past 63 bytes, which would give the branch a name you did not choose."
93
+ );
94
+ }
72
95
  }
73
96
 
74
97
  /**
75
98
  * Convert a user-facing branch name to the actual PostgreSQL database name.
76
99
  */
77
100
  function toBranchDbName(name: string): string {
78
- const sanitized = sanitizeBranchName(name);
79
- if (!sanitized) throw new Error("Branch name must contain at least one alphanumeric character.");
80
- return `${BRANCH_DB_PREFIX}${sanitized}`;
101
+ assertValidBranchName(name);
102
+ return `${BRANCH_DB_PREFIX}${name}`;
81
103
  }
82
104
 
83
105
  export class BranchService {
@@ -103,6 +125,10 @@ export class BranchService {
103
125
  metadata JSONB DEFAULT '{}'
104
126
  );
105
127
  `));
128
+
129
+ // Not a collection, so no RLS — and it names every branch database on
130
+ // this server. The driver's schema-wide grant reaches it, so revoke.
131
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
106
132
  }
107
133
 
108
134
  /**
@@ -120,15 +146,14 @@ export class BranchService {
120
146
  }
121
147
 
122
148
  const dbName = toBranchDbName(name);
123
- const sanitizedName = sanitizeBranchName(name);
124
149
  const sourceDb = options?.source || this.poolManager.defaultDatabaseName;
125
150
 
126
151
  // Check if branch already exists
127
152
  const existing = await this.db.execute(
128
- sql`SELECT name FROM rebase.branches WHERE name = ${sanitizedName} OR db_name = ${dbName}`
153
+ sql`SELECT name FROM rebase.branches WHERE name = ${name} OR db_name = ${dbName}`
129
154
  );
130
155
  if ((existing.rows as unknown[]).length > 0) {
131
- throw new Error(`Branch "${sanitizedName}" already exists.`);
156
+ throw new Error(`Branch "${name}" already exists.`);
132
157
  }
133
158
 
134
159
  // Disconnect any idle pools to the source DB so TEMPLATE works.
@@ -158,12 +183,12 @@ export class BranchService {
158
183
  // Record metadata in the default database
159
184
  const now = new Date();
160
185
  await this.db.execute(
161
- sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
162
- VALUES (${sanitizedName}, ${dbName}, ${sourceDb}, ${now.toISOString()})`
186
+ sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
187
+ VALUES (${name}, ${dbName}, ${sourceDb}, ${now.toISOString()})`
163
188
  );
164
189
 
165
190
  return {
166
- name: sanitizedName,
191
+ name,
167
192
  parentDatabase: sourceDb,
168
193
  createdAt: now
169
194
  };
@@ -174,20 +199,35 @@ export class BranchService {
174
199
  * Cannot delete the main/default database.
175
200
  */
176
201
  async deleteBranch(name: string): Promise<void> {
177
- const sanitizedName = sanitizeBranchName(name);
178
- const dbName = toBranchDbName(name);
202
+ assertValidBranchName(name);
179
203
 
180
- // Safety: never delete the default database
181
- if (dbName === this.poolManager.defaultDatabaseName) {
204
+ // Safety, first pass: a request that would target the default database
205
+ // is refused before any metadata is read, so the answer costs nothing
206
+ // and cannot depend on what a row happens to say.
207
+ if (toBranchDbName(name) === this.poolManager.defaultDatabaseName) {
182
208
  throw new Error("Cannot delete the main database.");
183
209
  }
184
210
 
185
- // Verify the branch exists in our metadata
211
+ // Verify the branch exists, and take the database name from the row
212
+ // rather than deriving it again. A branch created before names stopped
213
+ // being stripped is recorded as `rb_myfeature` while `my-feature` now
214
+ // derives `rb_my-feature`; re-deriving would drop a database this row
215
+ // never named — or, if that name happened to exist, somebody else's.
216
+ // The stored value is the only one that is true by construction.
186
217
  const existing = await this.db.execute(
187
- sql`SELECT db_name FROM rebase.branches WHERE name = ${sanitizedName}`
218
+ sql`SELECT db_name FROM rebase.branches WHERE name = ${name}`
188
219
  );
189
- if ((existing.rows as unknown[]).length === 0) {
190
- throw new Error(`Branch "${sanitizedName}" not found.`);
220
+ const existingRows = existing.rows as Record<string, unknown>[];
221
+ if (existingRows.length === 0) {
222
+ throw new Error(`Branch "${name}" not found.`);
223
+ }
224
+ const dbName = existingRows[0].db_name as string;
225
+
226
+ // Safety, second pass: the first checked a name we derived, this checks
227
+ // the one we are about to drop. They differ for any row written under
228
+ // the old scheme, and it is this one that governs.
229
+ if (dbName === this.poolManager.defaultDatabaseName) {
230
+ throw new Error("Cannot delete the main database.");
191
231
  }
192
232
 
193
233
  // Disconnect any pools to this branch before dropping
@@ -201,7 +241,7 @@ export class BranchService {
201
241
  const pgError = extractPgError(err);
202
242
  if (pgError?.code === PG_OBJECT_IN_USE) {
203
243
  throw new Error(
204
- `Cannot delete branch "${sanitizedName}": the database has active connections. ` +
244
+ `Cannot delete branch "${name}": the database has active connections. ` +
205
245
  "Close other clients and try again."
206
246
  );
207
247
  }
@@ -210,7 +250,7 @@ export class BranchService {
210
250
 
211
251
  // Remove metadata
212
252
  await this.db.execute(
213
- sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`
253
+ sql`DELETE FROM rebase.branches WHERE name = ${name}`
214
254
  );
215
255
  }
216
256
 
@@ -242,15 +282,16 @@ export class BranchService {
242
282
  * Get info about a specific branch.
243
283
  */
244
284
  async getBranchInfo(name: string): Promise<BranchInfo | undefined> {
245
- const sanitizedName = sanitizeBranchName(name);
285
+ assertValidBranchName(name);
246
286
 
247
287
  const result = await this.db.execute(sql`
248
- SELECT
288
+ SELECT
249
289
  b.name,
290
+ b.db_name,
250
291
  b.parent_db,
251
292
  b.created_at
252
293
  FROM rebase.branches b
253
- WHERE b.name = ${sanitizedName}
294
+ WHERE b.name = ${name}
254
295
  `);
255
296
 
256
297
  const rows = result.rows as Record<string, unknown>[];
@@ -258,10 +299,12 @@ export class BranchService {
258
299
 
259
300
  const row = rows[0];
260
301
 
261
- // Attempt to get size — may fail if the DB was externally dropped
302
+ // Attempt to get size — may fail if the DB was externally dropped.
303
+ // Same reason as `deleteBranch`: the size of a re-derived name is the
304
+ // size of some other database, or of nothing.
262
305
  let sizeBytes: number | undefined;
263
306
  try {
264
- const dbName = toBranchDbName(sanitizedName);
307
+ const dbName = row.db_name as string;
265
308
  const sizeResult = await this.db.execute(
266
309
  sql`SELECT pg_database_size(${dbName}) as size_bytes`
267
310
  );
@@ -1,10 +1,10 @@
1
- import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
1
+ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
5
  import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
6
  import { generateForeignKeyName } from "@rebasepro/utils";
7
- import { DrizzleConditionBuilder, type FilterCompilationOptions } from "../utils/drizzle-conditions";
7
+ import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
8
8
  import {
9
9
  getCollectionByPath,
10
10
  getTableForCollection,
@@ -12,6 +12,7 @@ import {
12
12
  requirePrimaryKeys,
13
13
  deriveRowAddress,
14
14
  parseIdValues,
15
+ idCanAddressTable,
15
16
  buildCompositeId,
16
17
  COMPOSITE_ID_SEPARATOR
17
18
  } from "./collection-helpers";
@@ -20,9 +21,10 @@ import { RelationService } from "./RelationService";
20
21
  import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
21
22
  import { DrizzleClient } from "../interfaces";
22
23
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
23
- import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
24
+ import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
24
25
  import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
25
- import { logger } from "@rebasepro/server";
26
+ import { ApiError, logger } from "@rebasepro/server";
27
+ import { reachedDatabase } from "../utils/pg-error-utils";
26
28
 
27
29
  /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
28
30
  type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
@@ -129,12 +131,10 @@ export class FetchService {
129
131
  if (direct) return direct;
130
132
 
131
133
  // Owning relation, resolved: the relation names its own local key.
132
- if (collection) {
133
- const relation = resolveCollectionRelations(collection)[orderBy];
134
- if (relation?.kind === "belongsTo") {
135
- const foreignKey = columnAt(relation.localKey);
136
- if (foreignKey) return foreignKey;
137
- }
134
+ const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : undefined;
135
+ if (declaredRelation?.kind === "belongsTo") {
136
+ const foreignKey = columnAt(declaredRelation.localKey);
137
+ if (foreignKey) return foreignKey;
138
138
  }
139
139
 
140
140
  // No collection in hand — the two shapes an owning relation's key takes
@@ -145,7 +145,61 @@ export class FetchService {
145
145
  if (foreignKey) return foreignKey;
146
146
  }
147
147
 
148
- return undefined;
148
+ // Nothing resolved. Returning `undefined` is exactly what the docblock
149
+ // above describes: the caller drops the ORDER BY and hands back rows in
150
+ // whatever order Postgres pleases, while the requester believes they
151
+ // are sorted. `?orderBy=titel` answered 200 with unsorted data and no
152
+ // hint that the sort had been ignored.
153
+ //
154
+ // A *filter* naming a field that does not exist is already refused for
155
+ // precisely this reason — it "used to widen results silently". An
156
+ // unresolvable sort field is the same drift between a query and the
157
+ // schema, so it answers the same way and honours the same switch: one
158
+ // knob, because a deployment that wants the lenient behaviour wants it
159
+ // for both.
160
+ const collectionName = collection?.slug ?? collection?.name;
161
+ const onCollection = collectionName ? ` on collection '${collectionName}'` : "";
162
+
163
+ // A declared to-many relation is a different mistake from a typo, and
164
+ // saying "unknown field" about a field the collection plainly declares
165
+ // sends the reader looking for a spelling error that is not there.
166
+ // There is simply no single value per row to order by — `posts.tags` is
167
+ // a set — so no ORDER BY exists to write, with or without a typo.
168
+ if (declaredRelation && declaredRelation.kind !== "belongsTo") {
169
+ throw ApiError.badRequest(
170
+ `Cannot sort by '${orderBy}'${onCollection}: it is a to-many relation ` +
171
+ `(${declaredRelation.kind}), which has no single value per row to order by.`,
172
+ "ORDER_BY_FIELD_NOT_SORTABLE",
173
+ { field: orderBy, kind: declaredRelation.kind, ...(collectionName && { collection: collectionName }) }
174
+ );
175
+ }
176
+
177
+ if (getUnknownFilterFieldsMode() === "warn") {
178
+ logger.warn(
179
+ `Sorting by field '${orderBy}'${onCollection}, but it does not exist in the table — ` +
180
+ "the ORDER BY was dropped and these rows are unsorted."
181
+ );
182
+ return undefined;
183
+ }
184
+
185
+ let validFields: string[] = [];
186
+ try {
187
+ validFields = Object.keys(getTableColumns(table)).sort();
188
+ } catch {
189
+ // A table stand-in without Drizzle's column symbols — the message
190
+ // is worth less without the list, but not worth failing over.
191
+ }
192
+
193
+ throw ApiError.badRequest(
194
+ `Unknown orderBy field '${orderBy}'${onCollection}` +
195
+ (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
196
+ "UNKNOWN_ORDER_BY_FIELD",
197
+ {
198
+ field: orderBy,
199
+ ...(collectionName && { collection: collectionName }),
200
+ ...(validFields.length > 0 && { validFields })
201
+ }
202
+ );
149
203
  }
150
204
 
151
205
  /**
@@ -536,6 +590,11 @@ idColumn };
536
590
  throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
537
591
  }
538
592
 
593
+ // An address the key columns cannot hold names no row — the same answer
594
+ // as a well-formed id nobody has. Asking Postgres instead raises 22P02
595
+ // and aborts the transaction around this read.
596
+ if (!idCanAddressTable(id, table, idInfoArray)) return undefined;
597
+
539
598
  const parsedIdObj = parseIdValues(id, idInfoArray);
540
599
  const parsedId = parsedIdObj[idInfo.fieldName];
541
600
 
@@ -555,7 +614,7 @@ idColumn };
555
614
 
556
615
  if (!row) return undefined;
557
616
 
558
- const flatRow = toCmsRow(row, collection, this.registry);
617
+ const flatRow = toFlatRow(row, collection, this.registry);
559
618
 
560
619
  // Post-fetch joinPath relations that Drizzle's `with` can't express
561
620
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -566,6 +625,7 @@ idColumn };
566
625
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
567
626
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
568
627
  }
628
+ if (reachedDatabase(e)) throw e;
569
629
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
570
630
  }
571
631
  }
@@ -682,7 +742,7 @@ idColumn };
682
742
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
683
743
 
684
744
  const rows = (results as Record<string, unknown>[]).map(row =>
685
- toCmsRow(row, collection, this.registry)
745
+ toFlatRow(row, collection, this.registry)
686
746
  );
687
747
 
688
748
  return rows;
@@ -691,6 +751,7 @@ idColumn };
691
751
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
692
752
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
693
753
  }
754
+ if (reachedDatabase(e)) throw e;
694
755
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
695
756
  }
696
757
  }
@@ -785,7 +846,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
785
846
  /**
786
847
  * Fallback path used when db.query is unavailable.
787
848
  *
788
- * The primary path runs the results through `toCmsRow`, which maps
849
+ * The primary path runs the results through `toFlatRow`, which maps
789
850
  * relations from what drizzle already nested — no query per row. This one
790
851
  * has no nesting to read, so it resolves relations itself, in batches.
791
852
  *
@@ -893,6 +954,16 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
893
954
  collectionPath: string,
894
955
  options: {
895
956
  filter?: FilterValues<Extract<keyof M, string>>;
957
+ /**
958
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
959
+ *
960
+ * `fetchRowsWithConditions` below has always applied this; it was
961
+ * simply absent from this signature, so the only callers that could
962
+ * pass one were the ones that went around this method. Realtime
963
+ * came through here, which is why a subscription filtered by a
964
+ * logical group was pushed every row in the table.
965
+ */
966
+ logical?: LogicalCondition;
896
967
  orderBy?: string;
897
968
  order?: "desc" | "asc";
898
969
  limit?: number;
@@ -942,6 +1013,7 @@ relatedTo: hop });
942
1013
  collectionPath: string,
943
1014
  options: {
944
1015
  filter?: FilterValues<Extract<keyof M, string>>;
1016
+ logical?: LogicalCondition;
945
1017
  searchString?: string;
946
1018
  databaseId?: string;
947
1019
  } = {}
@@ -973,6 +1045,13 @@ relatedTo: hop });
973
1045
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
974
1046
  }
975
1047
 
1048
+ if (options.logical) {
1049
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(
1050
+ options.logical, table, effectivePath, this.filterContext(effectivePath, table)
1051
+ );
1052
+ if (logicalCondition) allConditions.push(logicalCondition);
1053
+ }
1054
+
976
1055
  if (allConditions.length > 0) {
977
1056
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
978
1057
  if (finalCondition) query = query.where(finalCondition);
@@ -1043,6 +1122,8 @@ relatedTo: hop });
1043
1122
  collectionPath: string,
1044
1123
  options: {
1045
1124
  filter?: FilterValues<Extract<keyof M, string>>;
1125
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
1126
+ logical?: LogicalCondition;
1046
1127
  orderBy?: string;
1047
1128
  order?: "desc" | "asc";
1048
1129
  limit?: number;
@@ -1111,6 +1192,7 @@ relatedTo: hop }, include
1111
1192
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1112
1193
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1113
1194
  }
1195
+ if (reachedDatabase(e)) throw e;
1114
1196
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
1115
1197
  }
1116
1198
  }
@@ -1184,6 +1266,9 @@ relatedTo: hop }, include
1184
1266
  const idInfo = idInfoArray[0];
1185
1267
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1186
1268
 
1269
+ // See `fetchOne`: an unaddressable id is a 404, not a database error.
1270
+ if (!idCanAddressTable(id, table, idInfoArray)) return null;
1271
+
1187
1272
  const parsedIdObj = parseIdValues(id, idInfoArray);
1188
1273
  const parsedId = parsedIdObj[idInfo.fieldName];
1189
1274
 
@@ -1217,6 +1302,7 @@ relatedTo: hop }, include
1217
1302
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1218
1303
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1219
1304
  }
1305
+ if (reachedDatabase(e)) throw e;
1220
1306
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
1221
1307
  }
1222
1308
  }
@@ -1378,99 +1464,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1378
1464
  return !!this.getQueryBuilder(tableName);
1379
1465
  }
1380
1466
 
1381
- /**
1382
- * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
1383
- * for efficient JOIN-based relation loading.
1384
- * Returns null if the API is not available or the query fails.
1385
- * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
1386
- */
1387
- private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
1388
- collectionPath: string,
1389
- collection: CollectionConfig,
1390
- options: {
1391
- filter?: FilterValues<Extract<keyof M, string>>;
1392
- orderBy?: string;
1393
- order?: "desc" | "asc";
1394
- limit?: number;
1395
- },
1396
- include: string[],
1397
- idInfo: { fieldName: string; type: "string" | "number" },
1398
- idInfoArray?: { fieldName: string; type: "string" | "number" }[]
1399
- ): Promise<Record<string, unknown>[] | null> {
1400
- try {
1401
-
1402
- const table = getTableForCollection(collection, this.registry);
1403
- const tableName = getTableName(table);
1404
- const queryTarget = this.getQueryBuilder(tableName);
1405
-
1406
- if (!queryTarget?.findMany) return null;
1407
-
1408
- // Build the `with` config from include array
1409
- const resolvedRelations = resolveCollectionRelations(collection);
1410
- const withConfig: Record<string, boolean> = {};
1411
- for (const [key, relation] of Object.entries(resolvedRelations)) {
1412
- if (include[0] === "*" || include.includes(key)) {
1413
- // Use the Drizzle relation name (from the schema)
1414
- const drizzleRelName = relation.relationName || key;
1415
- withConfig[drizzleRelName] = true;
1416
- }
1417
- }
1418
-
1419
- // Build query options
1420
- const queryOpts: Record<string, unknown> = { with: withConfig };
1421
- if (options.limit) queryOpts.limit = options.limit;
1422
-
1423
- // Build where clause
1424
- if (options.filter) {
1425
- const filterConditions = this.buildFilterConditions(
1426
- options.filter, table, collectionPath
1427
- );
1428
- if (filterConditions.length > 0) {
1429
- queryOpts.where = and(...filterConditions);
1430
- }
1431
- }
1432
-
1433
- // Build orderBy
1434
- if (options.orderBy) {
1435
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1436
- if (orderByField) {
1437
- queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
1438
- }
1439
- }
1440
-
1441
-
1442
- const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1443
-
1444
- // Inline the nested Drizzle results, columns only — no synthesized id.
1445
- return results.map((row: Record<string, unknown>) => {
1446
- const flat: Record<string, unknown> = {};
1447
- for (const [k, v] of Object.entries(row)) {
1448
- if (Array.isArray(v)) {
1449
- // Many relation — inline each nested row
1450
- flat[k] = v.map((item: Record<string, unknown>) => {
1451
- // Junction table rows may have the target nested, unwrap those
1452
- const keys = Object.keys(item);
1453
- const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1454
- if (nestedObj && keys.length <= 3) {
1455
- return { ...(item[nestedObj] as Record<string, unknown>) };
1456
- }
1457
- return { ...item };
1458
- });
1459
- } else if (typeof v === "object" && v !== null) {
1460
- // One-to-one relation — inline the target's columns
1461
- flat[k] = { ...(v as Record<string, unknown>) };
1462
- } else {
1463
- flat[k] = v;
1464
- }
1465
- }
1466
- return flat;
1467
- });
1468
- } catch (e) {
1469
- logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
1470
- return null;
1471
- }
1472
- }
1473
-
1474
1467
  /**
1475
1468
  * Fallback path used when db.query is unavailable.
1476
1469
  * The primary path uses db.query.findMany with `with` config, which
@@ -1,7 +1,7 @@
1
1
  import { eq, and, sql, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
- import { CollectionConfig, Properties, ResolvedRelation, type ResolvedManyToMany, isManyToMany } from "@rebasepro/types";
4
+ import { CollectionConfig, Properties, ResolvedRelation, type ResolvedManyToMany, isManyToMany, hasForeignKeyOnTarget } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
6
6
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
7
  import {
@@ -240,16 +240,30 @@ export class PersistService {
240
240
  throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
241
241
  }
242
242
  } else {
243
- // One-to-many create: stamp the parent's id onto the child's FK.
243
+ // One-to-many create: stamp the parent's key onto the child's FK.
244
244
  const targetColumnName = this.resolveParentForeignKeyColumn(hop);
245
245
 
246
246
  if (targetColumnName) {
247
- const parsedParentId = parentIdForWrite();
247
+ // Not necessarily the id in the path: a link with a
248
+ // `sourceKey` is pointed at a column on the parent row, and
249
+ // stamping the id would create a child that belongs to
250
+ // nobody — a row the read path would never return again.
251
+ const parentKeyValue = hasForeignKeyOnTarget(hop.relation)
252
+ ? await this.relationService.parentKeyValue(hop.parentCollection, hop.relation, hop.parentId)
253
+ : parentIdForWrite();
254
+
255
+ if (parentKeyValue === undefined) {
256
+ throw ApiError.badRequest(
257
+ `Cannot create under "${collectionPath}": the parent row has no value in ` +
258
+ `\`sourceKey\`, so the new row has nothing to point at.`
259
+ );
260
+ }
261
+
248
262
  const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
249
- if (existingValue !== undefined && existingValue !== null && existingValue !== parsedParentId) {
250
- logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
263
+ if (existingValue !== undefined && existingValue !== null && existingValue !== parentKeyValue) {
264
+ logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent key '${parentKeyValue}'.`);
251
265
  }
252
- (effectiveValues as Record<string, unknown>)[targetColumnName] = parsedParentId;
266
+ (effectiveValues as Record<string, unknown>)[targetColumnName] = parentKeyValue;
253
267
  }
254
268
  }
255
269
  }
@@ -463,7 +477,20 @@ export class PersistService {
463
477
  const pgError = extractPgError(error);
464
478
 
465
479
  if (pgError) {
466
- const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
480
+ const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
481
+ // This is the only layer that holds the SQLSTATE, so it is the only
482
+ // one that can say whose fault a failure was. Returning a bare
483
+ // `Error` threw that away, and the REST layer compensated by calling
484
+ // *every* unclassified error a 400 — so an unreachable database was
485
+ // reported to callers as a bad request. Classes 22 (data exception)
486
+ // and 23 (integrity constraint violation) are the caller's data;
487
+ // everything else — a dropped connection, a missing column, a
488
+ // permission problem — is ours, and stays a 500.
489
+ if (/^2[23]/.test(code)) {
490
+ return code === "23505"
491
+ ? ApiError.conflict(message, `PG_${code}`)
492
+ : ApiError.badRequest(message, `PG_${code}`);
493
+ }
467
494
  return new Error(message);
468
495
  }
469
496