@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.
- package/dist/auth/services.d.ts +6 -1
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/{backup-service-DLb2drIH.js → backup-service-CD8o_1Sl.js} +136 -10
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/{ensure-collection-policies-Dv21KDMJ.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
- package/dist/{ensure-collection-policies-Dv21KDMJ.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
- package/dist/{ensure-collection-tables-CT6xHB1d.js → ensure-collection-tables-CBQdOETu.js} +2 -2
- package/dist/{ensure-collection-tables-CT6xHB1d.js.map → ensure-collection-tables-CBQdOETu.js.map} +1 -1
- package/dist/index.es.js +768 -537
- package/dist/index.es.js.map +1 -1
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/{src-BkdpiQdw.js → src-DlPBctw_.js} +72 -6
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +16 -0
- package/package.json +6 -6
- package/src/auth/services.ts +6 -3
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli.ts +11 -4
- package/src/collections/validate-relations.ts +15 -0
- package/src/data-transformer.ts +9 -3
- package/src/schema/generate-drizzle-schema-logic.ts +26 -1
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +364 -68
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +14 -0
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +211 -45
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +19 -0
- package/dist/backup-service-DLb2drIH.js.map +0 -1
- 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
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
165
|
-
|
|
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
|
|
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
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 = ${
|
|
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 "${
|
|
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 (${
|
|
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
|
|
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
|
-
|
|
178
|
-
const dbName = toBranchDbName(name);
|
|
197
|
+
assertValidBranchName(name);
|
|
179
198
|
|
|
180
|
-
// Safety:
|
|
181
|
-
|
|
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
|
|
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 = ${
|
|
213
|
+
sql`SELECT db_name FROM rebase.branches WHERE name = ${name}`
|
|
188
214
|
);
|
|
189
|
-
|
|
190
|
-
|
|
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 "${
|
|
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 = ${
|
|
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
|
-
|
|
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 = ${
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 !==
|
|
250
|
-
logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent
|
|
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] =
|
|
266
|
+
(effectiveValues as Record<string, unknown>)[targetColumnName] = parentKeyValue;
|
|
253
267
|
}
|
|
254
268
|
}
|
|
255
269
|
}
|