@rebasepro/server-postgres 0.12.1-canary.g4e7bcbf → 0.12.1-canary.g52d71ee

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 (51) hide show
  1. package/dist/auth/services.d.ts +6 -1
  2. package/dist/backup/backup-service.d.ts +10 -1
  3. package/dist/backup/pg-tools.d.ts +47 -0
  4. package/dist/{backup-service-DLb2drIH.js → backup-service-CD8o_1Sl.js} +136 -10
  5. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  6. package/dist/{ensure-collection-policies-Dv21KDMJ.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
  7. package/dist/{ensure-collection-policies-Dv21KDMJ.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
  8. package/dist/{ensure-collection-tables-CT6xHB1d.js → ensure-collection-tables-CBQdOETu.js} +2 -2
  9. package/dist/{ensure-collection-tables-CT6xHB1d.js.map → ensure-collection-tables-CBQdOETu.js.map} +1 -1
  10. package/dist/index.es.js +768 -537
  11. package/dist/index.es.js.map +1 -1
  12. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  13. package/dist/schema/introspect-db-logic.d.ts +94 -5
  14. package/dist/schema/introspect-db-queries.d.ts +119 -0
  15. package/dist/schema/introspect-db-structure.d.ts +263 -0
  16. package/dist/schema/introspect-db-types.d.ts +11 -0
  17. package/dist/services/RelationService.d.ts +24 -1
  18. package/dist/services/channel-bus/index.d.ts +1 -7
  19. package/dist/services/collection-helpers.d.ts +36 -2
  20. package/dist/{src-BkdpiQdw.js → src-DlPBctw_.js} +72 -6
  21. package/dist/src-DlPBctw_.js.map +1 -0
  22. package/dist/utils/connection-string.d.ts +29 -0
  23. package/dist/utils/drizzle-conditions.d.ts +5 -4
  24. package/dist/utils/pg-error-utils.d.ts +16 -0
  25. package/package.json +6 -6
  26. package/src/auth/services.ts +6 -3
  27. package/src/backup/backup-cli.ts +41 -2
  28. package/src/backup/backup-service.ts +38 -5
  29. package/src/backup/pg-tools.ts +96 -3
  30. package/src/cli.ts +11 -4
  31. package/src/collections/validate-relations.ts +15 -0
  32. package/src/data-transformer.ts +9 -3
  33. package/src/schema/generate-drizzle-schema-logic.ts +26 -1
  34. package/src/schema/introspect-db-constraints.ts +385 -0
  35. package/src/schema/introspect-db-inference.ts +18 -8
  36. package/src/schema/introspect-db-logic.ts +364 -68
  37. package/src/schema/introspect-db-queries.ts +326 -0
  38. package/src/schema/introspect-db-structure.ts +670 -0
  39. package/src/schema/introspect-db-types.ts +56 -0
  40. package/src/schema/introspect-db.ts +37 -80
  41. package/src/services/BranchService.ts +66 -28
  42. package/src/services/FetchService.ts +14 -0
  43. package/src/services/PersistService.ts +20 -6
  44. package/src/services/RelationService.ts +211 -45
  45. package/src/services/channel-bus/index.ts +0 -9
  46. package/src/services/collection-helpers.ts +69 -3
  47. package/src/utils/connection-string.ts +58 -0
  48. package/src/utils/drizzle-conditions.ts +31 -6
  49. package/src/utils/pg-error-utils.ts +19 -0
  50. package/dist/backup-service-DLb2drIH.js.map +0 -1
  51. package/dist/src-BkdpiQdw.js.map +0 -1
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The PostgreSQL type → Rebase property type mapping.
3
+ *
4
+ * Split out of `introspect-db-logic` so that the structural analysis can use it
5
+ * without importing the generator, which imports the analysis. Re-exported from
6
+ * `introspect-db-logic` so existing callers keep their import path.
7
+ */
8
+
9
+ /**
10
+ * Map a PostgreSQL data type to a Rebase property type.
11
+ */
12
+ export function mapPgType(dataType: string): string {
13
+ const dt = dataType.toLowerCase();
14
+
15
+ // Interval MUST be checked before numeric ("interval" contains "int")
16
+ if (dt === "interval") return "string";
17
+
18
+ // Array types MUST be checked before numeric ("_int4" contains "int")
19
+ if (dt === "array" || dt.startsWith("_")) return "array";
20
+
21
+ // Numeric types
22
+ if (
23
+ dt.includes("int") || // integer, smallint, bigint
24
+ dt.includes("numeric") ||
25
+ dt.includes("decimal") ||
26
+ dt.includes("serial") || // serial, bigserial
27
+ dt === "real" ||
28
+ dt === "float4" ||
29
+ dt === "float8" ||
30
+ dt === "double precision" ||
31
+ dt === "money"
32
+ ) {
33
+ return "number";
34
+ }
35
+
36
+ // Boolean
37
+ if (dt.includes("bool")) return "boolean";
38
+
39
+ // Date / Time
40
+ if (dt.includes("time") || dt.includes("date")) return "date";
41
+
42
+ // JSON
43
+ if (dt === "json" || dt === "jsonb") return "map";
44
+
45
+ // Binary
46
+ if (dt === "bytea") return "binary";
47
+
48
+ // Network types
49
+ if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
50
+
51
+ // UUID
52
+ if (dt === "uuid") return "string";
53
+
54
+ // Text/varchar/char — default to string
55
+ return "string";
56
+ }
@@ -7,19 +7,16 @@ import * as dotenv from "dotenv";
7
7
  import readline from "readline";
8
8
 
9
9
  import {
10
- TableRow,
11
- TableColumn,
12
- EnumValue,
13
- PrimaryKeyRow,
14
- ForeignKeyRow,
15
10
  buildTablesMap,
16
11
  buildEnumMap,
17
- identifyJoinTables,
18
12
  generateCollectionFile,
19
13
  generateIndexContent,
20
14
  mergeIndexContent,
21
15
  safeHostFromUrl
22
16
  } from "./introspect-db-logic";
17
+ import { countRowsUpTo, readSchemaMetadata } from "./introspect-db-queries";
18
+ import { classifyTables, lookupCandidates, LOOKUP_MAX_ROWS } from "./introspect-db-structure";
19
+ import { parseCheckConstraints } from "./introspect-db-constraints";
23
20
  import { logger } from "@rebasepro/server";
24
21
 
25
22
  async function main() {
@@ -89,82 +86,41 @@ async function main() {
89
86
  logger.info(chalk.gray(`Introspecting schema '${pgSchema}'...`));
90
87
 
91
88
  try {
92
- // 1. Get Tables
93
- const { rows: tables } = await client.query<TableRow>(`
94
- SELECT table_name
95
- FROM information_schema.tables
96
- WHERE table_schema = $1 AND table_type = 'BASE TABLE'
97
- AND table_name NOT LIKE 'drizzle_%'
98
- AND table_name NOT LIKE 'rebase_%'
99
- ORDER BY table_name
100
- `, [pgSchema]);
101
-
102
- // 2. Get Columns
103
- const { rows: columns } = await client.query<TableColumn>(`
104
- SELECT
105
- c.table_name,
106
- c.column_name,
107
- c.data_type,
108
- c.udt_name,
109
- c.is_nullable,
110
- c.column_default,
111
- (SELECT a.atttypmod FROM pg_attribute a
112
- JOIN pg_class pc ON a.attrelid = pc.oid
113
- WHERE pc.relname = c.table_name
114
- AND a.attname = c.column_name
115
- AND pc.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)) as atttypmod
116
- FROM information_schema.columns c
117
- WHERE c.table_schema = $1
118
- `, [pgSchema]);
119
-
120
- // 2b. Get Enum Types and their values
121
- const { rows: enumValues } = await client.query<EnumValue>(`
122
- SELECT t.typname AS enum_name,
123
- e.enumlabel AS enum_value,
124
- e.enumsortorder AS sort_order
125
- FROM pg_type t
126
- JOIN pg_enum e ON t.oid = e.enumtypid
127
- JOIN pg_namespace n ON t.typnamespace = n.oid
128
- WHERE n.nspname = $1
129
- ORDER BY t.typname, e.enumsortorder
130
- `, [pgSchema]);
131
-
132
- // Build a map: enum_name -> ordered list of values
133
- const enumMap = buildEnumMap(enumValues);
134
-
135
- // 3. Get Primary Keys
136
- const { rows: pks } = await client.query<PrimaryKeyRow>(`
137
- SELECT t.relname as table_name, a.attname as column_name
138
- FROM pg_index i
139
- JOIN pg_attribute a ON a.attrelid = i.indrelid
140
- AND a.attnum = ANY(i.indkey)
141
- JOIN pg_class t ON t.oid = i.indrelid
142
- JOIN pg_namespace n ON n.oid = t.relnamespace
143
- WHERE i.indisprimary AND n.nspname = $1
144
- `, [pgSchema]);
89
+ const metadata = await readSchemaMetadata(client, pgSchema);
90
+ const enumMap = buildEnumMap(metadata.enumValues);
91
+ const tablesMap = buildTablesMap(metadata.tables, metadata.columns, metadata.pks, metadata.fks);
92
+ const fks = metadata.fks;
93
+
94
+ // Only tables that could structurally be a code list are counted, and
95
+ // each count stops at the threshold — see `countRowsUpTo`. Introspection
96
+ // runs against a database it does not own, so "cheap on a table of any
97
+ // size" is a requirement, not an optimization.
98
+ for (const table of lookupCandidates(metadata, tablesMap)) {
99
+ try {
100
+ metadata.rowCounts[table] = await countRowsUpTo(client, pgSchema, table, LOOKUP_MAX_ROWS);
101
+ } catch (err) {
102
+ // A table this run cannot read is simply not classified as a code
103
+ // list; everything else about it still generates.
104
+ logger.info(chalk.gray(` (skipped row count for ${table}: ${err instanceof Error ? err.message : String(err)})`));
105
+ }
106
+ }
145
107
 
146
- // 4. Get Foreign Keys
147
- const { rows: fks } = await client.query<ForeignKeyRow>(`
148
- SELECT
149
- tc.table_name,
150
- kcu.column_name,
151
- ccu.table_name AS foreign_table_name,
152
- ccu.column_name AS foreign_column_name
153
- FROM
154
- information_schema.table_constraints AS tc
155
- JOIN information_schema.key_column_usage AS kcu
156
- ON tc.constraint_name = kcu.constraint_name
157
- AND tc.table_schema = kcu.table_schema
158
- JOIN information_schema.constraint_column_usage AS ccu
159
- ON ccu.constraint_name = tc.constraint_name
160
- AND ccu.table_schema = tc.table_schema
161
- WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1
162
- `, [pgSchema]);
108
+ const classifications = classifyTables(metadata, tablesMap);
109
+ const checkFacts = parseCheckConstraints(metadata.checks);
110
+ const joinTables = new Set(
111
+ Array.from(classifications.values())
112
+ .filter((c) => c.role === "junction")
113
+ .map((c) => c.table)
114
+ );
163
115
 
164
- const tablesMap = buildTablesMap(tables, columns, pks, fks);
165
- const joinTables = identifyJoinTables(tablesMap);
116
+ const roleCount = (role: string) =>
117
+ Array.from(classifications.values()).filter((c) => c.role === role).length;
166
118
 
167
- logger.info(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
119
+ logger.info(chalk.blue(`Found ${tablesMap.size} tables.`));
120
+ logger.info(chalk.gray(
121
+ ` ${roleCount("entity")} entities, ${joinTables.size} join tables (folded into relations), ` +
122
+ `${roleCount("lookup")} code lists, ${roleCount("owned-child")} owned by another table (hidden from navigation).`
123
+ ));
168
124
 
169
125
  let runDataInference = false;
170
126
  if (args["--no-data-inference"]) {
@@ -224,7 +180,8 @@ async function main() {
224
180
  joinTables,
225
181
  tablesMap,
226
182
  enumMap,
227
- sampleData
183
+ sampleData,
184
+ { metadata, classifications, checkFacts }
228
185
  );
229
186
 
230
187
  fs.writeFileSync(filePath, fileContent, "utf-8");
@@ -64,20 +64,41 @@ function validateIdentifier(value: string, label: string): void {
64
64
  }
65
65
 
66
66
  /**
67
- * Sanitize a user-provided branch name to a safe PostgreSQL identifier.
68
- * Only allows alphanumeric characters and underscores.
67
+ * Postgres truncates identifiers at NAMEDATALEN-1 = 63 bytes, and does it
68
+ * silently. The prefix comes out of the same budget.
69
69
  */
70
- function sanitizeBranchName(name: string): string {
71
- return name.replace(/[^a-zA-Z0-9_]/g, "");
70
+ const MAX_BRANCH_NAME_LENGTH = 63 - BRANCH_DB_PREFIX.length;
71
+
72
+ /**
73
+ * Check a user-provided branch name, and otherwise leave it exactly as given.
74
+ *
75
+ * This used to strip everything outside [a-zA-Z0-9_], so `my-feature` was
76
+ * quietly created as `myfeature`: the name you typed was not the name `list`
77
+ * gave back. Nothing ever needed that. Every identifier this service builds is
78
+ * double-quoted (see `CREATE DATABASE` below), which is what makes a hyphen
79
+ * safe, and `validateIdentifier` has always accepted hyphens for the `--from`
80
+ * source database — the two disagreed about the same character class.
81
+ *
82
+ * Refusing a name we cannot represent is better than representing a different
83
+ * one, which is also why the length is checked here rather than left to
84
+ * Postgres, whose answer to an over-long identifier is a silent rename.
85
+ */
86
+ function assertValidBranchName(name: string): void {
87
+ validateIdentifier(name, "branch name");
88
+ if (name.length > MAX_BRANCH_NAME_LENGTH) {
89
+ throw new Error(
90
+ `Branch name "${name}" is too long: ${name.length} characters, maximum ${MAX_BRANCH_NAME_LENGTH}. ` +
91
+ "Postgres truncates identifiers past 63 bytes, which would give the branch a name you did not choose."
92
+ );
93
+ }
72
94
  }
73
95
 
74
96
  /**
75
97
  * Convert a user-facing branch name to the actual PostgreSQL database name.
76
98
  */
77
99
  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}`;
100
+ assertValidBranchName(name);
101
+ return `${BRANCH_DB_PREFIX}${name}`;
81
102
  }
82
103
 
83
104
  export class BranchService {
@@ -120,15 +141,14 @@ export class BranchService {
120
141
  }
121
142
 
122
143
  const dbName = toBranchDbName(name);
123
- const sanitizedName = sanitizeBranchName(name);
124
144
  const sourceDb = options?.source || this.poolManager.defaultDatabaseName;
125
145
 
126
146
  // Check if branch already exists
127
147
  const existing = await this.db.execute(
128
- sql`SELECT name FROM rebase.branches WHERE name = ${sanitizedName} OR db_name = ${dbName}`
148
+ sql`SELECT name FROM rebase.branches WHERE name = ${name} OR db_name = ${dbName}`
129
149
  );
130
150
  if ((existing.rows as unknown[]).length > 0) {
131
- throw new Error(`Branch "${sanitizedName}" already exists.`);
151
+ throw new Error(`Branch "${name}" already exists.`);
132
152
  }
133
153
 
134
154
  // Disconnect any idle pools to the source DB so TEMPLATE works.
@@ -158,12 +178,12 @@ export class BranchService {
158
178
  // Record metadata in the default database
159
179
  const now = new Date();
160
180
  await this.db.execute(
161
- sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
162
- VALUES (${sanitizedName}, ${dbName}, ${sourceDb}, ${now.toISOString()})`
181
+ sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
182
+ VALUES (${name}, ${dbName}, ${sourceDb}, ${now.toISOString()})`
163
183
  );
164
184
 
165
185
  return {
166
- name: sanitizedName,
186
+ name,
167
187
  parentDatabase: sourceDb,
168
188
  createdAt: now
169
189
  };
@@ -174,20 +194,35 @@ export class BranchService {
174
194
  * Cannot delete the main/default database.
175
195
  */
176
196
  async deleteBranch(name: string): Promise<void> {
177
- const sanitizedName = sanitizeBranchName(name);
178
- const dbName = toBranchDbName(name);
197
+ assertValidBranchName(name);
179
198
 
180
- // Safety: never delete the default database
181
- if (dbName === this.poolManager.defaultDatabaseName) {
199
+ // Safety, first pass: a request that would target the default database
200
+ // is refused before any metadata is read, so the answer costs nothing
201
+ // and cannot depend on what a row happens to say.
202
+ if (toBranchDbName(name) === this.poolManager.defaultDatabaseName) {
182
203
  throw new Error("Cannot delete the main database.");
183
204
  }
184
205
 
185
- // Verify the branch exists in our metadata
206
+ // Verify the branch exists, and take the database name from the row
207
+ // rather than deriving it again. A branch created before names stopped
208
+ // being stripped is recorded as `rb_myfeature` while `my-feature` now
209
+ // derives `rb_my-feature`; re-deriving would drop a database this row
210
+ // never named — or, if that name happened to exist, somebody else's.
211
+ // The stored value is the only one that is true by construction.
186
212
  const existing = await this.db.execute(
187
- sql`SELECT db_name FROM rebase.branches WHERE name = ${sanitizedName}`
213
+ sql`SELECT db_name FROM rebase.branches WHERE name = ${name}`
188
214
  );
189
- if ((existing.rows as unknown[]).length === 0) {
190
- throw new Error(`Branch "${sanitizedName}" not found.`);
215
+ const existingRows = existing.rows as Record<string, unknown>[];
216
+ if (existingRows.length === 0) {
217
+ throw new Error(`Branch "${name}" not found.`);
218
+ }
219
+ const dbName = existingRows[0].db_name as string;
220
+
221
+ // Safety, second pass: the first checked a name we derived, this checks
222
+ // the one we are about to drop. They differ for any row written under
223
+ // the old scheme, and it is this one that governs.
224
+ if (dbName === this.poolManager.defaultDatabaseName) {
225
+ throw new Error("Cannot delete the main database.");
191
226
  }
192
227
 
193
228
  // Disconnect any pools to this branch before dropping
@@ -201,7 +236,7 @@ export class BranchService {
201
236
  const pgError = extractPgError(err);
202
237
  if (pgError?.code === PG_OBJECT_IN_USE) {
203
238
  throw new Error(
204
- `Cannot delete branch "${sanitizedName}": the database has active connections. ` +
239
+ `Cannot delete branch "${name}": the database has active connections. ` +
205
240
  "Close other clients and try again."
206
241
  );
207
242
  }
@@ -210,7 +245,7 @@ export class BranchService {
210
245
 
211
246
  // Remove metadata
212
247
  await this.db.execute(
213
- sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`
248
+ sql`DELETE FROM rebase.branches WHERE name = ${name}`
214
249
  );
215
250
  }
216
251
 
@@ -242,15 +277,16 @@ export class BranchService {
242
277
  * Get info about a specific branch.
243
278
  */
244
279
  async getBranchInfo(name: string): Promise<BranchInfo | undefined> {
245
- const sanitizedName = sanitizeBranchName(name);
280
+ assertValidBranchName(name);
246
281
 
247
282
  const result = await this.db.execute(sql`
248
- SELECT
283
+ SELECT
249
284
  b.name,
285
+ b.db_name,
250
286
  b.parent_db,
251
287
  b.created_at
252
288
  FROM rebase.branches b
253
- WHERE b.name = ${sanitizedName}
289
+ WHERE b.name = ${name}
254
290
  `);
255
291
 
256
292
  const rows = result.rows as Record<string, unknown>[];
@@ -258,10 +294,12 @@ export class BranchService {
258
294
 
259
295
  const row = rows[0];
260
296
 
261
- // Attempt to get size — may fail if the DB was externally dropped
297
+ // Attempt to get size — may fail if the DB was externally dropped.
298
+ // Same reason as `deleteBranch`: the size of a re-derived name is the
299
+ // size of some other database, or of nothing.
262
300
  let sizeBytes: number | undefined;
263
301
  try {
264
- const dbName = toBranchDbName(sanitizedName);
302
+ const dbName = row.db_name as string;
265
303
  const sizeResult = await this.db.execute(
266
304
  sql`SELECT pg_database_size(${dbName}) as size_bytes`
267
305
  );
@@ -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";
@@ -23,6 +24,7 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
23
24
  import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
24
25
  import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
25
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;
@@ -588,6 +590,11 @@ idColumn };
588
590
  throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
589
591
  }
590
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
+
591
598
  const parsedIdObj = parseIdValues(id, idInfoArray);
592
599
  const parsedId = parsedIdObj[idInfo.fieldName];
593
600
 
@@ -618,6 +625,7 @@ idColumn };
618
625
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
619
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.");
620
627
  }
628
+ if (reachedDatabase(e)) throw e;
621
629
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
622
630
  }
623
631
  }
@@ -743,6 +751,7 @@ idColumn };
743
751
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
744
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.");
745
753
  }
754
+ if (reachedDatabase(e)) throw e;
746
755
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
747
756
  }
748
757
  }
@@ -1173,6 +1182,7 @@ relatedTo: hop }, include
1173
1182
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1174
1183
  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.");
1175
1184
  }
1185
+ if (reachedDatabase(e)) throw e;
1176
1186
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
1177
1187
  }
1178
1188
  }
@@ -1246,6 +1256,9 @@ relatedTo: hop }, include
1246
1256
  const idInfo = idInfoArray[0];
1247
1257
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1248
1258
 
1259
+ // See `fetchOne`: an unaddressable id is a 404, not a database error.
1260
+ if (!idCanAddressTable(id, table, idInfoArray)) return null;
1261
+
1249
1262
  const parsedIdObj = parseIdValues(id, idInfoArray);
1250
1263
  const parsedId = parsedIdObj[idInfo.fieldName];
1251
1264
 
@@ -1279,6 +1292,7 @@ relatedTo: hop }, include
1279
1292
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1280
1293
  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.");
1281
1294
  }
1295
+ if (reachedDatabase(e)) throw e;
1282
1296
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
1283
1297
  }
1284
1298
  }
@@ -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
  }