@rebasepro/server-postgres 0.12.1-canary.gcfa333c → 0.12.1-canary.gddbd489
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 +1 -1
- package/dist/PostgresBootstrapper.d.ts +25 -1
- 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-DH9kPg-E.js → backup-service-CD8o_1Sl.js} +144 -11
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/cli-helpers.d.ts +39 -0
- package/dist/{connection-B5Wndbr1.js → connection-BuZ97wsr.js} +57 -3
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/{ensure-collection-policies-Vl3Cv1Q9.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
- package/dist/{ensure-collection-policies-Vl3Cv1Q9.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
- package/dist/{ensure-collection-tables-DsDsNl6o.js → ensure-collection-tables-CBQdOETu.js} +72 -12
- package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
- package/dist/index.es.js +990 -677
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CeA1JcxP.js +105 -0
- package/dist/policy-CeA1JcxP.js.map +1 -0
- package/dist/schema/ensure-collection-tables.d.ts +21 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +19 -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/services/FetchService.d.ts +4 -1
- 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 +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-DihrDFuP.js → src-DlPBctw_.js} +217 -101
- 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/dist/{websocket-BKcGvILX.js → websocket-B2LsrINK.js} +8 -6
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresAdapter.ts +7 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/PostgresBootstrapper.ts +83 -22
- 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-helpers.ts +70 -0
- package/src/cli.ts +44 -26
- package/src/collections/validate-relations.ts +15 -0
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +9 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/ensure-collection-tables.ts +81 -10
- package/src/schema/generate-drizzle-schema-logic.ts +35 -3
- package/src/schema/generate-postgres-ddl-logic.ts +50 -6
- 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/security/policy-drift.test.ts +11 -3
- package/src/services/FetchService.ts +90 -14
- 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/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- 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 +19 -0
- package/src/websocket.ts +18 -9
- package/dist/backup-service-DH9kPg-E.js.map +0 -1
- package/dist/connection-B5Wndbr1.js.map +0 -1
- package/dist/ensure-collection-tables-DsDsNl6o.js.map +0 -1
- package/dist/src-DihrDFuP.js.map +0 -1
- package/dist/websocket-BKcGvILX.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import { a as guardPoolAgainstDirtyRelease, i as createReadReplicaConnection, n as createDirectDatabaseConnection, r as createPostgresDatabaseConnection } from "./connection-
|
|
5
|
-
import {
|
|
4
|
+
import { a as guardPoolAgainstDirtyRelease, i as createReadReplicaConnection, n as createDirectDatabaseConnection, o as pinSearchPath, r as createPostgresDatabaseConnection } from "./connection-BuZ97wsr.js";
|
|
5
|
+
import { A as toSnakeCase, C as normalizeToEntityRelation, D as getPolicyNamesForRule, F as Vector, N as hasForeignKeyOnTarget, O as mergeDeep, P as isManyToMany, S as createRelationRefWithData, T as generateForeignKeyName, _ as buildCompositeId, a as getJunctionSecurityRules, b as parseIdValues, c as policyToPostgres, d as findRelation, f as getColumnName, g as resolveCollectionRelations, h as getTableVarName, i as getJunctionCollectionConfig, k as camelCase, l as securityRuleToConditions, m as getTableName$1, n as CollectionRegistry, o as resolveJunctionSpecs, p as getEnumVarName, r as resolveStringColumnLength, s as getEffectiveSecurityRules, t as buildSdkData, u as findAnonymousGrants, v as getDeclaredPrimaryKeys, w as updateDateAutoValues, x as createRelationRef, y as isAddressableId } from "./src-DlPBctw_.js";
|
|
6
6
|
import { n as isPostgresCollectionConfig, r as isRelationalCollectionConfig } from "./src-DoU9yPqq.js";
|
|
7
|
-
import { t as
|
|
8
|
-
import {
|
|
7
|
+
import { t as ANONYMOUS_USER_ID } from "./policy-CeA1JcxP.js";
|
|
8
|
+
import { t as createPostgresWebSocket } from "./websocket-B2LsrINK.js";
|
|
9
|
+
import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-CD8o_1Sl.js";
|
|
9
10
|
import { Client, Pool } from "pg";
|
|
10
11
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
11
12
|
import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
|
|
@@ -49,6 +50,39 @@ function resolveClientListLimit(rawLimit, opts = {}) {
|
|
|
49
50
|
return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
|
|
50
51
|
}
|
|
51
52
|
//#endregion
|
|
53
|
+
//#region ../common/src/util/email.ts
|
|
54
|
+
/**
|
|
55
|
+
* Email normalization — one implementation, because the database enforces it.
|
|
56
|
+
*
|
|
57
|
+
* `ensureAuthTablesExist` puts a `UNIQUE INDEX ON users (lower(email))` on the
|
|
58
|
+
* auth table. That index decides what "the same address" means, and it does not
|
|
59
|
+
* trim: to Postgres, `' foo@bar.com'` and `'foo@bar.com'` are two addresses and
|
|
60
|
+
* both may exist. So every write that reaches the column has to agree with
|
|
61
|
+
* every read, exactly, or the two disagree in the one direction that matters —
|
|
62
|
+
* a row that exists and cannot be found.
|
|
63
|
+
*
|
|
64
|
+
* That is not hypothetical. The lookup path trimmed and the admin create paths
|
|
65
|
+
* did not, so a user created through `POST /api/data/users` or
|
|
66
|
+
* `POST /api/auth/admin/users` with a stray space was stored untrimmed,
|
|
67
|
+
* survived the unique index alongside the real address, and was unreachable by
|
|
68
|
+
* login forever after. The HTTP auth routes were unaffected only because Zod's
|
|
69
|
+
* `.email()` happens to reject surrounding whitespace — a guard on a different
|
|
70
|
+
* layer, for a different reason, that the admin paths do not sit behind.
|
|
71
|
+
*
|
|
72
|
+
* It lives in `common` because `server`, `server-postgres` and `server-mongo`
|
|
73
|
+
* all write this column and must agree exactly, and `common` is the only
|
|
74
|
+
* package all three already depend on.
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* Canonical form of an email address: trimmed, lower-cased.
|
|
78
|
+
*
|
|
79
|
+
* Non-strings pass through untouched, so this is safe to apply to a value out
|
|
80
|
+
* of a partial update payload whose type is not known yet.
|
|
81
|
+
*/
|
|
82
|
+
function normalizeEmail(email) {
|
|
83
|
+
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
52
86
|
//#region ../common/src/util/callbacks.ts
|
|
53
87
|
/**
|
|
54
88
|
* Helper function to recursively check if there are any callbacks in the properties.
|
|
@@ -194,6 +228,27 @@ function getColumnMeta(col) {
|
|
|
194
228
|
primary: typeof raw.primary === "boolean" ? raw.primary : void 0
|
|
195
229
|
};
|
|
196
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Whether an address could name a row in this table, judged by the columns.
|
|
233
|
+
*
|
|
234
|
+
* {@link getPrimaryKeys} lets a config's `isId: "uuid"` win over the schema, so
|
|
235
|
+
* its `isUUID` is a claim rather than a fact — right for deriving addresses,
|
|
236
|
+
* wrong for refusing a query. Here the Drizzle column type decides, because it
|
|
237
|
+
* is what Postgres will enforce: a `uuid` column meets `/c/products/new` with
|
|
238
|
+
* `22P02`, which aborts the surrounding transaction and turns every later
|
|
239
|
+
* statement into an unrelated-looking `25P02`.
|
|
240
|
+
*/
|
|
241
|
+
function idCanAddressTable(id, table, idInfoArray) {
|
|
242
|
+
return isAddressableId(id, idInfoArray.map((info) => {
|
|
243
|
+
const col = table[info.fieldName];
|
|
244
|
+
const meta = col ? getColumnMeta(col) : void 0;
|
|
245
|
+
if (!meta?.columnType) return info;
|
|
246
|
+
return {
|
|
247
|
+
...info,
|
|
248
|
+
isUUID: meta.columnType === "PgUUID"
|
|
249
|
+
};
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
197
252
|
function getCollectionByPath(collectionPath, registry) {
|
|
198
253
|
const collection = registry.getCollectionByPath(collectionPath);
|
|
199
254
|
if (!collection) {
|
|
@@ -281,6 +336,35 @@ function requirePrimaryKeys(collection, registry) {
|
|
|
281
336
|
return keys;
|
|
282
337
|
}
|
|
283
338
|
/**
|
|
339
|
+
* The column on the *source* table that a `hasOne`/`hasMany` link points at.
|
|
340
|
+
*
|
|
341
|
+
* `sourceKey` is authored when the two sides join on a natural key — an
|
|
342
|
+
* external identity id, a SKU — and left off when they join on the row id,
|
|
343
|
+
* which is the overwhelming majority. That makes `undefined` the only optional
|
|
344
|
+
* field on a resolved relation, so it gets exactly one reader: this function.
|
|
345
|
+
* Every consumer that needs the column asks here, and none of them re-derives
|
|
346
|
+
* "or else the primary key" for itself. That is the whole point — the fallback
|
|
347
|
+
* chains this codebase removed from relation resolution were dangerous because
|
|
348
|
+
* they were *duplicated* and could disagree, not because they existed.
|
|
349
|
+
*/
|
|
350
|
+
function sourceKeyField(relation, sourceCollection, registry) {
|
|
351
|
+
if (relation.sourceKey) return relation.sourceKey;
|
|
352
|
+
return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Whether this link joins on something other than the source's primary key.
|
|
356
|
+
*
|
|
357
|
+
* Callers that hold a parent *id* — which is most of them, since an id is what
|
|
358
|
+
* a URL carries — must translate it to the source key's value before it can be
|
|
359
|
+
* compared with the target's foreign key. Those that hold the parent *row*, or
|
|
360
|
+
* that build a correlated subquery over the source table, can read the column
|
|
361
|
+
* directly and skip the lookup.
|
|
362
|
+
*/
|
|
363
|
+
function joinsOnNaturalKey(relation, sourceCollection, registry) {
|
|
364
|
+
if (!relation.sourceKey) return false;
|
|
365
|
+
return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
284
368
|
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
285
369
|
* instead.
|
|
286
370
|
*
|
|
@@ -451,7 +535,9 @@ var DrizzleConditionBuilder = class {
|
|
|
451
535
|
case "hasMany": {
|
|
452
536
|
const fkColumn = targetTable[relation.foreignKeyOnTarget];
|
|
453
537
|
if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
|
|
454
|
-
return eq(fkColumn, parentId);
|
|
538
|
+
if (!relation.sourceKey) return eq(fkColumn, parentId);
|
|
539
|
+
const { table, idColumn } = parent();
|
|
540
|
+
return sql`${fkColumn} = (SELECT ${sql.identifier(relation.sourceKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
|
|
455
541
|
}
|
|
456
542
|
case "belongsTo": {
|
|
457
543
|
const { table, idColumn } = parent();
|
|
@@ -541,12 +627,16 @@ var DrizzleConditionBuilder = class {
|
|
|
541
627
|
column: foreignKey
|
|
542
628
|
};
|
|
543
629
|
}
|
|
544
|
-
if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn)
|
|
545
|
-
|
|
546
|
-
relation,
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
630
|
+
if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
|
|
631
|
+
const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey ? columnAt(relation.sourceKey) : sourceIdColumn;
|
|
632
|
+
if (!correlationColumn) throw new Error(`\`sourceKey: "${relation.sourceKey}"\` on relation '${relation.relationName}' is not a column on '${collectionPath}', so a filter on that relation has nothing to correlate against.`);
|
|
633
|
+
return {
|
|
634
|
+
kind: "relation",
|
|
635
|
+
relation,
|
|
636
|
+
registry,
|
|
637
|
+
sourceIdColumn: correlationColumn
|
|
638
|
+
};
|
|
639
|
+
}
|
|
550
640
|
}
|
|
551
641
|
for (const guess of [`${field}_id`, generateForeignKeyName(field)]) {
|
|
552
642
|
const foreignKey = columnAt(guess);
|
|
@@ -1345,8 +1435,9 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1345
1435
|
} else if (hasForeignKeyOnTarget(relation) && db && registry) try {
|
|
1346
1436
|
const targetCollection = relation.target();
|
|
1347
1437
|
const targetTable = registry.getTable(getTableName$1(targetCollection));
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1438
|
+
const pks = getPrimaryKeys(collection, registry);
|
|
1439
|
+
const currentId = relation.sourceKey ? data[relation.sourceKey] : buildCompositeId(data, pks);
|
|
1440
|
+
if (targetTable && currentId !== void 0 && currentId !== null && currentId !== "") {
|
|
1350
1441
|
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
|
|
1351
1442
|
if (foreignKeyColumn) {
|
|
1352
1443
|
const relatedRows = await db.select().from(targetTable).where(eq(foreignKeyColumn, currentId)).limit(relation.cardinality === "one" ? 1 : 100);
|
|
@@ -1718,8 +1809,67 @@ var RelationService = class {
|
|
|
1718
1809
|
* reading. Left alone it would silently match on the first key column and
|
|
1719
1810
|
* hand a tenant's rows to its neighbour — say so instead.
|
|
1720
1811
|
*/
|
|
1721
|
-
assertSingleKeyAddressable(parentCollection, parentPks, via) {
|
|
1722
|
-
if (
|
|
1812
|
+
assertSingleKeyAddressable(parentCollection, parentPks, via, relation) {
|
|
1813
|
+
if (relation && hasForeignKeyOnTarget(relation) && relation.sourceKey) return;
|
|
1814
|
+
if (parentPks.length > 1) throw new Error(`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but '${parentCollection.slug}' is keyed on ${parentPks.map((k) => `'${k.fieldName}'`).join(" + ")}. One column cannot reference a composite key — give the relation a \`sourceKey\` naming a single unique column to point at, or express it with \`joinPath\`, whose \`on.from\`/\`on.to\` take every key column.`);
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* What the target's foreign key holds, for each of these parent rows.
|
|
1818
|
+
*
|
|
1819
|
+
* Ordinarily the parent's id, and then this is free. When the relation
|
|
1820
|
+
* declares a `sourceKey` the two are different values, and the mapping
|
|
1821
|
+
* between them lives in the source table — so it costs one SELECT, issued
|
|
1822
|
+
* once for the whole batch rather than per parent.
|
|
1823
|
+
*
|
|
1824
|
+
* Both directions come back because both are needed and deriving one from
|
|
1825
|
+
* the other by hand is how a batch loader ends up attributing a child to the
|
|
1826
|
+
* wrong parent: reads translate id → key to build the WHERE, and then
|
|
1827
|
+
* translate key → id to attribute each row that comes back.
|
|
1828
|
+
*/
|
|
1829
|
+
/**
|
|
1830
|
+
* The value a related row's foreign key must hold to belong to this parent.
|
|
1831
|
+
*
|
|
1832
|
+
* `undefined` when the parent's source key is null — which is not an error
|
|
1833
|
+
* here, only in the callers that were about to write it. Exposed for
|
|
1834
|
+
* {@link PersistService}, which stamps this onto a child created under a
|
|
1835
|
+
* nested path and would otherwise write the id and lose the row.
|
|
1836
|
+
*/
|
|
1837
|
+
async parentKeyValue(parentCollection, relation, parentId, db = this.db) {
|
|
1838
|
+
const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
|
|
1839
|
+
return keyByParentId.get(String(parentId));
|
|
1840
|
+
}
|
|
1841
|
+
async resolveSourceKeys(parentCollection, relation, parentIds, db = this.db) {
|
|
1842
|
+
const keyByParentId = /* @__PURE__ */ new Map();
|
|
1843
|
+
const parentIdByKey = /* @__PURE__ */ new Map();
|
|
1844
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
1845
|
+
if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
|
|
1846
|
+
for (const id of parentIds) {
|
|
1847
|
+
const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
|
|
1848
|
+
keyByParentId.set(String(id), value);
|
|
1849
|
+
parentIdByKey.set(String(value), id);
|
|
1850
|
+
}
|
|
1851
|
+
return {
|
|
1852
|
+
keyByParentId,
|
|
1853
|
+
parentIdByKey
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
const field = sourceKeyField(relation, parentCollection, this.registry);
|
|
1857
|
+
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
1858
|
+
if (!parentTable[field]) throw new Error(`\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not a column on '${parentCollection.slug}'. It names a column on the source table, not on the target.`);
|
|
1859
|
+
const rows = await db.select().from(parentTable).where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
1860
|
+
for (const row of rows) {
|
|
1861
|
+
const keyValue = row[field];
|
|
1862
|
+
if (keyValue === null || keyValue === void 0) continue;
|
|
1863
|
+
const parentId = buildCompositeId(row, parentPks);
|
|
1864
|
+
keyByParentId.set(String(parentId), keyValue);
|
|
1865
|
+
const existing = parentIdByKey.get(String(keyValue));
|
|
1866
|
+
if (existing !== void 0 && String(existing) !== String(parentId)) throw new Error(`\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not unique on '${parentCollection.slug}': rows '${existing}' and '${parentId}' both hold '${keyValue}'. Add a unique constraint — a link that addresses more than one source row cannot say which one a related row belongs to.`);
|
|
1867
|
+
parentIdByKey.set(String(keyValue), parentId);
|
|
1868
|
+
}
|
|
1869
|
+
return {
|
|
1870
|
+
keyByParentId,
|
|
1871
|
+
parentIdByKey
|
|
1872
|
+
};
|
|
1723
1873
|
}
|
|
1724
1874
|
/**
|
|
1725
1875
|
* Fetch rows related to a parent row through a specific relation
|
|
@@ -1778,6 +1928,8 @@ var RelationService = class {
|
|
|
1778
1928
|
}
|
|
1779
1929
|
return rows;
|
|
1780
1930
|
}
|
|
1931
|
+
const matchValue = hasForeignKeyOnTarget(relation) ? (await this.resolveSourceKeys(parentCollection, relation, [parentId])).keyByParentId.get(String(parentId)) : parsedParentId;
|
|
1932
|
+
if (matchValue === void 0) return [];
|
|
1781
1933
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
1782
1934
|
const additionalFilters = [];
|
|
1783
1935
|
if (options.searchString) {
|
|
@@ -1786,7 +1938,7 @@ var RelationService = class {
|
|
|
1786
1938
|
const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
|
|
1787
1939
|
if (searchCombined) additionalFilters.push(searchCombined);
|
|
1788
1940
|
}
|
|
1789
|
-
query = applyDynamicRelationQuery(query, query, relation,
|
|
1941
|
+
query = applyDynamicRelationQuery(query, query, relation, matchValue, targetTable, parentTable, parentIdCol, idField, this.registry, additionalFilters);
|
|
1790
1942
|
if (options.limit) query = query.limit(options.limit);
|
|
1791
1943
|
const results = await query;
|
|
1792
1944
|
if (!results || !Array.isArray(results)) return [];
|
|
@@ -1829,8 +1981,10 @@ var RelationService = class {
|
|
|
1829
1981
|
const parentTable = this.registry.getTable(getTableName$1(parentCollection));
|
|
1830
1982
|
if (!parentTable) throw new Error("Parent table not found");
|
|
1831
1983
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
1984
|
+
const matchValue = hasForeignKeyOnTarget(relation) ? (await this.resolveSourceKeys(parentCollection, relation, [parentId])).keyByParentId.get(String(parentId)) : parsedParentId;
|
|
1985
|
+
if (matchValue === void 0) return 0;
|
|
1832
1986
|
let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
1833
|
-
query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation,
|
|
1987
|
+
query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, matchValue, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
|
|
1834
1988
|
const result = await query;
|
|
1835
1989
|
return Number(result[0]?.count || 0);
|
|
1836
1990
|
}
|
|
@@ -1957,17 +2111,24 @@ var RelationService = class {
|
|
|
1957
2111
|
}
|
|
1958
2112
|
return resultMap;
|
|
1959
2113
|
}
|
|
1960
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
|
|
2114
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName, relation);
|
|
2115
|
+
const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation) ? await this.resolveSourceKeys(parentCollection, relation, parentIds) : {
|
|
2116
|
+
keyByParentId: /* @__PURE__ */ new Map(),
|
|
2117
|
+
parentIdByKey: /* @__PURE__ */ new Map()
|
|
2118
|
+
};
|
|
2119
|
+
const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
|
|
2120
|
+
if (matchValues.length === 0) return /* @__PURE__ */ new Map();
|
|
1961
2121
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
1962
|
-
query = applyDynamicRelationQuery(query, query, relation,
|
|
2122
|
+
query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
1963
2123
|
const results = await query;
|
|
1964
2124
|
const resultMap = /* @__PURE__ */ new Map();
|
|
1965
|
-
const parentIdSet = new Set(parsedParentIds.map(String));
|
|
1966
2125
|
for (const row of results) {
|
|
1967
2126
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
if (
|
|
2127
|
+
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
2128
|
+
const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
|
|
2129
|
+
if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
|
|
2130
|
+
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
2131
|
+
if (parentId !== void 0) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
1971
2132
|
}
|
|
1972
2133
|
return resultMap;
|
|
1973
2134
|
}
|
|
@@ -2045,17 +2206,24 @@ var RelationService = class {
|
|
|
2045
2206
|
}
|
|
2046
2207
|
return resultMap;
|
|
2047
2208
|
}
|
|
2048
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
|
|
2209
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName, relation);
|
|
2210
|
+
const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation) ? await this.resolveSourceKeys(parentCollection, relation, parentIds) : {
|
|
2211
|
+
keyByParentId: /* @__PURE__ */ new Map(),
|
|
2212
|
+
parentIdByKey: /* @__PURE__ */ new Map()
|
|
2213
|
+
};
|
|
2214
|
+
const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
|
|
2215
|
+
if (matchValues.length === 0) return /* @__PURE__ */ new Map();
|
|
2049
2216
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
2050
|
-
query = applyDynamicRelationQuery(query, query, relation,
|
|
2217
|
+
query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
2051
2218
|
const results = await query;
|
|
2052
2219
|
const resultMap = /* @__PURE__ */ new Map();
|
|
2053
|
-
const parentIdSet = new Set(parsedParentIds.map(String));
|
|
2054
2220
|
for (const row of results) {
|
|
2055
2221
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
if (
|
|
2222
|
+
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
2223
|
+
const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
|
|
2224
|
+
if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
|
|
2225
|
+
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
2226
|
+
if (parentId !== void 0) {
|
|
2059
2227
|
const key = String(parentId);
|
|
2060
2228
|
const arr = resultMap.get(key) || [];
|
|
2061
2229
|
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
@@ -2154,14 +2322,13 @@ var RelationService = class {
|
|
|
2154
2322
|
logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
2155
2323
|
continue;
|
|
2156
2324
|
}
|
|
2157
|
-
const
|
|
2158
|
-
|
|
2159
|
-
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
2325
|
+
const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
|
|
2326
|
+
if (parentKeyValue === void 0) throw new Error(`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in \`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is nothing for the related rows to point at.`);
|
|
2160
2327
|
if (targetEntityIds.length > 0) {
|
|
2161
2328
|
const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
2162
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(and(eq(fkCol,
|
|
2163
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]:
|
|
2164
|
-
} else await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(fkCol,
|
|
2329
|
+
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
|
|
2330
|
+
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
|
|
2331
|
+
} else await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(fkCol, parentKeyValue));
|
|
2165
2332
|
} else logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
|
|
2166
2333
|
}
|
|
2167
2334
|
}
|
|
@@ -2176,8 +2343,7 @@ var RelationService = class {
|
|
|
2176
2343
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2177
2344
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2178
2345
|
const targetIdInfo = targetPks[0];
|
|
2179
|
-
|
|
2180
|
-
const sourceIdInfo = sourcePks[0];
|
|
2346
|
+
requirePrimaryKeys(sourceCollection, this.registry)[0];
|
|
2181
2347
|
if (relation.kind === "via") {
|
|
2182
2348
|
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
2183
2349
|
continue;
|
|
@@ -2199,13 +2365,14 @@ var RelationService = class {
|
|
|
2199
2365
|
logger.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
|
|
2200
2366
|
continue;
|
|
2201
2367
|
}
|
|
2202
|
-
const
|
|
2203
|
-
if (
|
|
2368
|
+
const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
|
|
2369
|
+
if (sourceKeyValue === void 0) throw new Error(`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row '${sourceEntityId}' has no value in \`sourceKey: "${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing for the related row to point at.`);
|
|
2370
|
+
if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
2204
2371
|
else {
|
|
2205
2372
|
const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
|
|
2206
2373
|
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
2207
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn,
|
|
2208
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]:
|
|
2374
|
+
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
2375
|
+
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
|
|
2209
2376
|
}
|
|
2210
2377
|
} catch (e) {
|
|
2211
2378
|
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
@@ -2511,7 +2678,7 @@ function relationTargetAddress(targetRow, targetCollection, registry) {
|
|
|
2511
2678
|
* expects real types. The row's own address is *not* among the columns — it is
|
|
2512
2679
|
* derived by the consumer from the collection's primary keys.
|
|
2513
2680
|
*/
|
|
2514
|
-
function
|
|
2681
|
+
function toFlatRow(row, collection, registry) {
|
|
2515
2682
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2516
2683
|
const normalized = normalizeDbValues(row, collection);
|
|
2517
2684
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
@@ -2626,238 +2793,497 @@ function assertWritableThrough(hop, path) {
|
|
|
2626
2793
|
throw ApiError.badRequest(`"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on '${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set '${hop.relationKey}' on the parent instead.`, "RELATION_NOT_WRITABLE");
|
|
2627
2794
|
}
|
|
2628
2795
|
//#endregion
|
|
2629
|
-
//#region src/
|
|
2796
|
+
//#region src/utils/pg-error-utils.ts
|
|
2630
2797
|
/**
|
|
2631
|
-
*
|
|
2632
|
-
*
|
|
2798
|
+
* Shared PostgreSQL error extraction and user-friendly message formatting.
|
|
2799
|
+
*
|
|
2800
|
+
* Drizzle wraps native PG errors in a `.cause` chain. These utilities
|
|
2801
|
+
* unwrap that chain to get the real PostgreSQL error (identified by a
|
|
2802
|
+
* 5-character alphanumeric `code` such as `42P01`) and translate it into
|
|
2803
|
+
* a message that is safe and helpful to show to end-users.
|
|
2633
2804
|
*/
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
* target table or in a junction resolves to a correlated `EXISTS`, which
|
|
2657
|
-
* needs the registry to reach that other table and this table's key column
|
|
2658
|
-
* to correlate back.
|
|
2659
|
-
*
|
|
2660
|
-
* Looked up rather than passed: every read path already has the path, only
|
|
2661
|
-
* some have the collection, and a path that names no registered collection
|
|
2662
|
-
* (a nested/derived one) is not an error here — the builder simply falls
|
|
2663
|
-
* back to guessing the default key shapes, and a relation filter it cannot
|
|
2664
|
-
* compile stays unresolvable and so fails closed.
|
|
2665
|
-
*/
|
|
2666
|
-
filterContext(collectionPath, table) {
|
|
2667
|
-
const collection = this.registry.getCollectionByPath(collectionPath) ?? void 0;
|
|
2668
|
-
return {
|
|
2669
|
-
collection,
|
|
2670
|
-
registry: this.registry,
|
|
2671
|
-
sourceIdColumn: collection ? this.resolveIdColumn(collection, table) : void 0
|
|
2672
|
-
};
|
|
2673
|
-
}
|
|
2674
|
-
/**
|
|
2675
|
-
* The table column this collection's rows are keyed by, or `undefined`.
|
|
2676
|
-
*
|
|
2677
|
-
* `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
|
|
2678
|
-
* resolvable key is not an error on the filter path — it only means the
|
|
2679
|
-
* relation filters that would correlate on it cannot be compiled, which
|
|
2680
|
-
* the builder already handles by failing that field closed.
|
|
2681
|
-
*/
|
|
2682
|
-
resolveIdColumn(collection, table) {
|
|
2683
|
-
const [idInfo] = getPrimaryKeys(collection, this.registry);
|
|
2684
|
-
if (!idInfo) return void 0;
|
|
2685
|
-
return table[idInfo.fieldName];
|
|
2686
|
-
}
|
|
2687
|
-
/**
|
|
2688
|
-
* Build filter conditions from FilterValues
|
|
2689
|
-
* Delegates to DrizzleConditionBuilder.buildFilterConditions
|
|
2690
|
-
*/
|
|
2691
|
-
buildFilterConditions(filter, table, collectionPath) {
|
|
2692
|
-
return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath, this.filterContext(collectionPath, table));
|
|
2693
|
-
}
|
|
2694
|
-
/**
|
|
2695
|
-
* Resolves the correct Drizzle column for sorting.
|
|
2696
|
-
* Automatically maps owning relation property keys to their underlying foreign key column.
|
|
2697
|
-
*
|
|
2698
|
-
* The relation's own `localKey` is the authority for that foreign key, not
|
|
2699
|
-
* `<field>_id`. The default local key comes from `generateForeignKeyName`,
|
|
2700
|
-
* which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
|
|
2701
|
-
* `users` → `user_id` — and an author can override it outright. A wrong
|
|
2702
|
-
* guess resolves to nothing, the caller drops the `ORDER BY`, and the rows
|
|
2703
|
-
* come back in whatever order Postgres pleases: paging over that repeats
|
|
2704
|
-
* and skips rows rather than erroring. The guesses stay, last, for a
|
|
2705
|
-
* caller that hands over no collection to resolve against.
|
|
2706
|
-
*/
|
|
2707
|
-
resolveOrderByField(table, orderBy, collection) {
|
|
2708
|
-
const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
|
|
2709
|
-
const direct = columnAt(orderBy);
|
|
2710
|
-
if (direct) return direct;
|
|
2711
|
-
if (collection) {
|
|
2712
|
-
const relation = resolveCollectionRelations(collection)[orderBy];
|
|
2713
|
-
if (relation?.kind === "belongsTo") {
|
|
2714
|
-
const foreignKey = columnAt(relation.localKey);
|
|
2715
|
-
if (foreignKey) return foreignKey;
|
|
2716
|
-
}
|
|
2717
|
-
}
|
|
2718
|
-
for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
|
|
2719
|
-
const foreignKey = columnAt(guess);
|
|
2720
|
-
if (foreignKey) return foreignKey;
|
|
2721
|
-
}
|
|
2722
|
-
}
|
|
2723
|
-
/**
|
|
2724
|
-
* Build the `with` config for Drizzle's relational query API.
|
|
2725
|
-
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
2726
|
-
*
|
|
2727
|
-
* When `include` is provided, only those relations are loaded.
|
|
2728
|
-
* When `include` is absent, ALL relations are loaded (the admin path).
|
|
2729
|
-
*
|
|
2730
|
-
* Automatically detects many-to-many junction tables and nests
|
|
2731
|
-
* the target relation so actual row data is returned.
|
|
2732
|
-
*/
|
|
2733
|
-
buildWithConfig(collection, include) {
|
|
2734
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2735
|
-
const withConfig = {};
|
|
2736
|
-
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
2737
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
2738
|
-
if (!shouldInclude(key)) continue;
|
|
2739
|
-
const drizzleRelName = relation.relationName || key;
|
|
2740
|
-
if (relation.kind === "via") continue;
|
|
2741
|
-
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
2742
|
-
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
2743
|
-
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
2744
|
-
else withConfig[drizzleRelName] = true;
|
|
2745
|
-
} else withConfig[drizzleRelName] = true;
|
|
2746
|
-
}
|
|
2747
|
-
return withConfig;
|
|
2748
|
-
}
|
|
2749
|
-
/**
|
|
2750
|
-
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
2751
|
-
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
2752
|
-
*/
|
|
2753
|
-
getJunctionTargetRelationName(relation, _collection) {
|
|
2754
|
-
if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
2805
|
+
/**
|
|
2806
|
+
* Return the error when it is a deliberate 4xx, otherwise null.
|
|
2807
|
+
*
|
|
2808
|
+
* A thrown `ApiError` is a decision the server made about the request, not a
|
|
2809
|
+
* database failure: its message and code are already written for the client.
|
|
2810
|
+
*/
|
|
2811
|
+
function asClientFacingError(error) {
|
|
2812
|
+
if (!(error instanceof Error)) return null;
|
|
2813
|
+
const e = error;
|
|
2814
|
+
if (typeof e.statusCode !== "number" || e.statusCode < 400 || e.statusCode >= 500) return null;
|
|
2815
|
+
return e;
|
|
2816
|
+
}
|
|
2817
|
+
/**
|
|
2818
|
+
* Extract the underlying PostgreSQL error from a Drizzle wrapper.
|
|
2819
|
+
* Drizzle wraps PG errors in a `cause` property — this function
|
|
2820
|
+
* recursively walks the chain until it finds an object with a PG
|
|
2821
|
+
* error code (5-char alphanumeric, e.g. `42P01`).
|
|
2822
|
+
*/
|
|
2823
|
+
function extractPgError(error) {
|
|
2824
|
+
if (!error || typeof error !== "object") return null;
|
|
2825
|
+
if (!(error instanceof Error)) {
|
|
2826
|
+
if ("cause" in error && error.cause && typeof error.cause === "object") return extractPgError(error.cause);
|
|
2755
2827
|
return null;
|
|
2756
2828
|
}
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2829
|
+
if ("code" in error && typeof error.code === "string" && /^[0-9A-Z]{5}$/.test(error.code)) return error;
|
|
2830
|
+
if (error.cause && typeof error.cause === "object") return extractPgError(error.cause);
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
2833
|
+
/**
|
|
2834
|
+
* Whether the failure came back from Postgres rather than from building the
|
|
2835
|
+
* query — which decides whether a fallback query is worth issuing.
|
|
2836
|
+
*
|
|
2837
|
+
* Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
|
|
2838
|
+
* RLS). Once a statement raises, that transaction is aborted, and every later
|
|
2839
|
+
* statement on it returns `25P02` — "current transaction is aborted, commands
|
|
2840
|
+
* ignored until end of transaction block". So a retry after a database error
|
|
2841
|
+
* cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
|
|
2842
|
+
* for type uuid") with a generic one. Rethrow instead.
|
|
2843
|
+
*
|
|
2844
|
+
* A query the driver could not even build — a missing reciprocal relation, say
|
|
2845
|
+
* — never reached Postgres, leaves the transaction usable, and is exactly what
|
|
2846
|
+
* the fallback paths exist for.
|
|
2847
|
+
*/
|
|
2848
|
+
function reachedDatabase(error) {
|
|
2849
|
+
return extractPgError(error) !== null;
|
|
2850
|
+
}
|
|
2851
|
+
/**
|
|
2852
|
+
* Walk the error cause chain and return the deepest meaningful message.
|
|
2853
|
+
*/
|
|
2854
|
+
function extractCauseMessage(error) {
|
|
2855
|
+
if (!error || typeof error !== "object") return null;
|
|
2856
|
+
if (!(error instanceof Error)) return null;
|
|
2857
|
+
if (error.cause && typeof error.cause === "object") {
|
|
2858
|
+
const deeper = extractCauseMessage(error.cause);
|
|
2859
|
+
if (deeper) return deeper;
|
|
2860
|
+
if (error.cause instanceof Error && error.cause.message) return error.cause.message;
|
|
2776
2861
|
}
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2862
|
+
return null;
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Codes that mean "this connection will never work as configured".
|
|
2866
|
+
*
|
|
2867
|
+
* A wrong password or a database that does not exist is a settled fact about
|
|
2868
|
+
* the connection string, not a transient fault — retrying produces the same
|
|
2869
|
+
* answer forever.
|
|
2870
|
+
*/
|
|
2871
|
+
var UNRECOVERABLE_CONNECT_CODES = /* @__PURE__ */ new Set([
|
|
2872
|
+
"28P01",
|
|
2873
|
+
"28000",
|
|
2874
|
+
"3D000",
|
|
2875
|
+
"42501"
|
|
2876
|
+
]);
|
|
2877
|
+
/**
|
|
2878
|
+
* Describe a failed connection attempt in terms a developer can act on.
|
|
2879
|
+
*
|
|
2880
|
+
* The error a caller catches is Drizzle's wrapper: its message is
|
|
2881
|
+
* `Failed query: SELECT 1` and its stack runs through drizzle internals, while
|
|
2882
|
+
* the sentence that says what is actually wrong — "password authentication
|
|
2883
|
+
* failed for user …", "database … does not exist" — sits in `.cause`. Logging
|
|
2884
|
+
* the wrapper, as the bootstrapper used to, tells a developer with a typo in
|
|
2885
|
+
* their `DATABASE_URL` nothing at all.
|
|
2886
|
+
*/
|
|
2887
|
+
function classifyConnectFailure(error) {
|
|
2888
|
+
const pgError = extractPgError(error);
|
|
2889
|
+
const reason = pgError?.message ?? extractCauseMessage(error) ?? (error instanceof Error ? error.message : String(error));
|
|
2890
|
+
return {
|
|
2891
|
+
fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
|
|
2892
|
+
reason,
|
|
2893
|
+
code: pgError?.code
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Detect whether an error is specifically a role-switching permission failure
|
|
2898
|
+
* (e.g. "permission denied to set role" or "must be member of role"),
|
|
2899
|
+
* as opposed to a table-level permission denial.
|
|
2900
|
+
*
|
|
2901
|
+
* This is used by the backend driver to auto-disable role switching when the
|
|
2902
|
+
* connection user lacks SET ROLE privileges, rather than surfacing a confusing
|
|
2903
|
+
* error to the Studio SQL Editor user.
|
|
2904
|
+
*/
|
|
2905
|
+
function isRoleSwitchingPermissionError(error) {
|
|
2906
|
+
const pgError = extractPgError(error);
|
|
2907
|
+
if (!pgError || pgError.code !== "42501") return false;
|
|
2908
|
+
const msg = pgError.message.toLowerCase();
|
|
2909
|
+
return msg.includes("set role") || msg.includes("member of role");
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Translate a raw PostgreSQL error into a user-friendly message.
|
|
2913
|
+
*
|
|
2914
|
+
* @param pgError - The extracted PostgreSQL error (from {@link extractPgError})
|
|
2915
|
+
* @param context - A human-readable context string (e.g. collection slug or path)
|
|
2916
|
+
* @returns An object with a `message` safe for the client and the PG `code`.
|
|
2917
|
+
*/
|
|
2918
|
+
function pgErrorToFriendlyMessage(pgError, context) {
|
|
2919
|
+
const detail = pgError.detail;
|
|
2920
|
+
const hint = pgError.hint;
|
|
2921
|
+
const constraint = pgError.constraint;
|
|
2922
|
+
const column = pgError.column;
|
|
2923
|
+
const table = pgError.table;
|
|
2924
|
+
const dataType = pgError.dataType;
|
|
2925
|
+
const pgMessage = pgError.message || "Unknown database error";
|
|
2926
|
+
const code = pgError.code || "UNKNOWN";
|
|
2927
|
+
const suffix = hint ? ` Hint: ${hint}` : "";
|
|
2928
|
+
const tableRef = table ?? context;
|
|
2929
|
+
switch (pgError.code) {
|
|
2930
|
+
case "23503": return {
|
|
2931
|
+
message: detail ? `Foreign key constraint violated: ${detail}${suffix}` : `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
2932
|
+
code
|
|
2791
2933
|
};
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
|
|
2934
|
+
case "23505": return {
|
|
2935
|
+
message: detail ? `Duplicate value: ${detail}${suffix}` : `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
2936
|
+
code
|
|
2937
|
+
};
|
|
2938
|
+
case "23502": return {
|
|
2939
|
+
message: `Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`,
|
|
2940
|
+
code
|
|
2941
|
+
};
|
|
2942
|
+
case "23514": return {
|
|
2943
|
+
message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
2944
|
+
code
|
|
2945
|
+
};
|
|
2946
|
+
case "22P02": return {
|
|
2947
|
+
message: `Invalid data format in "${context}": ${pgMessage}${suffix}`,
|
|
2948
|
+
code
|
|
2949
|
+
};
|
|
2950
|
+
case "22001": return {
|
|
2951
|
+
message: `Value too long for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
|
|
2952
|
+
code
|
|
2953
|
+
};
|
|
2954
|
+
case "22003": return {
|
|
2955
|
+
message: `Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
|
|
2956
|
+
code
|
|
2957
|
+
};
|
|
2958
|
+
case "42703": return {
|
|
2959
|
+
message: `Unknown column in "${tableRef}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
2960
|
+
code
|
|
2961
|
+
};
|
|
2962
|
+
case "42P01": return {
|
|
2963
|
+
message: `Table not found for "${context}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
2964
|
+
code
|
|
2965
|
+
};
|
|
2966
|
+
case "42501": return {
|
|
2967
|
+
message: `Permission denied on "${tableRef}". Check your database credentials and RLS policies.${suffix}`,
|
|
2968
|
+
code
|
|
2969
|
+
};
|
|
2970
|
+
case "28000": return {
|
|
2971
|
+
message: `Authorization failed for "${context}". Check your database credentials.${suffix}`,
|
|
2972
|
+
code
|
|
2973
|
+
};
|
|
2974
|
+
default: {
|
|
2975
|
+
const parts = [`Database error in "${context}" [${code}]: ${pgMessage}`];
|
|
2976
|
+
if (detail) parts.push(`Detail: ${detail}`);
|
|
2977
|
+
if (column) parts.push(`Column: ${column}`);
|
|
2978
|
+
if (dataType) parts.push(`Data type: ${dataType}`);
|
|
2979
|
+
if (constraint) parts.push(`Constraint: ${constraint}`);
|
|
2980
|
+
if (hint) parts.push(`Hint: ${hint}`);
|
|
2981
|
+
return {
|
|
2982
|
+
message: parts.join(". "),
|
|
2983
|
+
code
|
|
2984
|
+
};
|
|
2808
2985
|
}
|
|
2809
2986
|
}
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Sanitize any error into a message safe and helpful for the client.
|
|
2990
|
+
*
|
|
2991
|
+
* A deliberate 4xx (`ApiError`) passes through untouched — the server already
|
|
2992
|
+
* decided what the client should read. Otherwise the PG error is extracted
|
|
2993
|
+
* from the Drizzle cause chain, falling back to a generic message that
|
|
2994
|
+
* doesn't leak SQL.
|
|
2995
|
+
*
|
|
2996
|
+
* @param error - The raw caught error
|
|
2997
|
+
* @param context - A human-readable context string (e.g. collection path)
|
|
2998
|
+
* @returns An object with `message` (user-friendly) and optional `code`
|
|
2999
|
+
* (the `ApiError` code, or the PG SQLSTATE).
|
|
3000
|
+
*/
|
|
3001
|
+
function sanitizeErrorForClient(error, context) {
|
|
3002
|
+
const clientError = asClientFacingError(error);
|
|
3003
|
+
if (clientError) {
|
|
3004
|
+
const line = `[API ${clientError.statusCode} ${clientError.code ?? "BAD_REQUEST"}] in "${context}": ${clientError.message}`;
|
|
3005
|
+
if (clientError.expected) logger.debug(line);
|
|
3006
|
+
else logger.warn(`⚠️ ${line}`);
|
|
3007
|
+
return {
|
|
3008
|
+
message: clientError.message,
|
|
3009
|
+
...clientError.code && { code: clientError.code }
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
3012
|
+
const pgError = extractPgError(error);
|
|
3013
|
+
if (pgError) {
|
|
3014
|
+
logger.error(`[PG ${pgError.code}] Error in "${context}"`, {
|
|
3015
|
+
code: pgError.code,
|
|
3016
|
+
message: pgError.message,
|
|
3017
|
+
detail: pgError.detail,
|
|
3018
|
+
hint: pgError.hint,
|
|
3019
|
+
column: pgError.column,
|
|
3020
|
+
table: pgError.table,
|
|
3021
|
+
constraint: pgError.constraint,
|
|
3022
|
+
dataType: pgError.dataType,
|
|
3023
|
+
drizzleMessage: error instanceof Error ? error.message : String(error)
|
|
3024
|
+
});
|
|
3025
|
+
return pgErrorToFriendlyMessage(pgError, context);
|
|
3026
|
+
}
|
|
3027
|
+
logger.error(`Database error in "${context}" (no PG error extracted)`, {
|
|
3028
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3029
|
+
stack: error instanceof Error ? error.stack : void 0,
|
|
3030
|
+
cause: error instanceof Error && error.cause ? error.cause instanceof Error ? error.cause.message : String(error.cause) : void 0
|
|
3031
|
+
});
|
|
3032
|
+
const causeMessage = extractCauseMessage(error);
|
|
3033
|
+
if (causeMessage) return { message: `Database error in "${context}": ${causeMessage}` };
|
|
3034
|
+
return { message: `Could not load data for "${context}". Check server logs for details.` };
|
|
3035
|
+
}
|
|
3036
|
+
//#endregion
|
|
3037
|
+
//#region src/services/FetchService.ts
|
|
3038
|
+
/**
|
|
3039
|
+
* Service for handling all row read operations.
|
|
3040
|
+
* Handles fetching, searching, counting, and filtering rows.
|
|
3041
|
+
*/
|
|
3042
|
+
var FetchService = class {
|
|
3043
|
+
db;
|
|
3044
|
+
registry;
|
|
3045
|
+
relationService;
|
|
3046
|
+
constructor(db, registry) {
|
|
3047
|
+
this.db = db;
|
|
3048
|
+
this.registry = registry;
|
|
3049
|
+
this.relationService = new RelationService(db, registry);
|
|
3050
|
+
}
|
|
2810
3051
|
/**
|
|
2811
|
-
*
|
|
2812
|
-
*
|
|
3052
|
+
* Get the relational query builder for a given table name.
|
|
3053
|
+
* Safely narrows the DrizzleClient union type to access db.query[tableName].
|
|
2813
3054
|
*/
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
if (withConfig) queryOpts.with = withConfig;
|
|
2817
|
-
const allConditions = [];
|
|
2818
|
-
if (scopeCondition) allConditions.push(scopeCondition);
|
|
2819
|
-
if (options.searchString) {
|
|
2820
|
-
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2821
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
2822
|
-
if (searchConditions.length === 0) {
|
|
2823
|
-
queryOpts.where = and(eq(idField, -99999999));
|
|
2824
|
-
return queryOpts;
|
|
2825
|
-
}
|
|
2826
|
-
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
2827
|
-
}
|
|
2828
|
-
if (options.filter) {
|
|
2829
|
-
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
2830
|
-
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
2831
|
-
}
|
|
2832
|
-
if (options.logical) {
|
|
2833
|
-
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
2834
|
-
if (logicalCondition) allConditions.push(logicalCondition);
|
|
2835
|
-
}
|
|
2836
|
-
if (options.startAfter) {
|
|
2837
|
-
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
2838
|
-
if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
|
|
2839
|
-
}
|
|
2840
|
-
if (allConditions.length > 0) queryOpts.where = and(...allConditions);
|
|
2841
|
-
const orderExpressions = [];
|
|
2842
|
-
if (options.orderBy) {
|
|
2843
|
-
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
2844
|
-
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
2845
|
-
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
2846
|
-
}
|
|
2847
|
-
orderExpressions.push(desc(idField));
|
|
2848
|
-
if (orderExpressions.length > 0) queryOpts.orderBy = orderExpressions;
|
|
2849
|
-
const limitValue = options.searchString ? options.limit || 50 : options.limit;
|
|
2850
|
-
if (limitValue) queryOpts.limit = limitValue;
|
|
2851
|
-
if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
|
|
2852
|
-
return queryOpts;
|
|
3055
|
+
getQueryBuilder(tableName) {
|
|
3056
|
+
return this.db.query?.[tableName];
|
|
2853
3057
|
}
|
|
2854
3058
|
/**
|
|
2855
|
-
*
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
3059
|
+
* The context the condition builder needs to compile a filter key that is
|
|
3060
|
+
* not a column name outright.
|
|
3061
|
+
*
|
|
3062
|
+
* Two such keys. An owning relation's key resolves through the collection's
|
|
3063
|
+
* relations to its foreign-key column; a relation whose link lives on the
|
|
3064
|
+
* target table or in a junction resolves to a correlated `EXISTS`, which
|
|
3065
|
+
* needs the registry to reach that other table and this table's key column
|
|
3066
|
+
* to correlate back.
|
|
3067
|
+
*
|
|
3068
|
+
* Looked up rather than passed: every read path already has the path, only
|
|
3069
|
+
* some have the collection, and a path that names no registered collection
|
|
3070
|
+
* (a nested/derived one) is not an error here — the builder simply falls
|
|
3071
|
+
* back to guessing the default key shapes, and a relation filter it cannot
|
|
3072
|
+
* compile stays unresolvable and so fails closed.
|
|
3073
|
+
*/
|
|
3074
|
+
filterContext(collectionPath, table) {
|
|
3075
|
+
const collection = this.registry.getCollectionByPath(collectionPath) ?? void 0;
|
|
3076
|
+
return {
|
|
3077
|
+
collection,
|
|
3078
|
+
registry: this.registry,
|
|
3079
|
+
sourceIdColumn: collection ? this.resolveIdColumn(collection, table) : void 0
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* The table column this collection's rows are keyed by, or `undefined`.
|
|
3084
|
+
*
|
|
3085
|
+
* `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
|
|
3086
|
+
* resolvable key is not an error on the filter path — it only means the
|
|
3087
|
+
* relation filters that would correlate on it cannot be compiled, which
|
|
3088
|
+
* the builder already handles by failing that field closed.
|
|
3089
|
+
*/
|
|
3090
|
+
resolveIdColumn(collection, table) {
|
|
3091
|
+
const [idInfo] = getPrimaryKeys(collection, this.registry);
|
|
3092
|
+
if (!idInfo) return void 0;
|
|
3093
|
+
return table[idInfo.fieldName];
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* Build filter conditions from FilterValues
|
|
3097
|
+
* Delegates to DrizzleConditionBuilder.buildFilterConditions
|
|
3098
|
+
*/
|
|
3099
|
+
buildFilterConditions(filter, table, collectionPath) {
|
|
3100
|
+
return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath, this.filterContext(collectionPath, table));
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* Resolves the correct Drizzle column for sorting.
|
|
3104
|
+
* Automatically maps owning relation property keys to their underlying foreign key column.
|
|
3105
|
+
*
|
|
3106
|
+
* The relation's own `localKey` is the authority for that foreign key, not
|
|
3107
|
+
* `<field>_id`. The default local key comes from `generateForeignKeyName`,
|
|
3108
|
+
* which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
|
|
3109
|
+
* `users` → `user_id` — and an author can override it outright. A wrong
|
|
3110
|
+
* guess resolves to nothing, the caller drops the `ORDER BY`, and the rows
|
|
3111
|
+
* come back in whatever order Postgres pleases: paging over that repeats
|
|
3112
|
+
* and skips rows rather than erroring. The guesses stay, last, for a
|
|
3113
|
+
* caller that hands over no collection to resolve against.
|
|
3114
|
+
*/
|
|
3115
|
+
resolveOrderByField(table, orderBy, collection) {
|
|
3116
|
+
const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
|
|
3117
|
+
const direct = columnAt(orderBy);
|
|
3118
|
+
if (direct) return direct;
|
|
3119
|
+
const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : void 0;
|
|
3120
|
+
if (declaredRelation?.kind === "belongsTo") {
|
|
3121
|
+
const foreignKey = columnAt(declaredRelation.localKey);
|
|
3122
|
+
if (foreignKey) return foreignKey;
|
|
3123
|
+
}
|
|
3124
|
+
for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
|
|
3125
|
+
const foreignKey = columnAt(guess);
|
|
3126
|
+
if (foreignKey) return foreignKey;
|
|
3127
|
+
}
|
|
3128
|
+
const collectionName = collection?.slug ?? collection?.name;
|
|
3129
|
+
const onCollection = collectionName ? ` on collection '${collectionName}'` : "";
|
|
3130
|
+
if (declaredRelation && declaredRelation.kind !== "belongsTo") throw ApiError.badRequest(`Cannot sort by '${orderBy}'${onCollection}: it is a to-many relation (${declaredRelation.kind}), which has no single value per row to order by.`, "ORDER_BY_FIELD_NOT_SORTABLE", {
|
|
3131
|
+
field: orderBy,
|
|
3132
|
+
kind: declaredRelation.kind,
|
|
3133
|
+
...collectionName && { collection: collectionName }
|
|
3134
|
+
});
|
|
3135
|
+
if (getUnknownFilterFieldsMode() === "warn") {
|
|
3136
|
+
logger.warn(`Sorting by field '${orderBy}'${onCollection}, but it does not exist in the table — the ORDER BY was dropped and these rows are unsorted.`);
|
|
3137
|
+
return;
|
|
3138
|
+
}
|
|
3139
|
+
let validFields = [];
|
|
3140
|
+
try {
|
|
3141
|
+
validFields = Object.keys(getTableColumns(table)).sort();
|
|
3142
|
+
} catch {}
|
|
3143
|
+
throw ApiError.badRequest(`Unknown orderBy field '${orderBy}'${onCollection}` + (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""), "UNKNOWN_ORDER_BY_FIELD", {
|
|
3144
|
+
field: orderBy,
|
|
3145
|
+
...collectionName && { collection: collectionName },
|
|
3146
|
+
...validFields.length > 0 && { validFields }
|
|
3147
|
+
});
|
|
3148
|
+
}
|
|
3149
|
+
/**
|
|
3150
|
+
* Build the `with` config for Drizzle's relational query API.
|
|
3151
|
+
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
3152
|
+
*
|
|
3153
|
+
* When `include` is provided, only those relations are loaded.
|
|
3154
|
+
* When `include` is absent, ALL relations are loaded (the admin path).
|
|
3155
|
+
*
|
|
3156
|
+
* Automatically detects many-to-many junction tables and nests
|
|
3157
|
+
* the target relation so actual row data is returned.
|
|
3158
|
+
*/
|
|
3159
|
+
buildWithConfig(collection, include) {
|
|
3160
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3161
|
+
const withConfig = {};
|
|
3162
|
+
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
3163
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
3164
|
+
if (!shouldInclude(key)) continue;
|
|
3165
|
+
const drizzleRelName = relation.relationName || key;
|
|
3166
|
+
if (relation.kind === "via") continue;
|
|
3167
|
+
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
3168
|
+
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
3169
|
+
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
3170
|
+
else withConfig[drizzleRelName] = true;
|
|
3171
|
+
} else withConfig[drizzleRelName] = true;
|
|
3172
|
+
}
|
|
3173
|
+
return withConfig;
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
3177
|
+
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
3178
|
+
*/
|
|
3179
|
+
getJunctionTargetRelationName(relation, _collection) {
|
|
3180
|
+
if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
3181
|
+
return null;
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Post-fetch joinPath relations for a single flat row.
|
|
3185
|
+
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
3186
|
+
* so they must be loaded separately after the primary query.
|
|
3187
|
+
*/
|
|
3188
|
+
async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
|
|
3189
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3190
|
+
const promises = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via").map(async ([key, relation]) => {
|
|
3191
|
+
try {
|
|
3192
|
+
const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
|
|
3193
|
+
if (relation.cardinality === "one" && relatedRows.length > 0) {
|
|
3194
|
+
const e = relatedRows[0];
|
|
3195
|
+
row[key] = createRelationRefWithData(e.id, e.path, e);
|
|
3196
|
+
} else if (relation.cardinality === "many") row[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
|
|
3197
|
+
} catch (e) {
|
|
3198
|
+
logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
|
|
3199
|
+
}
|
|
3200
|
+
});
|
|
3201
|
+
await Promise.all(promises);
|
|
3202
|
+
}
|
|
3203
|
+
/**
|
|
3204
|
+
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
3205
|
+
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
3206
|
+
*/
|
|
3207
|
+
async resolveJoinPathRelationsBatchRest(rows, collection, collectionPath, idInfoArray, include) {
|
|
3208
|
+
if (rows.length === 0) return;
|
|
3209
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3210
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
3211
|
+
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
3212
|
+
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
|
|
3213
|
+
if (joinPathRelations.length === 0) return;
|
|
3214
|
+
const parentIdOf = (row) => {
|
|
3215
|
+
const address = buildCompositeId(row, idInfoArray);
|
|
3216
|
+
return address && address.split(":::").some((part) => part !== "") ? address : void 0;
|
|
3217
|
+
};
|
|
3218
|
+
for (const [key, relation] of joinPathRelations) try {
|
|
3219
|
+
const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
|
|
3220
|
+
if (addressable.length === 0) continue;
|
|
3221
|
+
const rowIds = addressable.map((r) => parentIdOf(r));
|
|
3222
|
+
if (relation.cardinality === "one") {
|
|
3223
|
+
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
3224
|
+
for (const row of addressable) {
|
|
3225
|
+
const relatedRow = resultMap.get(String(parentIdOf(row)));
|
|
3226
|
+
row[key] = relatedRow ? { ...relatedRow.values } : null;
|
|
3227
|
+
}
|
|
3228
|
+
} else if (relation.cardinality === "many") {
|
|
3229
|
+
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
|
|
3230
|
+
for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
|
|
3231
|
+
}
|
|
3232
|
+
} catch (e) {
|
|
3233
|
+
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
/**
|
|
3237
|
+
* Build db.query-compatible options from standard fetch options.
|
|
3238
|
+
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
3239
|
+
*/
|
|
3240
|
+
buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
|
|
3241
|
+
const queryOpts = {};
|
|
3242
|
+
if (withConfig) queryOpts.with = withConfig;
|
|
3243
|
+
const allConditions = [];
|
|
3244
|
+
if (scopeCondition) allConditions.push(scopeCondition);
|
|
3245
|
+
if (options.searchString) {
|
|
3246
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3247
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3248
|
+
if (searchConditions.length === 0) {
|
|
3249
|
+
queryOpts.where = and(eq(idField, -99999999));
|
|
3250
|
+
return queryOpts;
|
|
3251
|
+
}
|
|
3252
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3253
|
+
}
|
|
3254
|
+
if (options.filter) {
|
|
3255
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3256
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
3257
|
+
}
|
|
3258
|
+
if (options.logical) {
|
|
3259
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
3260
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
3261
|
+
}
|
|
3262
|
+
if (options.startAfter) {
|
|
3263
|
+
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
3264
|
+
if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
|
|
3265
|
+
}
|
|
3266
|
+
if (allConditions.length > 0) queryOpts.where = and(...allConditions);
|
|
3267
|
+
const orderExpressions = [];
|
|
3268
|
+
if (options.orderBy) {
|
|
3269
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3270
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
3271
|
+
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3272
|
+
}
|
|
3273
|
+
orderExpressions.push(desc(idField));
|
|
3274
|
+
if (orderExpressions.length > 0) queryOpts.orderBy = orderExpressions;
|
|
3275
|
+
const limitValue = options.searchString ? options.limit || 50 : options.limit;
|
|
3276
|
+
if (limitValue) queryOpts.limit = limitValue;
|
|
3277
|
+
if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
|
|
3278
|
+
return queryOpts;
|
|
3279
|
+
}
|
|
3280
|
+
/**
|
|
3281
|
+
* Extract cursor pagination conditions from startAfter options.
|
|
3282
|
+
*/
|
|
3283
|
+
buildCursorConditions(table, idField, idInfo, options, collectionPath) {
|
|
3284
|
+
if (!options.startAfter) return [];
|
|
3285
|
+
const cursor = options.startAfter;
|
|
3286
|
+
if (options.orderBy) {
|
|
2861
3287
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
|
|
2862
3288
|
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
2863
3289
|
if (orderByField) {
|
|
@@ -2922,6 +3348,7 @@ var FetchService = class {
|
|
|
2922
3348
|
const idInfo = idInfoArray[0];
|
|
2923
3349
|
const idField = table[idInfo.fieldName];
|
|
2924
3350
|
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
3351
|
+
if (!idCanAddressTable(id, table, idInfoArray)) return void 0;
|
|
2925
3352
|
const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
|
|
2926
3353
|
const tableName = getTableName(table);
|
|
2927
3354
|
const qb = this.getQueryBuilder(tableName);
|
|
@@ -2932,7 +3359,7 @@ var FetchService = class {
|
|
|
2932
3359
|
with: withConfig
|
|
2933
3360
|
});
|
|
2934
3361
|
if (!row) return void 0;
|
|
2935
|
-
const flatRow =
|
|
3362
|
+
const flatRow = toFlatRow(row, collection, this.registry);
|
|
2936
3363
|
await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
2937
3364
|
return flatRow;
|
|
2938
3365
|
} catch (e) {
|
|
@@ -2940,6 +3367,7 @@ var FetchService = class {
|
|
|
2940
3367
|
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2941
3368
|
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.");
|
|
2942
3369
|
}
|
|
3370
|
+
if (reachedDatabase(e)) throw e;
|
|
2943
3371
|
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
2944
3372
|
}
|
|
2945
3373
|
const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
|
|
@@ -2987,12 +3415,13 @@ var FetchService = class {
|
|
|
2987
3415
|
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
2988
3416
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
2989
3417
|
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
|
|
2990
|
-
return (await qb.findMany(queryOpts)).map((row) =>
|
|
3418
|
+
return (await qb.findMany(queryOpts)).map((row) => toFlatRow(row, collection, this.registry));
|
|
2991
3419
|
} catch (e) {
|
|
2992
3420
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
2993
3421
|
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
2994
3422
|
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.");
|
|
2995
3423
|
}
|
|
3424
|
+
if (reachedDatabase(e)) throw e;
|
|
2996
3425
|
logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
2997
3426
|
}
|
|
2998
3427
|
let vectorMeta;
|
|
@@ -3050,7 +3479,7 @@ var FetchService = class {
|
|
|
3050
3479
|
/**
|
|
3051
3480
|
* Fallback path used when db.query is unavailable.
|
|
3052
3481
|
*
|
|
3053
|
-
* The primary path runs the results through `
|
|
3482
|
+
* The primary path runs the results through `toFlatRow`, which maps
|
|
3054
3483
|
* relations from what drizzle already nested — no query per row. This one
|
|
3055
3484
|
* has no nesting to read, so it resolves relations itself, in batches.
|
|
3056
3485
|
*
|
|
@@ -3143,6 +3572,10 @@ var FetchService = class {
|
|
|
3143
3572
|
const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
|
|
3144
3573
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
3145
3574
|
}
|
|
3575
|
+
if (options.logical) {
|
|
3576
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, effectivePath, this.filterContext(effectivePath, table));
|
|
3577
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
3578
|
+
}
|
|
3146
3579
|
if (allConditions.length > 0) {
|
|
3147
3580
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
3148
3581
|
if (finalCondition) query = query.where(finalCondition);
|
|
@@ -3208,6 +3641,7 @@ var FetchService = class {
|
|
|
3208
3641
|
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
3209
3642
|
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.");
|
|
3210
3643
|
}
|
|
3644
|
+
if (reachedDatabase(e)) throw e;
|
|
3211
3645
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
3212
3646
|
}
|
|
3213
3647
|
const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
|
|
@@ -3253,6 +3687,7 @@ var FetchService = class {
|
|
|
3253
3687
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
3254
3688
|
const idInfo = idInfoArray[0];
|
|
3255
3689
|
const idField = table[idInfo.fieldName];
|
|
3690
|
+
if (!idCanAddressTable(id, table, idInfoArray)) return null;
|
|
3256
3691
|
const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
|
|
3257
3692
|
const tableName = getTableName(table);
|
|
3258
3693
|
const qb = this.getQueryBuilder(tableName);
|
|
@@ -3271,6 +3706,7 @@ var FetchService = class {
|
|
|
3271
3706
|
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
3272
3707
|
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.");
|
|
3273
3708
|
}
|
|
3709
|
+
if (reachedDatabase(e)) throw e;
|
|
3274
3710
|
logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
|
|
3275
3711
|
}
|
|
3276
3712
|
const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
|
|
@@ -3304,353 +3740,130 @@ var FetchService = class {
|
|
|
3304
3740
|
}
|
|
3305
3741
|
/**
|
|
3306
3742
|
* Fetch raw rows without any relation processing (for REST fast path)
|
|
3307
|
-
*/
|
|
3308
|
-
async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
|
|
3309
|
-
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3310
|
-
const table = getTableForCollection(collection, this.registry);
|
|
3311
|
-
const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
|
|
3312
|
-
let vectorMeta;
|
|
3313
|
-
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
3314
|
-
let query = vectorMeta ? this.db.select({
|
|
3315
|
-
table_row: table,
|
|
3316
|
-
_distance: vectorMeta.distanceSelect
|
|
3317
|
-
}).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
3318
|
-
const allConditions = [];
|
|
3319
|
-
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
3320
|
-
if (options.searchString) {
|
|
3321
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3322
|
-
if (searchConditions.length === 0) return [];
|
|
3323
|
-
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3324
|
-
}
|
|
3325
|
-
if (options.filter) {
|
|
3326
|
-
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3327
|
-
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
3328
|
-
}
|
|
3329
|
-
if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
|
|
3330
|
-
if (allConditions.length > 0) {
|
|
3331
|
-
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
3332
|
-
if (finalCondition) query = query.where(finalCondition);
|
|
3333
|
-
}
|
|
3334
|
-
const orderExpressions = [];
|
|
3335
|
-
if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
|
|
3336
|
-
else if (options.orderBy) {
|
|
3337
|
-
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
3338
|
-
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3339
|
-
}
|
|
3340
|
-
orderExpressions.push(desc(idField));
|
|
3341
|
-
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
3342
|
-
const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
|
|
3343
|
-
if (limitValue) query = query.limit(limitValue);
|
|
3344
|
-
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
3345
|
-
const rawResults = await query;
|
|
3346
|
-
if (vectorMeta) return rawResults.map((r) => ({
|
|
3347
|
-
...r.table_row,
|
|
3348
|
-
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
3349
|
-
}));
|
|
3350
|
-
return rawResults;
|
|
3351
|
-
}
|
|
3352
|
-
/**
|
|
3353
|
-
* Check if the Drizzle instance has the relational query API available
|
|
3354
|
-
* for a given collection path.
|
|
3355
|
-
* Note: Primary path now uses inline `getQueryBuilder()` checks.
|
|
3356
|
-
*/
|
|
3357
|
-
hasDrizzleQueryAPI(collectionPath) {
|
|
3358
|
-
if (!this.getQueryBuilder("__probe__")) return false;
|
|
3359
|
-
const tableName = getTableName(getTableForCollection(getCollectionByPath(collectionPath, this.registry), this.registry));
|
|
3360
|
-
return !!this.getQueryBuilder(tableName);
|
|
3361
|
-
}
|
|
3362
|
-
/**
|
|
3363
|
-
* Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
|
|
3364
|
-
* for efficient JOIN-based relation loading.
|
|
3365
|
-
* Returns null if the API is not available or the query fails.
|
|
3366
|
-
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
3367
|
-
*/
|
|
3368
|
-
async fetchWithDrizzleQuery(collectionPath, collection, options, include, idInfo, idInfoArray) {
|
|
3369
|
-
try {
|
|
3370
|
-
const table = getTableForCollection(collection, this.registry);
|
|
3371
|
-
const tableName = getTableName(table);
|
|
3372
|
-
const queryTarget = this.getQueryBuilder(tableName);
|
|
3373
|
-
if (!queryTarget?.findMany) return null;
|
|
3374
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3375
|
-
const withConfig = {};
|
|
3376
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) if (include[0] === "*" || include.includes(key)) {
|
|
3377
|
-
const drizzleRelName = relation.relationName || key;
|
|
3378
|
-
withConfig[drizzleRelName] = true;
|
|
3379
|
-
}
|
|
3380
|
-
const queryOpts = { with: withConfig };
|
|
3381
|
-
if (options.limit) queryOpts.limit = options.limit;
|
|
3382
|
-
if (options.filter) {
|
|
3383
|
-
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3384
|
-
if (filterConditions.length > 0) queryOpts.where = and(...filterConditions);
|
|
3385
|
-
}
|
|
3386
|
-
if (options.orderBy) {
|
|
3387
|
-
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
3388
|
-
if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
3389
|
-
}
|
|
3390
|
-
return (await queryTarget.findMany(queryOpts)).map((row) => {
|
|
3391
|
-
const flat = {};
|
|
3392
|
-
for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
|
|
3393
|
-
const keys = Object.keys(item);
|
|
3394
|
-
const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
3395
|
-
if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
|
|
3396
|
-
return { ...item };
|
|
3397
|
-
});
|
|
3398
|
-
else if (typeof v === "object" && v !== null) flat[k] = { ...v };
|
|
3399
|
-
else flat[k] = v;
|
|
3400
|
-
return flat;
|
|
3401
|
-
});
|
|
3402
|
-
} catch (e) {
|
|
3403
|
-
logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
|
|
3404
|
-
return null;
|
|
3405
|
-
}
|
|
3406
|
-
}
|
|
3407
|
-
/**
|
|
3408
|
-
* Fallback path used when db.query is unavailable.
|
|
3409
|
-
* The primary path uses db.query.findMany with `with` config, which
|
|
3410
|
-
* loads all relations in a single query.
|
|
3411
|
-
*
|
|
3412
|
-
* Batch fetch many-to-many related rows for multiple parent IDs.
|
|
3413
|
-
* Groups results by parent ID to avoid N+1.
|
|
3414
|
-
*/
|
|
3415
|
-
async batchFetchManyRelatedRows(parentCollectionPath, parentIds, relationKey) {
|
|
3416
|
-
if (parentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
3417
|
-
const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
|
|
3418
|
-
if (!relation) {
|
|
3419
|
-
logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
|
|
3420
|
-
return /* @__PURE__ */ new Map();
|
|
3421
|
-
}
|
|
3422
|
-
const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
|
|
3423
|
-
const flatMap = /* @__PURE__ */ new Map();
|
|
3424
|
-
for (const [key, rows] of entityMap) flatMap.set(key, rows.map((e) => ({
|
|
3425
|
-
...e.values,
|
|
3426
|
-
id: e.id
|
|
3427
|
-
})));
|
|
3428
|
-
return flatMap;
|
|
3429
|
-
}
|
|
3430
|
-
};
|
|
3431
|
-
//#endregion
|
|
3432
|
-
//#region src/utils/pg-error-utils.ts
|
|
3433
|
-
/**
|
|
3434
|
-
* Shared PostgreSQL error extraction and user-friendly message formatting.
|
|
3435
|
-
*
|
|
3436
|
-
* Drizzle wraps native PG errors in a `.cause` chain. These utilities
|
|
3437
|
-
* unwrap that chain to get the real PostgreSQL error (identified by a
|
|
3438
|
-
* 5-character alphanumeric `code` such as `42P01`) and translate it into
|
|
3439
|
-
* a message that is safe and helpful to show to end-users.
|
|
3440
|
-
*/
|
|
3441
|
-
/**
|
|
3442
|
-
* Return the error when it is a deliberate 4xx, otherwise null.
|
|
3443
|
-
*
|
|
3444
|
-
* A thrown `ApiError` is a decision the server made about the request, not a
|
|
3445
|
-
* database failure: its message and code are already written for the client.
|
|
3446
|
-
*/
|
|
3447
|
-
function asClientFacingError(error) {
|
|
3448
|
-
if (!(error instanceof Error)) return null;
|
|
3449
|
-
const e = error;
|
|
3450
|
-
if (typeof e.statusCode !== "number" || e.statusCode < 400 || e.statusCode >= 500) return null;
|
|
3451
|
-
return e;
|
|
3452
|
-
}
|
|
3453
|
-
/**
|
|
3454
|
-
* Extract the underlying PostgreSQL error from a Drizzle wrapper.
|
|
3455
|
-
* Drizzle wraps PG errors in a `cause` property — this function
|
|
3456
|
-
* recursively walks the chain until it finds an object with a PG
|
|
3457
|
-
* error code (5-char alphanumeric, e.g. `42P01`).
|
|
3458
|
-
*/
|
|
3459
|
-
function extractPgError(error) {
|
|
3460
|
-
if (!error || typeof error !== "object") return null;
|
|
3461
|
-
if (!(error instanceof Error)) {
|
|
3462
|
-
if ("cause" in error && error.cause && typeof error.cause === "object") return extractPgError(error.cause);
|
|
3463
|
-
return null;
|
|
3464
|
-
}
|
|
3465
|
-
if ("code" in error && typeof error.code === "string" && /^[0-9A-Z]{5}$/.test(error.code)) return error;
|
|
3466
|
-
if (error.cause && typeof error.cause === "object") return extractPgError(error.cause);
|
|
3467
|
-
return null;
|
|
3468
|
-
}
|
|
3469
|
-
/**
|
|
3470
|
-
* Walk the error cause chain and return the deepest meaningful message.
|
|
3471
|
-
*/
|
|
3472
|
-
function extractCauseMessage(error) {
|
|
3473
|
-
if (!error || typeof error !== "object") return null;
|
|
3474
|
-
if (!(error instanceof Error)) return null;
|
|
3475
|
-
if (error.cause && typeof error.cause === "object") {
|
|
3476
|
-
const deeper = extractCauseMessage(error.cause);
|
|
3477
|
-
if (deeper) return deeper;
|
|
3478
|
-
if (error.cause instanceof Error && error.cause.message) return error.cause.message;
|
|
3479
|
-
}
|
|
3480
|
-
return null;
|
|
3481
|
-
}
|
|
3482
|
-
/**
|
|
3483
|
-
* Codes that mean "this connection will never work as configured".
|
|
3484
|
-
*
|
|
3485
|
-
* A wrong password or a database that does not exist is a settled fact about
|
|
3486
|
-
* the connection string, not a transient fault — retrying produces the same
|
|
3487
|
-
* answer forever.
|
|
3488
|
-
*/
|
|
3489
|
-
var UNRECOVERABLE_CONNECT_CODES = /* @__PURE__ */ new Set([
|
|
3490
|
-
"28P01",
|
|
3491
|
-
"28000",
|
|
3492
|
-
"3D000",
|
|
3493
|
-
"42501"
|
|
3494
|
-
]);
|
|
3495
|
-
/**
|
|
3496
|
-
* Describe a failed connection attempt in terms a developer can act on.
|
|
3497
|
-
*
|
|
3498
|
-
* The error a caller catches is Drizzle's wrapper: its message is
|
|
3499
|
-
* `Failed query: SELECT 1` and its stack runs through drizzle internals, while
|
|
3500
|
-
* the sentence that says what is actually wrong — "password authentication
|
|
3501
|
-
* failed for user …", "database … does not exist" — sits in `.cause`. Logging
|
|
3502
|
-
* the wrapper, as the bootstrapper used to, tells a developer with a typo in
|
|
3503
|
-
* their `DATABASE_URL` nothing at all.
|
|
3504
|
-
*/
|
|
3505
|
-
function classifyConnectFailure(error) {
|
|
3506
|
-
const pgError = extractPgError(error);
|
|
3507
|
-
const reason = pgError?.message ?? extractCauseMessage(error) ?? (error instanceof Error ? error.message : String(error));
|
|
3508
|
-
return {
|
|
3509
|
-
fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
|
|
3510
|
-
reason,
|
|
3511
|
-
code: pgError?.code
|
|
3512
|
-
};
|
|
3513
|
-
}
|
|
3514
|
-
/**
|
|
3515
|
-
* Detect whether an error is specifically a role-switching permission failure
|
|
3516
|
-
* (e.g. "permission denied to set role" or "must be member of role"),
|
|
3517
|
-
* as opposed to a table-level permission denial.
|
|
3518
|
-
*
|
|
3519
|
-
* This is used by the backend driver to auto-disable role switching when the
|
|
3520
|
-
* connection user lacks SET ROLE privileges, rather than surfacing a confusing
|
|
3521
|
-
* error to the Studio SQL Editor user.
|
|
3522
|
-
*/
|
|
3523
|
-
function isRoleSwitchingPermissionError(error) {
|
|
3524
|
-
const pgError = extractPgError(error);
|
|
3525
|
-
if (!pgError || pgError.code !== "42501") return false;
|
|
3526
|
-
const msg = pgError.message.toLowerCase();
|
|
3527
|
-
return msg.includes("set role") || msg.includes("member of role");
|
|
3528
|
-
}
|
|
3529
|
-
/**
|
|
3530
|
-
* Translate a raw PostgreSQL error into a user-friendly message.
|
|
3531
|
-
*
|
|
3532
|
-
* @param pgError - The extracted PostgreSQL error (from {@link extractPgError})
|
|
3533
|
-
* @param context - A human-readable context string (e.g. collection slug or path)
|
|
3534
|
-
* @returns An object with a `message` safe for the client and the PG `code`.
|
|
3535
|
-
*/
|
|
3536
|
-
function pgErrorToFriendlyMessage(pgError, context) {
|
|
3537
|
-
const detail = pgError.detail;
|
|
3538
|
-
const hint = pgError.hint;
|
|
3539
|
-
const constraint = pgError.constraint;
|
|
3540
|
-
const column = pgError.column;
|
|
3541
|
-
const table = pgError.table;
|
|
3542
|
-
const dataType = pgError.dataType;
|
|
3543
|
-
const pgMessage = pgError.message || "Unknown database error";
|
|
3544
|
-
const code = pgError.code || "UNKNOWN";
|
|
3545
|
-
const suffix = hint ? ` Hint: ${hint}` : "";
|
|
3546
|
-
const tableRef = table ?? context;
|
|
3547
|
-
switch (pgError.code) {
|
|
3548
|
-
case "23503": return {
|
|
3549
|
-
message: detail ? `Foreign key constraint violated: ${detail}${suffix}` : `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
3550
|
-
code
|
|
3551
|
-
};
|
|
3552
|
-
case "23505": return {
|
|
3553
|
-
message: detail ? `Duplicate value: ${detail}${suffix}` : `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
3554
|
-
code
|
|
3555
|
-
};
|
|
3556
|
-
case "23502": return {
|
|
3557
|
-
message: `Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`,
|
|
3558
|
-
code
|
|
3559
|
-
};
|
|
3560
|
-
case "23514": return {
|
|
3561
|
-
message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
3562
|
-
code
|
|
3563
|
-
};
|
|
3564
|
-
case "22P02": return {
|
|
3565
|
-
message: `Invalid data format in "${context}": ${pgMessage}${suffix}`,
|
|
3566
|
-
code
|
|
3567
|
-
};
|
|
3568
|
-
case "22001": return {
|
|
3569
|
-
message: `Value too long for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
|
|
3570
|
-
code
|
|
3571
|
-
};
|
|
3572
|
-
case "22003": return {
|
|
3573
|
-
message: `Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
|
|
3574
|
-
code
|
|
3575
|
-
};
|
|
3576
|
-
case "42703": return {
|
|
3577
|
-
message: `Unknown column in "${tableRef}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
3578
|
-
code
|
|
3579
|
-
};
|
|
3580
|
-
case "42P01": return {
|
|
3581
|
-
message: `Table not found for "${context}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
3582
|
-
code
|
|
3583
|
-
};
|
|
3584
|
-
case "42501": return {
|
|
3585
|
-
message: `Permission denied on "${tableRef}". Check your database credentials and RLS policies.${suffix}`,
|
|
3586
|
-
code
|
|
3587
|
-
};
|
|
3588
|
-
case "28000": return {
|
|
3589
|
-
message: `Authorization failed for "${context}". Check your database credentials.${suffix}`,
|
|
3590
|
-
code
|
|
3591
|
-
};
|
|
3592
|
-
default: {
|
|
3593
|
-
const parts = [`Database error in "${context}" [${code}]: ${pgMessage}`];
|
|
3594
|
-
if (detail) parts.push(`Detail: ${detail}`);
|
|
3595
|
-
if (column) parts.push(`Column: ${column}`);
|
|
3596
|
-
if (dataType) parts.push(`Data type: ${dataType}`);
|
|
3597
|
-
if (constraint) parts.push(`Constraint: ${constraint}`);
|
|
3598
|
-
if (hint) parts.push(`Hint: ${hint}`);
|
|
3599
|
-
return {
|
|
3600
|
-
message: parts.join(". "),
|
|
3601
|
-
code
|
|
3602
|
-
};
|
|
3743
|
+
*/
|
|
3744
|
+
async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
|
|
3745
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3746
|
+
const table = getTableForCollection(collection, this.registry);
|
|
3747
|
+
const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
|
|
3748
|
+
let vectorMeta;
|
|
3749
|
+
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
3750
|
+
let query = vectorMeta ? this.db.select({
|
|
3751
|
+
table_row: table,
|
|
3752
|
+
_distance: vectorMeta.distanceSelect
|
|
3753
|
+
}).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
3754
|
+
const allConditions = [];
|
|
3755
|
+
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
3756
|
+
if (options.searchString) {
|
|
3757
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3758
|
+
if (searchConditions.length === 0) return [];
|
|
3759
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3760
|
+
}
|
|
3761
|
+
if (options.filter) {
|
|
3762
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3763
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
3764
|
+
}
|
|
3765
|
+
if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
|
|
3766
|
+
if (allConditions.length > 0) {
|
|
3767
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
3768
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
3769
|
+
}
|
|
3770
|
+
const orderExpressions = [];
|
|
3771
|
+
if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
|
|
3772
|
+
else if (options.orderBy) {
|
|
3773
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
3774
|
+
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3603
3775
|
}
|
|
3776
|
+
orderExpressions.push(desc(idField));
|
|
3777
|
+
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
3778
|
+
const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
|
|
3779
|
+
if (limitValue) query = query.limit(limitValue);
|
|
3780
|
+
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
3781
|
+
const rawResults = await query;
|
|
3782
|
+
if (vectorMeta) return rawResults.map((r) => ({
|
|
3783
|
+
...r.table_row,
|
|
3784
|
+
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
3785
|
+
}));
|
|
3786
|
+
return rawResults;
|
|
3604
3787
|
}
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
*
|
|
3608
|
-
*
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
* @param error - The raw caught error
|
|
3615
|
-
* @param context - A human-readable context string (e.g. collection path)
|
|
3616
|
-
* @returns An object with `message` (user-friendly) and optional `code`
|
|
3617
|
-
* (the `ApiError` code, or the PG SQLSTATE).
|
|
3618
|
-
*/
|
|
3619
|
-
function sanitizeErrorForClient(error, context) {
|
|
3620
|
-
const clientError = asClientFacingError(error);
|
|
3621
|
-
if (clientError) {
|
|
3622
|
-
const line = `[API ${clientError.statusCode} ${clientError.code ?? "BAD_REQUEST"}] in "${context}": ${clientError.message}`;
|
|
3623
|
-
if (clientError.expected) logger.debug(line);
|
|
3624
|
-
else logger.warn(`⚠️ ${line}`);
|
|
3625
|
-
return {
|
|
3626
|
-
message: clientError.message,
|
|
3627
|
-
...clientError.code && { code: clientError.code }
|
|
3628
|
-
};
|
|
3788
|
+
/**
|
|
3789
|
+
* Check if the Drizzle instance has the relational query API available
|
|
3790
|
+
* for a given collection path.
|
|
3791
|
+
* Note: Primary path now uses inline `getQueryBuilder()` checks.
|
|
3792
|
+
*/
|
|
3793
|
+
hasDrizzleQueryAPI(collectionPath) {
|
|
3794
|
+
if (!this.getQueryBuilder("__probe__")) return false;
|
|
3795
|
+
const tableName = getTableName(getTableForCollection(getCollectionByPath(collectionPath, this.registry), this.registry));
|
|
3796
|
+
return !!this.getQueryBuilder(tableName);
|
|
3629
3797
|
}
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
table
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3798
|
+
/**
|
|
3799
|
+
* Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
|
|
3800
|
+
* for efficient JOIN-based relation loading.
|
|
3801
|
+
* Returns null if the API is not available or the query fails.
|
|
3802
|
+
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
3803
|
+
*/
|
|
3804
|
+
async fetchWithDrizzleQuery(collectionPath, collection, options, include, idInfo, idInfoArray) {
|
|
3805
|
+
try {
|
|
3806
|
+
const table = getTableForCollection(collection, this.registry);
|
|
3807
|
+
const tableName = getTableName(table);
|
|
3808
|
+
const queryTarget = this.getQueryBuilder(tableName);
|
|
3809
|
+
if (!queryTarget?.findMany) return null;
|
|
3810
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3811
|
+
const withConfig = {};
|
|
3812
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) if (include[0] === "*" || include.includes(key)) {
|
|
3813
|
+
const drizzleRelName = relation.relationName || key;
|
|
3814
|
+
withConfig[drizzleRelName] = true;
|
|
3815
|
+
}
|
|
3816
|
+
const queryOpts = { with: withConfig };
|
|
3817
|
+
if (options.limit) queryOpts.limit = options.limit;
|
|
3818
|
+
if (options.filter) {
|
|
3819
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3820
|
+
if (filterConditions.length > 0) queryOpts.where = and(...filterConditions);
|
|
3821
|
+
}
|
|
3822
|
+
if (options.orderBy) {
|
|
3823
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
3824
|
+
if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
3825
|
+
}
|
|
3826
|
+
return (await queryTarget.findMany(queryOpts)).map((row) => {
|
|
3827
|
+
const flat = {};
|
|
3828
|
+
for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
|
|
3829
|
+
const keys = Object.keys(item);
|
|
3830
|
+
const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
3831
|
+
if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
|
|
3832
|
+
return { ...item };
|
|
3833
|
+
});
|
|
3834
|
+
else if (typeof v === "object" && v !== null) flat[k] = { ...v };
|
|
3835
|
+
else flat[k] = v;
|
|
3836
|
+
return flat;
|
|
3837
|
+
});
|
|
3838
|
+
} catch (e) {
|
|
3839
|
+
logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
|
|
3840
|
+
return null;
|
|
3841
|
+
}
|
|
3644
3842
|
}
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3843
|
+
/**
|
|
3844
|
+
* Fallback path used when db.query is unavailable.
|
|
3845
|
+
* The primary path uses db.query.findMany with `with` config, which
|
|
3846
|
+
* loads all relations in a single query.
|
|
3847
|
+
*
|
|
3848
|
+
* Batch fetch many-to-many related rows for multiple parent IDs.
|
|
3849
|
+
* Groups results by parent ID to avoid N+1.
|
|
3850
|
+
*/
|
|
3851
|
+
async batchFetchManyRelatedRows(parentCollectionPath, parentIds, relationKey) {
|
|
3852
|
+
if (parentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
3853
|
+
const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
|
|
3854
|
+
if (!relation) {
|
|
3855
|
+
logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
|
|
3856
|
+
return /* @__PURE__ */ new Map();
|
|
3857
|
+
}
|
|
3858
|
+
const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
|
|
3859
|
+
const flatMap = /* @__PURE__ */ new Map();
|
|
3860
|
+
for (const [key, rows] of entityMap) flatMap.set(key, rows.map((e) => ({
|
|
3861
|
+
...e.values,
|
|
3862
|
+
id: e.id
|
|
3863
|
+
})));
|
|
3864
|
+
return flatMap;
|
|
3865
|
+
}
|
|
3866
|
+
};
|
|
3654
3867
|
//#endregion
|
|
3655
3868
|
//#region src/services/PersistService.ts
|
|
3656
3869
|
/**
|
|
@@ -3774,10 +3987,11 @@ var PersistService = class {
|
|
|
3774
3987
|
} else {
|
|
3775
3988
|
const targetColumnName = this.resolveParentForeignKeyColumn(hop);
|
|
3776
3989
|
if (targetColumnName) {
|
|
3777
|
-
const
|
|
3990
|
+
const parentKeyValue = hasForeignKeyOnTarget(hop.relation) ? await this.relationService.parentKeyValue(hop.parentCollection, hop.relation, hop.parentId) : parentIdForWrite();
|
|
3991
|
+
if (parentKeyValue === void 0) throw ApiError.badRequest(`Cannot create under "${collectionPath}": the parent row has no value in \`sourceKey\`, so the new row has nothing to point at.`);
|
|
3778
3992
|
const existingValue = effectiveValues[targetColumnName];
|
|
3779
|
-
if (existingValue !== void 0 && existingValue !== null && existingValue !==
|
|
3780
|
-
effectiveValues[targetColumnName] =
|
|
3993
|
+
if (existingValue !== void 0 && existingValue !== null && existingValue !== parentKeyValue) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent key '${parentKeyValue}'.`);
|
|
3994
|
+
effectiveValues[targetColumnName] = parentKeyValue;
|
|
3781
3995
|
}
|
|
3782
3996
|
}
|
|
3783
3997
|
}
|
|
@@ -5055,9 +5269,10 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5055
5269
|
async checkUniqueField(path, name, value, id, collection) {
|
|
5056
5270
|
return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
|
|
5057
5271
|
}
|
|
5058
|
-
async count({ path, collection, filter, searchString }) {
|
|
5272
|
+
async count({ path, collection, filter, logical, searchString }) {
|
|
5059
5273
|
return this.dataService.count(path, {
|
|
5060
5274
|
filter,
|
|
5275
|
+
logical,
|
|
5061
5276
|
searchString
|
|
5062
5277
|
});
|
|
5063
5278
|
}
|
|
@@ -5426,7 +5641,7 @@ var DatabasePoolManager = class {
|
|
|
5426
5641
|
const url = new URL(this.rootConnectionString);
|
|
5427
5642
|
url.pathname = `/${databaseName}`;
|
|
5428
5643
|
const pool = new Pool({
|
|
5429
|
-
connectionString: url.toString(),
|
|
5644
|
+
connectionString: pinSearchPath(url.toString()),
|
|
5430
5645
|
max: 10,
|
|
5431
5646
|
idleTimeoutMillis: 1e4,
|
|
5432
5647
|
allowExitOnIdle: true
|
|
@@ -5776,6 +5991,7 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
5776
5991
|
const isId = isIdProperty(propName, prop, collection);
|
|
5777
5992
|
let baseType = numProp.validation?.integer || isId ? `integer("${colName}")` : `numeric("${colName}")`;
|
|
5778
5993
|
if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
|
|
5994
|
+
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
|
|
5779
5995
|
else baseType = `${numProp.columnType}("${colName}")`;
|
|
5780
5996
|
if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
|
|
5781
5997
|
else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
|
|
@@ -6014,8 +6230,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6014
6230
|
const tableVarName = getTableVarName(tableName);
|
|
6015
6231
|
if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
|
|
6016
6232
|
const targetCollection = relation.target();
|
|
6017
|
-
const
|
|
6018
|
-
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
6233
|
+
const tableCreator = "pgTable";
|
|
6019
6234
|
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
6020
6235
|
const { sourceColumn, targetColumn } = relation.through;
|
|
6021
6236
|
const refOptions = `{ onDelete: \"${relation.onDelete ?? "cascade"}\" }`;
|
|
@@ -6129,8 +6344,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6129
6344
|
if (!emittedRelationNames.has(deduplicationKey)) {
|
|
6130
6345
|
const otherTableVar = getTableVarName(getTableName$1(otherCollection));
|
|
6131
6346
|
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
6347
|
+
const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
|
|
6132
6348
|
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
6133
|
-
tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${
|
|
6349
|
+
tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${referencedKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
6134
6350
|
emittedRelationNames.add(deduplicationKey);
|
|
6135
6351
|
}
|
|
6136
6352
|
}
|
|
@@ -7288,14 +7504,6 @@ function resolveChannelBusSetting(configured) {
|
|
|
7288
7504
|
return raw === "memory" ? { type: "memory" } : { type: "postgres" };
|
|
7289
7505
|
}
|
|
7290
7506
|
/**
|
|
7291
|
-
* @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
|
|
7292
|
-
* supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
|
|
7293
|
-
* config-only callers keep their exact types.
|
|
7294
|
-
*/
|
|
7295
|
-
function resolveChannelBusConfig(configured) {
|
|
7296
|
-
return resolveChannelBusSetting(configured);
|
|
7297
|
-
}
|
|
7298
|
-
/**
|
|
7299
7507
|
* Produce the bus a setting asks for.
|
|
7300
7508
|
*
|
|
7301
7509
|
* An instance is handed straight back — constructing it was the application's
|
|
@@ -7772,7 +7980,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7772
7980
|
if (this.driver) {
|
|
7773
7981
|
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
7774
7982
|
const activeAuth = authContext || {
|
|
7775
|
-
uid: "
|
|
7983
|
+
uid: "anonymous",
|
|
7776
7984
|
roles: ["anon"]
|
|
7777
7985
|
};
|
|
7778
7986
|
return await this.db.transaction(async (tx) => {
|
|
@@ -7901,7 +8109,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7901
8109
|
if (this.driver) {
|
|
7902
8110
|
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
7903
8111
|
const activeAuth = authContext || {
|
|
7904
|
-
uid: "
|
|
8112
|
+
uid: "anonymous",
|
|
7905
8113
|
roles: ["anon"]
|
|
7906
8114
|
};
|
|
7907
8115
|
return await this.db.transaction(async (tx) => {
|
|
@@ -8906,7 +9114,7 @@ function createBackupCron(config) {
|
|
|
8906
9114
|
enabled: config.enabled ?? true,
|
|
8907
9115
|
timeoutSeconds: 3600,
|
|
8908
9116
|
async handler({ log }) {
|
|
8909
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
9117
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-CD8o_1Sl.js").then((n) => n.r);
|
|
8910
9118
|
const { destination } = config;
|
|
8911
9119
|
if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
|
|
8912
9120
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -9037,6 +9245,11 @@ function findRelationDefects(collections, registry) {
|
|
|
9037
9245
|
problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
|
|
9038
9246
|
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
9039
9247
|
});
|
|
9248
|
+
if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) defects.push({
|
|
9249
|
+
...at,
|
|
9250
|
+
problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
|
|
9251
|
+
fix: targetColumns.has(relation.sourceKey) ? `it is a column on the *target* table \`${targetTableName}\` — \`sourceKey\` names the column on this collection that the target's foreign key points at, so it must be one of: ${quote(sourceColumns)}` : `add the column, or set \`sourceKey\` to one of: ${quote(sourceColumns)}`
|
|
9252
|
+
});
|
|
9040
9253
|
break;
|
|
9041
9254
|
case "manyToMany": {
|
|
9042
9255
|
const { table, sourceColumn, targetColumn } = relation.through;
|
|
@@ -9788,21 +10001,6 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9788
10001
|
}
|
|
9789
10002
|
//#endregion
|
|
9790
10003
|
//#region src/auth/services.ts
|
|
9791
|
-
function getColumnKey(table, ...keys) {
|
|
9792
|
-
if (!table) return void 0;
|
|
9793
|
-
for (const key of keys) {
|
|
9794
|
-
if (key in table) return key;
|
|
9795
|
-
const snake = toSnakeCase(key);
|
|
9796
|
-
if (snake in table) return snake;
|
|
9797
|
-
const camel = camelCase(key);
|
|
9798
|
-
if (camel in table) return camel;
|
|
9799
|
-
}
|
|
9800
|
-
}
|
|
9801
|
-
function getColumn(table, ...keys) {
|
|
9802
|
-
if (!table) return void 0;
|
|
9803
|
-
const key = getColumnKey(table, ...keys);
|
|
9804
|
-
return key ? table[key] : void 0;
|
|
9805
|
-
}
|
|
9806
10004
|
/**
|
|
9807
10005
|
* The single definition of what an email address looks like in storage.
|
|
9808
10006
|
*
|
|
@@ -9817,9 +10015,25 @@ function getColumn(table, ...keys) {
|
|
|
9817
10015
|
*
|
|
9818
10016
|
* Whitespace goes too: a trailing space survives the fold and reproduces the
|
|
9819
10017
|
* problem exactly.
|
|
10018
|
+
*
|
|
10019
|
+
* Re-exported rather than defined here: `@rebasepro/server` and
|
|
10020
|
+
* `@rebasepro/server-mongo` write this column too, and a second copy of this
|
|
10021
|
+
* rule is the defect it exists to prevent.
|
|
9820
10022
|
*/
|
|
9821
|
-
function
|
|
9822
|
-
|
|
10023
|
+
function getColumnKey(table, ...keys) {
|
|
10024
|
+
if (!table) return void 0;
|
|
10025
|
+
for (const key of keys) {
|
|
10026
|
+
if (key in table) return key;
|
|
10027
|
+
const snake = toSnakeCase(key);
|
|
10028
|
+
if (snake in table) return snake;
|
|
10029
|
+
const camel = camelCase(key);
|
|
10030
|
+
if (camel in table) return camel;
|
|
10031
|
+
}
|
|
10032
|
+
}
|
|
10033
|
+
function getColumn(table, ...keys) {
|
|
10034
|
+
if (!table) return void 0;
|
|
10035
|
+
const key = getColumnKey(table, ...keys);
|
|
10036
|
+
return key ? table[key] : void 0;
|
|
9823
10037
|
}
|
|
9824
10038
|
/**
|
|
9825
10039
|
* PostgreSQL implementation of UserRepository.
|
|
@@ -11160,6 +11374,31 @@ function humanize(snakeName) {
|
|
|
11160
11374
|
return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
11161
11375
|
}
|
|
11162
11376
|
//#endregion
|
|
11377
|
+
//#region src/schema/introspect-db-types.ts
|
|
11378
|
+
/**
|
|
11379
|
+
* The PostgreSQL type → Rebase property type mapping.
|
|
11380
|
+
*
|
|
11381
|
+
* Split out of `introspect-db-logic` so that the structural analysis can use it
|
|
11382
|
+
* without importing the generator, which imports the analysis. Re-exported from
|
|
11383
|
+
* `introspect-db-logic` so existing callers keep their import path.
|
|
11384
|
+
*/
|
|
11385
|
+
/**
|
|
11386
|
+
* Map a PostgreSQL data type to a Rebase property type.
|
|
11387
|
+
*/
|
|
11388
|
+
function mapPgType(dataType) {
|
|
11389
|
+
const dt = dataType.toLowerCase();
|
|
11390
|
+
if (dt === "interval") return "string";
|
|
11391
|
+
if (dt === "array" || dt.startsWith("_")) return "array";
|
|
11392
|
+
if (dt.includes("int") || dt.includes("numeric") || dt.includes("decimal") || dt.includes("serial") || dt === "real" || dt === "float4" || dt === "float8" || dt === "double precision" || dt === "money") return "number";
|
|
11393
|
+
if (dt.includes("bool")) return "boolean";
|
|
11394
|
+
if (dt.includes("time") || dt.includes("date")) return "date";
|
|
11395
|
+
if (dt === "json" || dt === "jsonb") return "map";
|
|
11396
|
+
if (dt === "bytea") return "binary";
|
|
11397
|
+
if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
|
|
11398
|
+
if (dt === "uuid") return "string";
|
|
11399
|
+
return "string";
|
|
11400
|
+
}
|
|
11401
|
+
//#endregion
|
|
11163
11402
|
//#region src/schema/introspect-db-logic.ts
|
|
11164
11403
|
var IRREGULAR_SINGULARS = {
|
|
11165
11404
|
people: "person",
|
|
@@ -11175,6 +11414,36 @@ var IRREGULAR_SINGULARS = {
|
|
|
11175
11414
|
criteria: "criterion",
|
|
11176
11415
|
phenomena: "phenomenon"
|
|
11177
11416
|
};
|
|
11417
|
+
/**
|
|
11418
|
+
* Plurals in "-ves" whose singular really ends in f/fe, and which of the two.
|
|
11419
|
+
*
|
|
11420
|
+
* A blanket "-ves" -> "-f" rule gets `knives` -> `knif`, and mangles every
|
|
11421
|
+
* ordinary "-ive" noun that happens to be plural along with it: `archives` ->
|
|
11422
|
+
* `archif`, `objectives` -> `objectif`. The set of English words that genuinely
|
|
11423
|
+
* swap f/fe for ves is small and closed, so it is listed rather than guessed —
|
|
11424
|
+
* anything else ending in "ves" drops the trailing 's' like any other plural.
|
|
11425
|
+
*
|
|
11426
|
+
* Matched on the whole word, not on the suffix: `olives` ends in `lives`.
|
|
11427
|
+
*/
|
|
11428
|
+
var VES_SINGULAR_ENDINGS = {
|
|
11429
|
+
calves: "f",
|
|
11430
|
+
dwarves: "f",
|
|
11431
|
+
elves: "f",
|
|
11432
|
+
halves: "f",
|
|
11433
|
+
hooves: "f",
|
|
11434
|
+
leaves: "f",
|
|
11435
|
+
loaves: "f",
|
|
11436
|
+
scarves: "f",
|
|
11437
|
+
selves: "f",
|
|
11438
|
+
sheaves: "f",
|
|
11439
|
+
shelves: "f",
|
|
11440
|
+
thieves: "f",
|
|
11441
|
+
wharves: "f",
|
|
11442
|
+
wolves: "f",
|
|
11443
|
+
knives: "fe",
|
|
11444
|
+
lives: "fe",
|
|
11445
|
+
wives: "fe"
|
|
11446
|
+
};
|
|
11178
11447
|
/** Words ending in 's' that are already singular. */
|
|
11179
11448
|
var UNCOUNTABLE = /* @__PURE__ */ new Set([
|
|
11180
11449
|
"status",
|
|
@@ -11213,7 +11482,7 @@ function singularize(word) {
|
|
|
11213
11482
|
if (UNCOUNTABLE.has(lower)) return word;
|
|
11214
11483
|
if (lower.endsWith("ices") && lower.length > 5) return word.slice(0, -4) + "ex";
|
|
11215
11484
|
if (lower.endsWith("ies") && lower.length > 3) return word.slice(0, -3) + "y";
|
|
11216
|
-
if (lower
|
|
11485
|
+
if (VES_SINGULAR_ENDINGS[lower]) return word.slice(0, -3) + VES_SINGULAR_ENDINGS[lower];
|
|
11217
11486
|
if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) return word.slice(0, -2);
|
|
11218
11487
|
if (lower.endsWith("ses") && !lower.endsWith("sses")) return word.slice(0, -1);
|
|
11219
11488
|
if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
|
|
@@ -11234,22 +11503,6 @@ function getIconForTable(tableName) {
|
|
|
11234
11503
|
if (table.includes("comment") || table.includes("review") || table.includes("feedback")) return "MessageCircle";
|
|
11235
11504
|
return "Database";
|
|
11236
11505
|
}
|
|
11237
|
-
/**
|
|
11238
|
-
* Map a PostgreSQL data type to a Rebase property type.
|
|
11239
|
-
*/
|
|
11240
|
-
function mapPgType(dataType) {
|
|
11241
|
-
const dt = dataType.toLowerCase();
|
|
11242
|
-
if (dt === "interval") return "string";
|
|
11243
|
-
if (dt === "array" || dt.startsWith("_")) return "array";
|
|
11244
|
-
if (dt.includes("int") || dt.includes("numeric") || dt.includes("decimal") || dt.includes("serial") || dt === "real" || dt === "float4" || dt === "float8" || dt === "double precision" || dt === "money") return "number";
|
|
11245
|
-
if (dt.includes("bool")) return "boolean";
|
|
11246
|
-
if (dt.includes("time") || dt.includes("date")) return "date";
|
|
11247
|
-
if (dt === "json" || dt === "jsonb") return "map";
|
|
11248
|
-
if (dt === "bytea") return "binary";
|
|
11249
|
-
if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
|
|
11250
|
-
if (dt === "uuid") return "string";
|
|
11251
|
-
return "string";
|
|
11252
|
-
}
|
|
11253
11506
|
function buildEnumMap(enumValues) {
|
|
11254
11507
|
const enumMap = /* @__PURE__ */ new Map();
|
|
11255
11508
|
for (const ev of enumValues) {
|
|
@@ -11269,6 +11522,20 @@ function buildTablesMap(tables, columns, pks, fks) {
|
|
|
11269
11522
|
});
|
|
11270
11523
|
return tablesMap;
|
|
11271
11524
|
}
|
|
11525
|
+
/**
|
|
11526
|
+
* Join tables, identified by column name.
|
|
11527
|
+
*
|
|
11528
|
+
* Superseded for the CLI by `classifyTables` in `./introspect-db-structure`,
|
|
11529
|
+
* which asks the database instead: two single-column keys, unique together, no
|
|
11530
|
+
* payload column, nothing referencing the table. This rule folds away
|
|
11531
|
+
* `northwind.order_details` — which has the key shape and carries unit price,
|
|
11532
|
+
* quantity and discount — because it recognises `id`, `created_at` and
|
|
11533
|
+
* `updated_at` by name and calls everything else a foreign key.
|
|
11534
|
+
*
|
|
11535
|
+
* Still used by `./introspect-runtime`, which builds collections in memory from
|
|
11536
|
+
* a narrower set of catalog queries and has no unique-constraint or row-count
|
|
11537
|
+
* data to reason with.
|
|
11538
|
+
*/
|
|
11272
11539
|
function identifyJoinTables(tablesMap) {
|
|
11273
11540
|
const joinTables = /* @__PURE__ */ new Set();
|
|
11274
11541
|
for (const [tableName, meta] of tablesMap.entries()) if (meta.fks.length === 2) {
|
|
@@ -11647,6 +11914,32 @@ function isEconnrefused(err) {
|
|
|
11647
11914
|
* Implements the `BackendBootstrapper` interface for PostgreSQL.
|
|
11648
11915
|
*/
|
|
11649
11916
|
/**
|
|
11917
|
+
* Which table name the boot-time drift check should look for, for one collection.
|
|
11918
|
+
*
|
|
11919
|
+
* A declared `table` IS the table name, not a hint to be second-guessed. This
|
|
11920
|
+
* used to ask the registry whether it had indexed the declared name and fall
|
|
11921
|
+
* back to the SLUG when it had not — but "the registry does not know this table"
|
|
11922
|
+
* is exactly the condition the drift check exists to report, so the fallback
|
|
11923
|
+
* fired precisely when it was most harmful.
|
|
11924
|
+
*
|
|
11925
|
+
* A collection with `slug: "usage-daily", table: "usage_daily"` was reported as
|
|
11926
|
+
* missing table `usage-daily`: a name that does not exist, should never exist,
|
|
11927
|
+
* and that nobody can find by looking. Worse, the remediation the caller prints
|
|
11928
|
+
* says to run `rebase db push` — which would then CREATE that invented table
|
|
11929
|
+
* beside the correct one, the same "second copy" hazard the misplaced-schema
|
|
11930
|
+
* branch further down exists to prevent. Seen in production, where a correctly
|
|
11931
|
+
* migrated database reported drift on every boot.
|
|
11932
|
+
*
|
|
11933
|
+
* The slug is used only when nothing was declared, which is the config shape
|
|
11934
|
+
* where the slug genuinely is the table name.
|
|
11935
|
+
*
|
|
11936
|
+
* Exported for its own test: the caller needs a live pool and a real database,
|
|
11937
|
+
* and this is the part that was wrong.
|
|
11938
|
+
*/
|
|
11939
|
+
function resolveDriftCheckName(col, registeredTableNames) {
|
|
11940
|
+
return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
|
|
11941
|
+
}
|
|
11942
|
+
/**
|
|
11650
11943
|
* Default PostgreSQL bootstrapper.
|
|
11651
11944
|
*
|
|
11652
11945
|
* Use it to register Postgres with `initializeRebaseBackend()`:
|
|
@@ -11735,7 +12028,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
11735
12028
|
let readDb;
|
|
11736
12029
|
const readUrl = process.env.DATABASE_READ_URL;
|
|
11737
12030
|
if (readUrl && readUrl !== pgConfig.connectionString) try {
|
|
11738
|
-
const { createReadReplicaConnection } = await import("./connection-
|
|
12031
|
+
const { createReadReplicaConnection } = await import("./connection-BuZ97wsr.js").then((n) => n.t);
|
|
11739
12032
|
readDb = createReadReplicaConnection(readUrl, mergedSchema).db;
|
|
11740
12033
|
logger.info("📖 [PostgresBootstrapper] Read replica connection established");
|
|
11741
12034
|
} catch (err) {
|
|
@@ -11837,39 +12130,59 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
11837
12130
|
try {
|
|
11838
12131
|
const registeredCollections = registry.getCollections();
|
|
11839
12132
|
if (registeredCollections.length > 0) {
|
|
11840
|
-
const schemasList = Array.from(new Set(registeredCollections.map((c) => "schema" in c && c.schema ? c.schema : "public"))).map((s) => `'${s}'`).join(",");
|
|
11841
12133
|
const result = await schemaAwareDb.execute(sql.raw(`
|
|
11842
12134
|
SELECT table_name, table_schema
|
|
11843
12135
|
FROM information_schema.tables
|
|
11844
|
-
WHERE table_schema IN (
|
|
12136
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
11845
12137
|
AND table_type = 'BASE TABLE'
|
|
11846
12138
|
`));
|
|
12139
|
+
const tablesByName = /* @__PURE__ */ new Map();
|
|
12140
|
+
for (const row of result.rows) {
|
|
12141
|
+
const schemas = tablesByName.get(row.table_name) ?? [];
|
|
12142
|
+
schemas.push(row.table_schema);
|
|
12143
|
+
tablesByName.set(row.table_name, schemas);
|
|
12144
|
+
}
|
|
11847
12145
|
const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
|
|
11848
12146
|
const missing = [];
|
|
11849
12147
|
for (const col of registeredCollections) {
|
|
11850
12148
|
if (col.auth?.enabled) continue;
|
|
11851
12149
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
11852
|
-
const
|
|
11853
|
-
const tableName = registry.hasTableForCollection(declaredTable ?? col.slug) ? declaredTable ?? col.slug : col.slug;
|
|
11854
|
-
const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
|
|
12150
|
+
const checkName = resolveDriftCheckName(col, registry.getTableNames());
|
|
11855
12151
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
11856
12152
|
if (!dbTables.has(fullCheckName)) missing.push({
|
|
11857
12153
|
slug: col.slug,
|
|
11858
|
-
table: fullCheckName
|
|
12154
|
+
table: fullCheckName,
|
|
12155
|
+
foundIn: (tablesByName.get(checkName) ?? []).filter((s) => s !== schemaName)
|
|
11859
12156
|
});
|
|
11860
12157
|
}
|
|
11861
12158
|
if (missing.length > 0) {
|
|
11862
|
-
const lines = missing.map((m) => ` • collection "${m.slug}" → table "${m.table}"`);
|
|
12159
|
+
const lines = missing.map((m) => ` • collection "${m.slug}" → table "${m.table}"` + (m.foundIn.length > 0 ? ` — but a table of that name exists in ${m.foundIn.map((s) => `"${s}"`).join(", ")}` : ""));
|
|
12160
|
+
const misplaced = missing.filter((m) => m.foundIn.length > 0);
|
|
12161
|
+
const misplacedHelp = misplaced.length === 0 ? [] : [
|
|
12162
|
+
" Those tables exist — in the wrong schema. Unqualified SQL run by a",
|
|
12163
|
+
" role whose name matches a schema (`rebase` is both, by default)",
|
|
12164
|
+
" resolves through search_path's \"$user\" and creates there. Move them:",
|
|
12165
|
+
...misplaced.map((m) => ` ALTER TABLE "${m.foundIn[0]}"."${m.table.split(".").pop()}" SET SCHEMA "${m.table.includes(".") ? m.table.split(".")[0] : "public"}";`),
|
|
12166
|
+
" and qualify the SQL that created them, or pin search_path in DATABASE_URL",
|
|
12167
|
+
" (`?options=-c%20search_path%3Dpublic`).",
|
|
12168
|
+
""
|
|
12169
|
+
];
|
|
11863
12170
|
logger.warn([
|
|
11864
12171
|
"",
|
|
11865
12172
|
"⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
|
|
11866
12173
|
...lines,
|
|
11867
12174
|
"",
|
|
12175
|
+
...misplacedHelp,
|
|
11868
12176
|
" This runtime applies the collection schema at boot unless",
|
|
11869
12177
|
" REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
|
|
11870
12178
|
" log lines above — this drift means that step was off, skipped, or failed.",
|
|
11871
|
-
" • Managed cloud: redeploy
|
|
11872
|
-
"
|
|
12179
|
+
" • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
|
|
12180
|
+
" \"ensure\"; the runtime applies the schema to the tenant DB.",
|
|
12181
|
+
" If the log above says the driver does not implement collection-table",
|
|
12182
|
+
" creation, THIS driver is too old to do it. A driver is installed from",
|
|
12183
|
+
" your bundle's dependencies, not supplied by the platform image, so a",
|
|
12184
|
+
" newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
|
|
12185
|
+
" in your project's package.json and redeploy.",
|
|
11873
12186
|
" • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
|
|
11874
12187
|
" against DATABASE_URL.",
|
|
11875
12188
|
""
|
|
@@ -11958,7 +12271,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
11958
12271
|
*/
|
|
11959
12272
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
11960
12273
|
const internals = driverResult.internals;
|
|
11961
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
12274
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-CBQdOETu.js");
|
|
11962
12275
|
const plan = await ensureCollectionTables({ async query(text) {
|
|
11963
12276
|
const result = await internals.db.execute(sql.raw(text));
|
|
11964
12277
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
@@ -11983,7 +12296,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
11983
12296
|
*/
|
|
11984
12297
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
11985
12298
|
const internals = driverResult.internals;
|
|
11986
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
12299
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-ViG8XiPn.js");
|
|
11987
12300
|
const outcome = await ensureCollectionPolicies({ async query(text) {
|
|
11988
12301
|
const result = await internals.db.execute(sql.raw(text));
|
|
11989
12302
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
@@ -11997,7 +12310,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
11997
12310
|
},
|
|
11998
12311
|
mountRoutes(app, basePath, driverResult) {},
|
|
11999
12312
|
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
12000
|
-
const { createPostgresWebSocket } = await import("./websocket-
|
|
12313
|
+
const { createPostgresWebSocket } = await import("./websocket-B2LsrINK.js").then((n) => n.n);
|
|
12001
12314
|
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
12002
12315
|
}
|
|
12003
12316
|
};
|
|
@@ -12023,8 +12336,8 @@ function createPostgresAdapter(pgConfig) {
|
|
|
12023
12336
|
async initializeHistory(config, driverResult) {
|
|
12024
12337
|
if (bootstrapper.initializeHistory) return bootstrapper.initializeHistory(config, driverResult);
|
|
12025
12338
|
},
|
|
12026
|
-
initializeWebsockets(server, realtimeService, driver, config) {
|
|
12027
|
-
if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config);
|
|
12339
|
+
initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
12340
|
+
if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config, adapter);
|
|
12028
12341
|
},
|
|
12029
12342
|
ensureCollectionSchema: bootstrapper.ensureCollectionSchema ? (collections, driverResult, log) => bootstrapper.ensureCollectionSchema(collections, driverResult, log) : void 0,
|
|
12030
12343
|
ensureCollectionPolicies: bootstrapper.ensureCollectionPolicies ? (collections, driverResult, log) => bootstrapper.ensureCollectionPolicies(collections, driverResult, log) : void 0,
|
|
@@ -12037,6 +12350,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
12037
12350
|
};
|
|
12038
12351
|
}
|
|
12039
12352
|
//#endregion
|
|
12040
|
-
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, frameByteLength, generateSchema, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations,
|
|
12353
|
+
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, diagnoseRowSecurityDumpFailure, ensureDatabaseExists, frameByteLength, generateSchema, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
12041
12354
|
|
|
12042
12355
|
//# sourceMappingURL=index.es.js.map
|