@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.
- package/dist/PostgresBackendDriver.d.ts +49 -2
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
- package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-Bww-Lg0s.js +8999 -0
- package/dist/backup-service-Bww-Lg0s.js.map +1 -0
- package/dist/cli-helpers.d.ts +40 -1
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/ensure-collection-policies-DMUdRQdM.js +57 -0
- package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
- package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
- package/dist/index.es.js +1701 -10167
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CPkCqVTz.js +105 -0
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +45 -3
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -3
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- 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/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/security/rls-enforcement.d.ts +53 -2
- package/dist/services/FetchService.d.ts +14 -8
- 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/services/dataService.d.ts +5 -1
- package/dist/services/realtimeService.d.ts +25 -21
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
- package/dist/src-C_wvdMnl.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 +35 -0
- package/dist/websocket-D0TBU3ia.js +530 -0
- package/dist/websocket-D0TBU3ia.js.map +1 -0
- package/package.json +17 -16
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +169 -3
- package/src/PostgresBootstrapper.ts +233 -35
- package/src/auth/ensure-tables.ts +335 -81
- package/src/auth/services.ts +24 -2
- 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-helpers.ts +92 -9
- package/src/cli.ts +219 -56
- package/src/collections/validate-relations.ts +136 -14
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +22 -6
- package/src/databasePoolManager.ts +5 -2
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +301 -44
- package/src/schema/generate-drizzle-schema-logic.ts +49 -7
- package/src/schema/generate-postgres-ddl-logic.ts +474 -27
- package/src/schema/generated-schema-staleness.ts +169 -0
- 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 +385 -71
- 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/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +11 -3
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +71 -28
- package/src/services/FetchService.ts +100 -107
- package/src/services/PersistService.ts +34 -7
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +5 -1
- package/src/services/realtimeService.ts +38 -35
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +65 -0
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
- 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
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 = ${
|
|
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 "${
|
|
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 (${
|
|
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
|
|
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
|
-
|
|
178
|
-
const dbName = toBranchDbName(name);
|
|
202
|
+
assertValidBranchName(name);
|
|
179
203
|
|
|
180
|
-
// Safety:
|
|
181
|
-
|
|
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
|
|
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 = ${
|
|
218
|
+
sql`SELECT db_name FROM rebase.branches WHERE name = ${name}`
|
|
188
219
|
);
|
|
189
|
-
|
|
190
|
-
|
|
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 "${
|
|
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 = ${
|
|
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
|
-
|
|
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 = ${
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|