@rebasepro/server-postgresql 0.0.1-canary.eae7889 → 0.1.0
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/index.es.js +458 -201
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +458 -201
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +8 -1
- package/dist/server-postgresql/src/schema/introspect-db-inference.d.ts +5 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +117 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +9 -0
- package/dist/types/src/controllers/auth.d.ts +8 -2
- package/dist/types/src/controllers/client.d.ts +13 -0
- package/dist/types/src/controllers/collection_registry.d.ts +2 -1
- package/dist/types/src/controllers/data_driver.d.ts +36 -1
- package/dist/types/src/controllers/navigation.d.ts +18 -6
- package/dist/types/src/controllers/registry.d.ts +9 -1
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
- package/dist/types/src/rebase_context.d.ts +17 -0
- package/dist/types/src/types/backend_hooks.d.ts +187 -0
- package/dist/types/src/types/collections.d.ts +31 -11
- package/dist/types/src/types/component_ref.d.ts +47 -0
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +6 -7
- package/dist/types/src/types/formex.d.ts +40 -0
- package/dist/types/src/types/index.d.ts +3 -0
- package/dist/types/src/types/plugins.d.ts +6 -3
- package/dist/types/src/types/properties.d.ts +72 -88
- package/dist/types/src/types/slots.d.ts +20 -10
- package/dist/types/src/types/translations.d.ts +6 -0
- package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
- package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
- package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
- package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
- package/package.json +6 -5
- package/src/PostgresBackendDriver.ts +32 -6
- package/src/cli.ts +68 -2
- package/src/data-transformer.ts +84 -1
- package/src/schema/doctor.ts +14 -2
- package/src/schema/generate-drizzle-schema-logic.ts +59 -30
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +896 -0
- package/src/schema/introspect-db.ts +254 -0
- package/src/services/EntityFetchService.ts +16 -0
- package/src/services/EntityPersistService.ts +95 -13
- package/test/generate-drizzle-schema.test.ts +342 -0
- package/test/introspect-db-generation.test.ts +458 -0
- package/test/introspect-db-utils.test.ts +392 -0
- package/test/property-ordering.test.ts +395 -0
- package/test/relations.test.ts +4 -4
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/jest-all.log +0 -3128
- package/jest.log +0 -49
- package/scratch.ts +0 -41
- package/test-drizzle-bug.ts +0 -18
- package/test-drizzle-out/0000_cultured_freak.sql +0 -7
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
- package/test-drizzle-out/meta/0000_snapshot.json +0 -55
- package/test-drizzle-out/meta/0001_snapshot.json +0 -63
- package/test-drizzle-out/meta/_journal.json +0 -20
- package/test-drizzle-prompt.sh +0 -2
- package/test-policy-prompt.sh +0 -3
- package/test-programmatic.ts +0 -30
- package/test-programmatic2.ts +0 -59
- package/test-schema-no-policies.ts +0 -12
- package/test_drizzle_mock.js +0 -3
- package/test_find_changed.mjs +0 -32
- package/test_hash.js +0 -14
- package/test_output.txt +0 -3145
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import pg from "pg";
|
|
5
|
+
import arg from "arg";
|
|
6
|
+
import * as dotenv from "dotenv";
|
|
7
|
+
import readline from "readline";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
TableRow,
|
|
11
|
+
TableColumn,
|
|
12
|
+
EnumValue,
|
|
13
|
+
PrimaryKeyRow,
|
|
14
|
+
ForeignKeyRow,
|
|
15
|
+
buildTablesMap,
|
|
16
|
+
buildEnumMap,
|
|
17
|
+
identifyJoinTables,
|
|
18
|
+
generateCollectionFile,
|
|
19
|
+
generateIndexContent,
|
|
20
|
+
mergeIndexContent,
|
|
21
|
+
safeHostFromUrl,
|
|
22
|
+
} from "./introspect-db-logic";
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const args = arg(
|
|
26
|
+
{
|
|
27
|
+
"--output": String,
|
|
28
|
+
"--collections": String,
|
|
29
|
+
"--force": Boolean,
|
|
30
|
+
"--schema": String,
|
|
31
|
+
"--data-inference": Boolean,
|
|
32
|
+
"-o": "--output",
|
|
33
|
+
"-c": "--collections",
|
|
34
|
+
"-f": "--force",
|
|
35
|
+
},
|
|
36
|
+
{ permissive: true }
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const isBackendDir = path.basename(cwd) === "backend";
|
|
41
|
+
const defaultOutDir = isBackendDir
|
|
42
|
+
? path.resolve(cwd, "..", "config", "collections")
|
|
43
|
+
: path.resolve(cwd, "config", "collections");
|
|
44
|
+
|
|
45
|
+
const outDir = args["--output"] || args["--collections"] || defaultOutDir;
|
|
46
|
+
const force = args["--force"] || false;
|
|
47
|
+
const pgSchema = args["--schema"] || "public";
|
|
48
|
+
|
|
49
|
+
if (!fs.existsSync(outDir)) {
|
|
50
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Load env
|
|
54
|
+
const envPaths = [
|
|
55
|
+
process.env.DOTENV_CONFIG_PATH,
|
|
56
|
+
path.resolve(process.cwd(), ".env"),
|
|
57
|
+
path.resolve(process.cwd(), "../.env"),
|
|
58
|
+
path.resolve(process.cwd(), "../../.env")
|
|
59
|
+
].filter(Boolean) as string[];
|
|
60
|
+
|
|
61
|
+
for (const p of envPaths) {
|
|
62
|
+
if (fs.existsSync(p)) {
|
|
63
|
+
dotenv.config({ path: p });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
69
|
+
if (!databaseUrl) {
|
|
70
|
+
console.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const client = new pg.Client({ connectionString: databaseUrl });
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await client.connect();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(chalk.red(`✗ Failed to connect to database: ${err instanceof Error ? err.message : String(err)}`));
|
|
80
|
+
console.error(chalk.gray(" Check your DATABASE_URL and ensure the database is reachable."));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Log the host portion safely — handle URLs without "@"
|
|
85
|
+
const hostPart = safeHostFromUrl(databaseUrl);
|
|
86
|
+
console.log(chalk.gray(`Connected to database: ${hostPart}`));
|
|
87
|
+
console.log(chalk.gray(`Introspecting schema '${pgSchema}'...`));
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// 1. Get Tables
|
|
91
|
+
const { rows: tables } = await client.query<TableRow>(`
|
|
92
|
+
SELECT table_name
|
|
93
|
+
FROM information_schema.tables
|
|
94
|
+
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
|
|
95
|
+
AND table_name NOT LIKE 'drizzle_%'
|
|
96
|
+
AND table_name NOT LIKE 'rebase_%'
|
|
97
|
+
ORDER BY table_name
|
|
98
|
+
`, [pgSchema]);
|
|
99
|
+
|
|
100
|
+
// 2. Get Columns
|
|
101
|
+
const { rows: columns } = await client.query<TableColumn>(`
|
|
102
|
+
SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default
|
|
103
|
+
FROM information_schema.columns
|
|
104
|
+
WHERE table_schema = $1
|
|
105
|
+
`, [pgSchema]);
|
|
106
|
+
|
|
107
|
+
// 2b. Get Enum Types and their values
|
|
108
|
+
const { rows: enumValues } = await client.query<EnumValue>(`
|
|
109
|
+
SELECT t.typname AS enum_name,
|
|
110
|
+
e.enumlabel AS enum_value,
|
|
111
|
+
e.enumsortorder AS sort_order
|
|
112
|
+
FROM pg_type t
|
|
113
|
+
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
114
|
+
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
115
|
+
WHERE n.nspname = $1
|
|
116
|
+
ORDER BY t.typname, e.enumsortorder
|
|
117
|
+
`, [pgSchema]);
|
|
118
|
+
|
|
119
|
+
// Build a map: enum_name -> ordered list of values
|
|
120
|
+
const enumMap = buildEnumMap(enumValues);
|
|
121
|
+
|
|
122
|
+
// 3. Get Primary Keys
|
|
123
|
+
const { rows: pks } = await client.query<PrimaryKeyRow>(`
|
|
124
|
+
SELECT t.relname as table_name, a.attname as column_name
|
|
125
|
+
FROM pg_index i
|
|
126
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid
|
|
127
|
+
AND a.attnum = ANY(i.indkey)
|
|
128
|
+
JOIN pg_class t ON t.oid = i.indrelid
|
|
129
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
130
|
+
WHERE i.indisprimary AND n.nspname = $1
|
|
131
|
+
`, [pgSchema]);
|
|
132
|
+
|
|
133
|
+
// 4. Get Foreign Keys
|
|
134
|
+
const { rows: fks } = await client.query<ForeignKeyRow>(`
|
|
135
|
+
SELECT
|
|
136
|
+
tc.table_name,
|
|
137
|
+
kcu.column_name,
|
|
138
|
+
ccu.table_name AS foreign_table_name,
|
|
139
|
+
ccu.column_name AS foreign_column_name
|
|
140
|
+
FROM
|
|
141
|
+
information_schema.table_constraints AS tc
|
|
142
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
143
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
144
|
+
AND tc.table_schema = kcu.table_schema
|
|
145
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
146
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
147
|
+
AND ccu.table_schema = tc.table_schema
|
|
148
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1
|
|
149
|
+
`, [pgSchema]);
|
|
150
|
+
|
|
151
|
+
const tablesMap = buildTablesMap(tables, columns, pks, fks);
|
|
152
|
+
const joinTables = identifyJoinTables(tablesMap);
|
|
153
|
+
|
|
154
|
+
console.log(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
|
|
155
|
+
|
|
156
|
+
let runDataInference = false;
|
|
157
|
+
if (args["--data-inference"] !== undefined) {
|
|
158
|
+
runDataInference = args["--data-inference"];
|
|
159
|
+
} else {
|
|
160
|
+
const rl = readline.createInterface({
|
|
161
|
+
input: process.stdin,
|
|
162
|
+
output: process.stdout
|
|
163
|
+
});
|
|
164
|
+
const answer = await new Promise<string>((resolve) => rl.question(chalk.yellow("? Do you want to run comprehensive data inference on sampled rows to auto-detect types, formats, constraints, and UI configurations? (y/N) "), resolve));
|
|
165
|
+
runDataInference = answer.trim().toLowerCase() === 'y';
|
|
166
|
+
rl.close();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (runDataInference) {
|
|
170
|
+
console.log(chalk.gray(`Sampling database rows for data inference...`));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Generate Collections
|
|
174
|
+
const generatedFiles: string[] = [];
|
|
175
|
+
const skippedFiles: string[] = [];
|
|
176
|
+
|
|
177
|
+
const tablesToProcess = Array.from(tablesMap.entries()).filter(([tableName]) => !joinTables.has(tableName));
|
|
178
|
+
|
|
179
|
+
const BATCH_SIZE = 10;
|
|
180
|
+
for (let i = 0; i < tablesToProcess.length; i += BATCH_SIZE) {
|
|
181
|
+
const batch = tablesToProcess.slice(i, i + BATCH_SIZE);
|
|
182
|
+
|
|
183
|
+
await Promise.all(batch.map(async ([tableName, meta]) => {
|
|
184
|
+
// ── File overwrite protection ──────────────────────────────
|
|
185
|
+
const filePath = path.join(outDir, `${tableName}.ts`);
|
|
186
|
+
if (fs.existsSync(filePath) && !force) {
|
|
187
|
+
skippedFiles.push(tableName);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let sampleData: Record<string, unknown>[] | undefined = undefined;
|
|
192
|
+
if (runDataInference) {
|
|
193
|
+
try {
|
|
194
|
+
const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
|
|
195
|
+
sampleData = rows;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const fileContent = generateCollectionFile(
|
|
202
|
+
tableName,
|
|
203
|
+
meta,
|
|
204
|
+
fks,
|
|
205
|
+
joinTables,
|
|
206
|
+
tablesMap,
|
|
207
|
+
enumMap,
|
|
208
|
+
sampleData,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
fs.writeFileSync(filePath, fileContent, "utf-8");
|
|
212
|
+
generatedFiles.push(tableName);
|
|
213
|
+
console.log(chalk.green(` ✓ ${filePath}`));
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Generate index.ts (sorted alphabetically for deterministic output)
|
|
218
|
+
if (generatedFiles.length > 0) {
|
|
219
|
+
const indexPath = path.join(outDir, "index.ts");
|
|
220
|
+
|
|
221
|
+
if (fs.existsSync(indexPath) && !force) {
|
|
222
|
+
// Merge: read existing index, add new exports that don't already exist
|
|
223
|
+
const existing = fs.readFileSync(indexPath, "utf-8");
|
|
224
|
+
const merged = mergeIndexContent(existing, generatedFiles);
|
|
225
|
+
fs.writeFileSync(indexPath, merged, "utf-8");
|
|
226
|
+
} else {
|
|
227
|
+
const indexContent = generateIndexContent(generatedFiles);
|
|
228
|
+
fs.writeFileSync(indexPath, indexContent, "utf-8");
|
|
229
|
+
}
|
|
230
|
+
console.log(chalk.green(` ✓ ${indexPath}`));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log("");
|
|
234
|
+
if (skippedFiles.length > 0) {
|
|
235
|
+
console.log(chalk.yellow(`⚠ Skipped ${skippedFiles.length} existing file(s): ${skippedFiles.join(", ")}`));
|
|
236
|
+
console.log(chalk.gray(` Use --force to overwrite existing files.`));
|
|
237
|
+
console.log("");
|
|
238
|
+
}
|
|
239
|
+
console.log(chalk.bold.green(`✓ Introspected ${tablesMap.size} tables — generated ${generatedFiles.length} collection(s).`));
|
|
240
|
+
console.log(chalk.gray(` Review the generated files in ${outDir} and customize properties as needed.`));
|
|
241
|
+
console.log("");
|
|
242
|
+
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.error(chalk.red(`✗ Error introspecting database: ${e instanceof Error ? e.message : String(e)}`));
|
|
245
|
+
process.exit(1);
|
|
246
|
+
} finally {
|
|
247
|
+
await client.end();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
main().catch((err) => {
|
|
252
|
+
console.error(err);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
});
|
|
@@ -617,6 +617,10 @@ export class EntityFetchService {
|
|
|
617
617
|
|
|
618
618
|
return entity;
|
|
619
619
|
} catch (e) {
|
|
620
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
621
|
+
console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
622
|
+
console.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.`);
|
|
623
|
+
}
|
|
620
624
|
console.warn(`[EntityFetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select:`, e);
|
|
621
625
|
}
|
|
622
626
|
}
|
|
@@ -733,6 +737,10 @@ export class EntityFetchService {
|
|
|
733
737
|
|
|
734
738
|
return entities;
|
|
735
739
|
} catch (e) {
|
|
740
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
741
|
+
console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
742
|
+
console.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.`);
|
|
743
|
+
}
|
|
736
744
|
console.warn(`[EntityFetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select:`, e);
|
|
737
745
|
}
|
|
738
746
|
}
|
|
@@ -1195,6 +1203,10 @@ export class EntityFetchService {
|
|
|
1195
1203
|
|
|
1196
1204
|
return restRows;
|
|
1197
1205
|
} catch (e) {
|
|
1206
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
1207
|
+
console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
1208
|
+
console.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.`);
|
|
1209
|
+
}
|
|
1198
1210
|
console.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back:`, e);
|
|
1199
1211
|
}
|
|
1200
1212
|
}
|
|
@@ -1305,6 +1317,10 @@ export class EntityFetchService {
|
|
|
1305
1317
|
|
|
1306
1318
|
return restRow;
|
|
1307
1319
|
} catch (e) {
|
|
1320
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
1321
|
+
console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
1322
|
+
console.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.`);
|
|
1323
|
+
}
|
|
1308
1324
|
console.warn(`[fetchEntityForRest] db.query.findFirst failed for ${collectionPath}, falling back:`, e);
|
|
1309
1325
|
}
|
|
1310
1326
|
}
|
|
@@ -111,7 +111,7 @@ export class EntityPersistService {
|
|
|
111
111
|
targetColumnName = relation.localKey;
|
|
112
112
|
} else if (relation.foreignKeyOnTarget) {
|
|
113
113
|
targetColumnName = relation.foreignKeyOnTarget;
|
|
114
|
-
} else if (relation.joinPath && relation.joinPath.length
|
|
114
|
+
} else if (relation.joinPath && relation.joinPath.length === 1) {
|
|
115
115
|
const targetTableName = getTableName(targetCollection);
|
|
116
116
|
const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
|
|
117
117
|
|
|
@@ -123,6 +123,12 @@ export class EntityPersistService {
|
|
|
123
123
|
const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
|
|
124
124
|
targetColumnName = targetColumnNames[0];
|
|
125
125
|
}
|
|
126
|
+
} else if (relation.joinPath && relation.joinPath.length > 1) {
|
|
127
|
+
// For multi-hop relations (like many-to-many through a junction table),
|
|
128
|
+
// there is no direct foreign key on the target table pointing to the parent.
|
|
129
|
+
// The relationship is managed via the junction table.
|
|
130
|
+
// We shouldn't inject the parent ID directly into the target entity payload.
|
|
131
|
+
break;
|
|
126
132
|
} else {
|
|
127
133
|
throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
|
|
128
134
|
}
|
|
@@ -292,51 +298,127 @@ export class EntityPersistService {
|
|
|
292
298
|
|
|
293
299
|
if (pgError) {
|
|
294
300
|
const detail = pgError.detail as string | undefined;
|
|
301
|
+
const hint = pgError.hint as string | undefined;
|
|
295
302
|
const constraint = pgError.constraint as string | undefined;
|
|
296
303
|
const column = pgError.column as string | undefined;
|
|
297
304
|
const table = pgError.table as string | undefined;
|
|
305
|
+
const dataType = pgError.dataType as string | undefined;
|
|
306
|
+
const pgMessage = pgError.message || "Unknown database error";
|
|
307
|
+
|
|
308
|
+
const suffix = hint ? ` Hint: ${hint}` : "";
|
|
309
|
+
const tableRef = table ?? collectionSlug;
|
|
298
310
|
|
|
299
311
|
switch (pgError.code) {
|
|
300
312
|
case "23503": // foreign_key_violation
|
|
301
313
|
return new Error(
|
|
302
314
|
detail
|
|
303
|
-
? `Foreign key constraint violated: ${detail}`
|
|
304
|
-
: `Cannot save: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}"
|
|
315
|
+
? `Foreign key constraint violated: ${detail}${suffix}`
|
|
316
|
+
: `Cannot save: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
|
|
305
317
|
);
|
|
306
318
|
case "23505": // unique_violation
|
|
307
319
|
return new Error(
|
|
308
320
|
detail
|
|
309
|
-
? `Duplicate value: ${detail}`
|
|
310
|
-
: `Cannot save: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}"
|
|
321
|
+
? `Duplicate value: ${detail}${suffix}`
|
|
322
|
+
: `Cannot save: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
|
|
311
323
|
);
|
|
312
324
|
case "23502": // not_null_violation
|
|
313
325
|
return new Error(
|
|
314
|
-
`Missing required field: "${column ?? "unknown"}" in "${
|
|
326
|
+
`Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`
|
|
315
327
|
);
|
|
316
328
|
case "23514": // check_violation
|
|
317
329
|
return new Error(
|
|
318
|
-
`Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}"
|
|
330
|
+
`Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
|
|
331
|
+
);
|
|
332
|
+
case "22P02": // invalid_text_representation (e.g. invalid UUID, wrong enum value)
|
|
333
|
+
return new Error(
|
|
334
|
+
`Invalid data format in "${collectionSlug}": ${pgMessage}${suffix}`
|
|
335
|
+
);
|
|
336
|
+
case "22001": // string_data_right_truncation (value too long)
|
|
337
|
+
return new Error(
|
|
338
|
+
`Value too long for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`
|
|
339
|
+
);
|
|
340
|
+
case "22003": // numeric_value_out_of_range
|
|
341
|
+
return new Error(
|
|
342
|
+
`Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`
|
|
319
343
|
);
|
|
344
|
+
case "42703": // undefined_column
|
|
345
|
+
return new Error(
|
|
346
|
+
`Unknown column in "${tableRef}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`
|
|
347
|
+
);
|
|
348
|
+
case "42P01": // undefined_table
|
|
349
|
+
return new Error(
|
|
350
|
+
`Table not found for "${collectionSlug}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`
|
|
351
|
+
);
|
|
352
|
+
default: {
|
|
353
|
+
// Unhandled PG code — still surface the actual database message
|
|
354
|
+
const parts = [`Database error in "${collectionSlug}" [${pgError.code}]: ${pgMessage}`];
|
|
355
|
+
if (detail) parts.push(`Detail: ${detail}`);
|
|
356
|
+
if (column) parts.push(`Column: ${column}`);
|
|
357
|
+
if (dataType) parts.push(`Data type: ${dataType}`);
|
|
358
|
+
if (constraint) parts.push(`Constraint: ${constraint}`);
|
|
359
|
+
if (hint) parts.push(`Hint: ${hint}`);
|
|
360
|
+
return new Error(parts.join(". "));
|
|
361
|
+
}
|
|
320
362
|
}
|
|
321
363
|
}
|
|
322
364
|
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
365
|
+
// No PG error found — try to extract a useful message from the
|
|
366
|
+
// Drizzle wrapper instead of leaking the raw SQL query + params.
|
|
367
|
+
const causeMessage = this.extractCauseMessage(error);
|
|
368
|
+
if (causeMessage) {
|
|
369
|
+
return new Error(`Database error in "${collectionSlug}": ${causeMessage}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Last resort: use the original error message but strip the SQL query
|
|
373
|
+
if (error instanceof Error) {
|
|
374
|
+
const cleaned = this.stripSqlFromMessage(error.message, collectionSlug);
|
|
375
|
+
return new Error(cleaned);
|
|
376
|
+
}
|
|
377
|
+
return new Error(`Database error in "${collectionSlug}": ${String(error)}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Walk the error cause chain and return the deepest meaningful message.
|
|
382
|
+
*/
|
|
383
|
+
private extractCauseMessage(error: unknown): string | null {
|
|
384
|
+
if (!error || typeof error !== "object") return null;
|
|
385
|
+
const err = error as Error & { cause?: unknown };
|
|
386
|
+
|
|
387
|
+
if (err.cause && typeof err.cause === "object") {
|
|
388
|
+
const deeper = this.extractCauseMessage(err.cause);
|
|
389
|
+
if (deeper) return deeper;
|
|
390
|
+
// The cause itself has a message
|
|
391
|
+
if (err.cause instanceof Error && err.cause.message) {
|
|
392
|
+
return err.cause.message;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Strip the raw SQL query from a Drizzle "Failed query: ..." message,
|
|
400
|
+
* keeping only the error description.
|
|
401
|
+
*/
|
|
402
|
+
private stripSqlFromMessage(message: string, collectionSlug: string): string {
|
|
403
|
+
// Drizzle format: "Failed query: <SQL>\nparams: <params>"
|
|
404
|
+
if (message.startsWith("Failed query:")) {
|
|
405
|
+
return `Failed to save entity in "${collectionSlug}". Check server logs for details.`;
|
|
406
|
+
}
|
|
407
|
+
return message;
|
|
326
408
|
}
|
|
327
409
|
|
|
328
410
|
/**
|
|
329
411
|
* Extract the underlying PostgreSQL error from a Drizzle wrapper.
|
|
330
412
|
* Drizzle wraps PG errors in a `cause` property.
|
|
331
413
|
*/
|
|
332
|
-
private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown }) | null {
|
|
414
|
+
private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; hint?: unknown; constraint?: unknown; column?: unknown; table?: unknown; dataType?: unknown }) | null {
|
|
333
415
|
if (!error || typeof error !== "object") return null;
|
|
334
416
|
|
|
335
417
|
const err = error as Error & { code?: string; cause?: unknown; detail?: unknown };
|
|
336
418
|
|
|
337
419
|
// Check if the error itself has a PG error code
|
|
338
|
-
if (err.code && /^[0-
|
|
339
|
-
return err as Error & { code: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown };
|
|
420
|
+
if (err.code && /^[0-9A-Z]{5}$/.test(err.code)) {
|
|
421
|
+
return err as Error & { code: string; detail?: unknown; hint?: unknown; constraint?: unknown; column?: unknown; table?: unknown; dataType?: unknown };
|
|
340
422
|
}
|
|
341
423
|
|
|
342
424
|
// Check the cause chain (Drizzle wraps PG errors)
|