@rebasepro/server-postgres 0.12.1-canary.g7f1150f → 0.12.1-canary.g802486a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +6 -1
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/{backup-service-DH9kPg-E.js → backup-service-CD8o_1Sl.js} +144 -11
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/{connection-B5Wndbr1.js → connection-BuZ97wsr.js} +57 -3
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/{ensure-collection-policies-t875gSJF.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
  13. package/dist/{ensure-collection-policies-t875gSJF.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
  14. package/dist/{ensure-collection-tables-DmOzh_G6.js → ensure-collection-tables-CBQdOETu.js} +73 -12
  15. package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
  16. package/dist/index.es.js +1048 -691
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/ensure-collection-tables.d.ts +21 -1
  21. package/dist/schema/generate-postgres-ddl-logic.d.ts +19 -0
  22. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  23. package/dist/schema/introspect-db-logic.d.ts +94 -5
  24. package/dist/schema/introspect-db-queries.d.ts +119 -0
  25. package/dist/schema/introspect-db-structure.d.ts +263 -0
  26. package/dist/schema/introspect-db-types.d.ts +11 -0
  27. package/dist/services/FetchService.d.ts +4 -1
  28. package/dist/services/RelationService.d.ts +24 -1
  29. package/dist/services/channel-bus/index.d.ts +1 -7
  30. package/dist/services/collection-helpers.d.ts +36 -2
  31. package/dist/services/dataService.d.ts +3 -1
  32. package/dist/services/row-pipeline.d.ts +1 -1
  33. package/dist/{src-DihrDFuP.js → src-DlPBctw_.js} +217 -101
  34. package/dist/src-DlPBctw_.js.map +1 -0
  35. package/dist/utils/connection-string.d.ts +29 -0
  36. package/dist/utils/drizzle-conditions.d.ts +5 -4
  37. package/dist/utils/pg-error-utils.d.ts +16 -0
  38. package/dist/{websocket-BKcGvILX.js → websocket-B2LsrINK.js} +8 -6
  39. package/dist/websocket-B2LsrINK.js.map +1 -0
  40. package/package.json +6 -6
  41. package/src/PostgresAdapter.ts +7 -2
  42. package/src/PostgresBackendDriver.ts +4 -0
  43. package/src/PostgresBootstrapper.ts +83 -22
  44. package/src/auth/services.ts +6 -3
  45. package/src/backup/backup-cli.ts +41 -2
  46. package/src/backup/backup-service.ts +38 -5
  47. package/src/backup/pg-tools.ts +96 -3
  48. package/src/cli-helpers.ts +70 -0
  49. package/src/cli.ts +44 -26
  50. package/src/collections/validate-relations.ts +15 -0
  51. package/src/connection.ts +73 -0
  52. package/src/data-transformer.ts +9 -3
  53. package/src/databasePoolManager.ts +5 -2
  54. package/src/schema/ensure-collection-tables.ts +86 -11
  55. package/src/schema/generate-drizzle-schema-logic.ts +35 -3
  56. package/src/schema/generate-postgres-ddl-logic.ts +50 -6
  57. package/src/schema/introspect-db-constraints.ts +385 -0
  58. package/src/schema/introspect-db-inference.ts +18 -8
  59. package/src/schema/introspect-db-logic.ts +385 -71
  60. package/src/schema/introspect-db-queries.ts +326 -0
  61. package/src/schema/introspect-db-structure.ts +670 -0
  62. package/src/schema/introspect-db-types.ts +56 -0
  63. package/src/schema/introspect-db.ts +37 -80
  64. package/src/security/policy-drift.test.ts +11 -3
  65. package/src/services/BranchService.ts +66 -28
  66. package/src/services/FetchService.ts +90 -14
  67. package/src/services/PersistService.ts +20 -6
  68. package/src/services/RelationService.ts +249 -48
  69. package/src/services/channel-bus/index.ts +0 -9
  70. package/src/services/collection-helpers.ts +69 -3
  71. package/src/services/dataService.ts +3 -1
  72. package/src/services/realtimeService.ts +3 -3
  73. package/src/services/row-pipeline.ts +1 -1
  74. package/src/utils/connection-string.ts +58 -0
  75. package/src/utils/drizzle-conditions.ts +31 -6
  76. package/src/utils/pg-error-utils.ts +19 -0
  77. package/src/websocket.ts +18 -9
  78. package/dist/backup-service-DH9kPg-E.js.map +0 -1
  79. package/dist/connection-B5Wndbr1.js.map +0 -1
  80. package/dist/ensure-collection-tables-DmOzh_G6.js.map +0 -1
  81. package/dist/src-DihrDFuP.js.map +0 -1
  82. 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-B5Wndbr1.js";
5
- import { C as updateDateAutoValues, D as camelCase, E as mergeDeep, M as hasForeignKeyOnTarget, N as isManyToMany, O as toSnakeCase, P as Vector, S as normalizeToEntityRelation, T as getPolicyNamesForRule, _ as buildCompositeId, a as getJunctionSecurityRules, b as createRelationRef, c as policyToPostgres, d as findRelation, f as getColumnName, g as resolveCollectionRelations, h as getTableVarName, i as getJunctionCollectionConfig, j as ANONYMOUS_USER_ID, 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 generateForeignKeyName, x as createRelationRefWithData, y as parseIdValues } from "./src-DihrDFuP.js";
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 createPostgresWebSocket } from "./websocket-BKcGvILX.js";
8
- import { A as serverVersionNumToMajor, C as globalsFileForDump, D as parseDbNameFromUrl, E as parseBackupTimestamp, M as withDatabaseName, O as parsePgToolMajor, S as checkToolServerCompatibility, T as parseBackupDestination, _ 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 splitGlobalsStatements, k as resolveConnectionString, 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 joinStorageKey, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-DH9kPg-E.js";
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) return {
545
- kind: "relation",
546
- relation,
547
- registry,
548
- sourceIdColumn
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 currentId = buildCompositeId(data, getPrimaryKeys(collection, registry));
1349
- if (targetTable && currentId) {
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);
@@ -1614,6 +1705,33 @@ function normalizeDbValues(data, collection) {
1614
1705
  //#endregion
1615
1706
  //#region src/services/RelationService.ts
1616
1707
  /**
1708
+ * The ids in a to-many relation write, whatever shape the caller sent.
1709
+ *
1710
+ * A membership list is written as either the related rows (`[{ id: 1 }]`, what
1711
+ * the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
1712
+ * what anyone writing the API by hand sends). Only the first was read, via a
1713
+ * blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
1714
+ * on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
1715
+ * and on a string-keyed one it did not surface at all — `String(undefined)`
1716
+ * wrote a junction row pointing at the literal `"undefined"`, which no read
1717
+ * would ever match. Both shapes are accepted here, in one place, because both
1718
+ * call sites had the same assumption.
1719
+ *
1720
+ * An element that carries no key is refused rather than skipped: dropping it
1721
+ * would silently write a shorter membership list than the caller asked for.
1722
+ */
1723
+ function relationTargetIds(value, relationName, collectionSlug) {
1724
+ if (!Array.isArray(value)) return [];
1725
+ return value.map((element, index) => {
1726
+ if (typeof element === "string" || typeof element === "number") return element;
1727
+ if (element && typeof element === "object") {
1728
+ const id = element.id;
1729
+ if (typeof id === "string" || typeof id === "number") return id;
1730
+ }
1731
+ throw new Error(`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. Pass either the related rows (\`[{ id: … }]\`) or their keys (\`[1, 2]\`), not ${element === null ? "null" : typeof element}.`);
1732
+ });
1733
+ }
1734
+ /**
1617
1735
  * Typed wrapper for Drizzle dynamic query innerJoin.
1618
1736
  * Drizzle's `$dynamic()` queries lose the `innerJoin` method from
1619
1737
  * their static type, but it exists at runtime. This helper bridges
@@ -1691,8 +1809,67 @@ var RelationService = class {
1691
1809
  * reading. Left alone it would silently match on the first key column and
1692
1810
  * hand a tenant's rows to its neighbour — say so instead.
1693
1811
  */
1694
- assertSingleKeyAddressable(parentCollection, parentPks, via) {
1695
- 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 — express this relation with \`joinPath\`, whose \`on.from\`/\`on.to\` take every key column.`);
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
+ };
1696
1873
  }
1697
1874
  /**
1698
1875
  * Fetch rows related to a parent row through a specific relation
@@ -1751,6 +1928,8 @@ var RelationService = class {
1751
1928
  }
1752
1929
  return rows;
1753
1930
  }
1931
+ const matchValue = hasForeignKeyOnTarget(relation) ? (await this.resolveSourceKeys(parentCollection, relation, [parentId])).keyByParentId.get(String(parentId)) : parsedParentId;
1932
+ if (matchValue === void 0) return [];
1754
1933
  let query = this.db.select().from(targetTable).$dynamic();
1755
1934
  const additionalFilters = [];
1756
1935
  if (options.searchString) {
@@ -1759,7 +1938,7 @@ var RelationService = class {
1759
1938
  const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
1760
1939
  if (searchCombined) additionalFilters.push(searchCombined);
1761
1940
  }
1762
- query = applyDynamicRelationQuery(query, query, relation, parsedParentId, targetTable, parentTable, parentIdCol, idField, this.registry, additionalFilters);
1941
+ query = applyDynamicRelationQuery(query, query, relation, matchValue, targetTable, parentTable, parentIdCol, idField, this.registry, additionalFilters);
1763
1942
  if (options.limit) query = query.limit(options.limit);
1764
1943
  const results = await query;
1765
1944
  if (!results || !Array.isArray(results)) return [];
@@ -1802,8 +1981,10 @@ var RelationService = class {
1802
1981
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
1803
1982
  if (!parentTable) throw new Error("Parent table not found");
1804
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;
1805
1986
  let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
1806
- query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, parsedParentId, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
1987
+ query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, matchValue, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
1807
1988
  const result = await query;
1808
1989
  return Number(result[0]?.count || 0);
1809
1990
  }
@@ -1930,17 +2111,24 @@ var RelationService = class {
1930
2111
  }
1931
2112
  return resultMap;
1932
2113
  }
1933
- 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();
1934
2121
  let query = this.db.select().from(targetTable).$dynamic();
1935
- query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
2122
+ query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1936
2123
  const results = await query;
1937
2124
  const resultMap = /* @__PURE__ */ new Map();
1938
- const parentIdSet = new Set(parsedParentIds.map(String));
1939
2125
  for (const row of results) {
1940
2126
  const targetRow = row[getTableName$1(targetCollection)] || row;
1941
- let parentId;
1942
- if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
1943
- if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
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));
1944
2132
  }
1945
2133
  return resultMap;
1946
2134
  }
@@ -2018,17 +2206,24 @@ var RelationService = class {
2018
2206
  }
2019
2207
  return resultMap;
2020
2208
  }
2021
- 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();
2022
2216
  let query = this.db.select().from(targetTable).$dynamic();
2023
- query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
2217
+ query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
2024
2218
  const results = await query;
2025
2219
  const resultMap = /* @__PURE__ */ new Map();
2026
- const parentIdSet = new Set(parsedParentIds.map(String));
2027
2220
  for (const row of results) {
2028
2221
  const targetRow = row[getTableName$1(targetCollection)] || row;
2029
- let parentId;
2030
- if (hasForeignKeyOnTarget(relation)) parentId = targetRow[relation.foreignKeyOnTarget];
2031
- if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
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) {
2032
2227
  const key = String(parentId);
2033
2228
  const arr = resultMap.get(key) || [];
2034
2229
  arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
@@ -2045,7 +2240,7 @@ var RelationService = class {
2045
2240
  for (const [key, value] of Object.entries(relationValues)) {
2046
2241
  const relation = findRelation(resolvedRelations, key);
2047
2242
  if (!relation || relation.cardinality !== "many") continue;
2048
- const targetEntityIds = value && Array.isArray(value) ? value.map((rel) => rel.id) : [];
2243
+ const targetEntityIds = relationTargetIds(value, key, collection.slug);
2049
2244
  const targetCollection = relation.target();
2050
2245
  if (relation.kind === "via") {
2051
2246
  const parentTableName = getTableName$1(collection);
@@ -2127,14 +2322,13 @@ var RelationService = class {
2127
2322
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
2128
2323
  continue;
2129
2324
  }
2130
- const parentPks = requirePrimaryKeys(collection, this.registry);
2131
- const parentIdInfo = parentPks[0];
2132
- 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.`);
2133
2327
  if (targetEntityIds.length > 0) {
2134
2328
  const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
2135
- await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(and(eq(fkCol, parsedParentId), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
2136
- await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: parsedParentId }).where(inArray(targetIdCol, parsedTargetIds));
2137
- } else await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(fkCol, parsedParentId));
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));
2138
2332
  } else logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
2139
2333
  }
2140
2334
  }
@@ -2149,8 +2343,7 @@ var RelationService = class {
2149
2343
  const targetTable = getTableForCollection(targetCollection, this.registry);
2150
2344
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2151
2345
  const targetIdInfo = targetPks[0];
2152
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
2153
- const sourceIdInfo = sourcePks[0];
2346
+ requirePrimaryKeys(sourceCollection, this.registry)[0];
2154
2347
  if (relation.kind === "via") {
2155
2348
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
2156
2349
  continue;
@@ -2172,13 +2365,14 @@ var RelationService = class {
2172
2365
  logger.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
2173
2366
  continue;
2174
2367
  }
2175
- const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
2176
- if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, parsedSourceId));
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));
2177
2371
  else {
2178
2372
  const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
2179
2373
  const targetIdField = targetTable[targetIdInfo.fieldName];
2180
- await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, parsedSourceId));
2181
- await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: parsedSourceId }).where(eq(targetIdField, parsedNewTargetId));
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));
2182
2376
  }
2183
2377
  } catch (e) {
2184
2378
  logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
@@ -2225,7 +2419,7 @@ var RelationService = class {
2225
2419
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
2226
2420
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2227
2421
  const targetIdInfo = targetPks[0];
2228
- const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2422
+ const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2229
2423
  [sourceJunctionColumn.name]: parsedSourceId,
2230
2424
  [targetJunctionColumn.name]: targetId
2231
2425
  }));
@@ -2269,7 +2463,7 @@ var RelationService = class {
2269
2463
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
2270
2464
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2271
2465
  const targetIdInfo = targetPks[0];
2272
- const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2466
+ const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2273
2467
  [sourceJunctionColumn.name]: parsedSourceId,
2274
2468
  [targetJunctionColumn.name]: targetId
2275
2469
  }));
@@ -2484,7 +2678,7 @@ function relationTargetAddress(targetRow, targetCollection, registry) {
2484
2678
  * expects real types. The row's own address is *not* among the columns — it is
2485
2679
  * derived by the consumer from the collection's primary keys.
2486
2680
  */
2487
- function toCmsRow(row, collection, registry) {
2681
+ function toFlatRow(row, collection, registry) {
2488
2682
  const resolvedRelations = resolveCollectionRelations(collection);
2489
2683
  const normalized = normalizeDbValues(row, collection);
2490
2684
  for (const [key, relation] of Object.entries(resolvedRelations)) {
@@ -2599,235 +2793,494 @@ function assertWritableThrough(hop, path) {
2599
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");
2600
2794
  }
2601
2795
  //#endregion
2602
- //#region src/services/FetchService.ts
2796
+ //#region src/utils/pg-error-utils.ts
2603
2797
  /**
2604
- * Service for handling all row read operations.
2605
- * Handles fetching, searching, counting, and filtering rows.
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.
2606
2804
  */
2607
- var FetchService = class {
2608
- db;
2609
- registry;
2610
- relationService;
2611
- constructor(db, registry) {
2612
- this.db = db;
2613
- this.registry = registry;
2614
- this.relationService = new RelationService(db, registry);
2615
- }
2616
- /**
2617
- * Get the relational query builder for a given table name.
2618
- * Safely narrows the DrizzleClient union type to access db.query[tableName].
2619
- */
2620
- getQueryBuilder(tableName) {
2621
- return this.db.query?.[tableName];
2622
- }
2623
- /**
2624
- * The context the condition builder needs to compile a filter key that is
2625
- * not a column name outright.
2626
- *
2627
- * Two such keys. An owning relation's key resolves through the collection's
2628
- * relations to its foreign-key column; a relation whose link lives on the
2629
- * target table or in a junction resolves to a correlated `EXISTS`, which
2630
- * needs the registry to reach that other table and this table's key column
2631
- * to correlate back.
2632
- *
2633
- * Looked up rather than passed: every read path already has the path, only
2634
- * some have the collection, and a path that names no registered collection
2635
- * (a nested/derived one) is not an error here — the builder simply falls
2636
- * back to guessing the default key shapes, and a relation filter it cannot
2637
- * compile stays unresolvable and so fails closed.
2638
- */
2639
- filterContext(collectionPath, table) {
2640
- const collection = this.registry.getCollectionByPath(collectionPath) ?? void 0;
2641
- return {
2642
- collection,
2643
- registry: this.registry,
2644
- sourceIdColumn: collection ? this.resolveIdColumn(collection, table) : void 0
2645
- };
2646
- }
2647
- /**
2648
- * The table column this collection's rows are keyed by, or `undefined`.
2649
- *
2650
- * `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
2651
- * resolvable key is not an error on the filter path — it only means the
2652
- * relation filters that would correlate on it cannot be compiled, which
2653
- * the builder already handles by failing that field closed.
2654
- */
2655
- resolveIdColumn(collection, table) {
2656
- const [idInfo] = getPrimaryKeys(collection, this.registry);
2657
- if (!idInfo) return void 0;
2658
- return table[idInfo.fieldName];
2659
- }
2660
- /**
2661
- * Build filter conditions from FilterValues
2662
- * Delegates to DrizzleConditionBuilder.buildFilterConditions
2663
- */
2664
- buildFilterConditions(filter, table, collectionPath) {
2665
- return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath, this.filterContext(collectionPath, table));
2666
- }
2667
- /**
2668
- * Resolves the correct Drizzle column for sorting.
2669
- * Automatically maps owning relation property keys to their underlying foreign key column.
2670
- *
2671
- * The relation's own `localKey` is the authority for that foreign key, not
2672
- * `<field>_id`. The default local key comes from `generateForeignKeyName`,
2673
- * which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
2674
- * `users` → `user_id` — and an author can override it outright. A wrong
2675
- * guess resolves to nothing, the caller drops the `ORDER BY`, and the rows
2676
- * come back in whatever order Postgres pleases: paging over that repeats
2677
- * and skips rows rather than erroring. The guesses stay, last, for a
2678
- * caller that hands over no collection to resolve against.
2679
- */
2680
- resolveOrderByField(table, orderBy, collection) {
2681
- const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
2682
- const direct = columnAt(orderBy);
2683
- if (direct) return direct;
2684
- if (collection) {
2685
- const relation = resolveCollectionRelations(collection)[orderBy];
2686
- if (relation?.kind === "belongsTo") {
2687
- const foreignKey = columnAt(relation.localKey);
2688
- if (foreignKey) return foreignKey;
2689
- }
2690
- }
2691
- for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
2692
- const foreignKey = columnAt(guess);
2693
- if (foreignKey) return foreignKey;
2694
- }
2695
- }
2696
- /**
2697
- * Build the `with` config for Drizzle's relational query API.
2698
- * Converts collection relations to a Drizzle-compatible `with` object.
2699
- *
2700
- * When `include` is provided, only those relations are loaded.
2701
- * When `include` is absent, ALL relations are loaded (the admin path).
2702
- *
2703
- * Automatically detects many-to-many junction tables and nests
2704
- * the target relation so actual row data is returned.
2705
- */
2706
- buildWithConfig(collection, include) {
2707
- const resolvedRelations = resolveCollectionRelations(collection);
2708
- const withConfig = {};
2709
- const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
2710
- for (const [key, relation] of Object.entries(resolvedRelations)) {
2711
- if (!shouldInclude(key)) continue;
2712
- const drizzleRelName = relation.relationName || key;
2713
- if (relation.kind === "via") continue;
2714
- if (relation.cardinality === "many" && isJunctionRelation(relation)) {
2715
- const targetFkName = this.getJunctionTargetRelationName(relation, collection);
2716
- if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
2717
- else withConfig[drizzleRelName] = true;
2718
- } else withConfig[drizzleRelName] = true;
2719
- }
2720
- return withConfig;
2721
- }
2722
- /**
2723
- * Get the Drizzle relation name on the junction table that points to the actual target row.
2724
- * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
2725
- */
2726
- getJunctionTargetRelationName(relation, _collection) {
2727
- 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);
2728
2827
  return null;
2729
2828
  }
2730
- /**
2731
- * Post-fetch joinPath relations for a single flat row.
2732
- * joinPath relations cannot be expressed via Drizzle's `with` config,
2733
- * so they must be loaded separately after the primary query.
2734
- */
2735
- async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
2736
- const resolvedRelations = resolveCollectionRelations(collection);
2737
- const promises = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via").map(async ([key, relation]) => {
2738
- try {
2739
- const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
2740
- if (relation.cardinality === "one" && relatedRows.length > 0) {
2741
- const e = relatedRows[0];
2742
- row[key] = createRelationRefWithData(e.id, e.path, e);
2743
- } else if (relation.cardinality === "many") row[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
2744
- } catch (e) {
2745
- logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
2746
- }
2747
- });
2748
- await Promise.all(promises);
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;
2749
2861
  }
2750
- /**
2751
- * Resolves joinPath relations for raw REST rows and directly injects them.
2752
- * Uses RelationService to query the database and maps results back to the flattened objects.
2753
- */
2754
- async resolveJoinPathRelationsBatchRest(rows, collection, collectionPath, idInfoArray, include) {
2755
- if (rows.length === 0) return;
2756
- const resolvedRelations = resolveCollectionRelations(collection);
2757
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
2758
- const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
2759
- const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
2760
- if (joinPathRelations.length === 0) return;
2761
- const parentIdOf = (row) => {
2762
- const address = buildCompositeId(row, idInfoArray);
2763
- return address && address.split(":::").some((part) => part !== "") ? address : void 0;
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
2764
2933
  };
2765
- for (const [key, relation] of joinPathRelations) try {
2766
- const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
2767
- if (addressable.length === 0) continue;
2768
- const rowIds = addressable.map((r) => parentIdOf(r));
2769
- if (relation.cardinality === "one") {
2770
- const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
2771
- for (const row of addressable) {
2772
- const relatedRow = resultMap.get(String(parentIdOf(row)));
2773
- row[key] = relatedRow ? { ...relatedRow.values } : null;
2774
- }
2775
- } else if (relation.cardinality === "many") {
2776
- const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
2777
- for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
2778
- }
2779
- } catch (e) {
2780
- logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
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
+ };
2781
2985
  }
2782
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
+ }
2783
3051
  /**
2784
- * Build db.query-compatible options from standard fetch options.
2785
- * Handles filter, search, orderBy, limit, and cursor-based pagination.
3052
+ * Get the relational query builder for a given table name.
3053
+ * Safely narrows the DrizzleClient union type to access db.query[tableName].
2786
3054
  */
2787
- buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
2788
- const queryOpts = {};
2789
- if (withConfig) queryOpts.with = withConfig;
2790
- const allConditions = [];
2791
- if (scopeCondition) allConditions.push(scopeCondition);
2792
- if (options.searchString) {
2793
- const collection = getCollectionByPath(collectionPath, this.registry);
2794
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
2795
- if (searchConditions.length === 0) {
2796
- queryOpts.where = and(eq(idField, -99999999));
2797
- return queryOpts;
2798
- }
2799
- allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
2800
- }
2801
- if (options.filter) {
2802
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
2803
- if (filterConditions.length > 0) allConditions.push(...filterConditions);
2804
- }
2805
- if (options.logical) {
2806
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
2807
- if (logicalCondition) allConditions.push(logicalCondition);
2808
- }
2809
- if (options.startAfter) {
2810
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
2811
- if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
2812
- }
2813
- if (allConditions.length > 0) queryOpts.where = and(...allConditions);
2814
- const orderExpressions = [];
2815
- if (options.orderBy) {
2816
- const collection = getCollectionByPath(collectionPath, this.registry);
2817
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
2818
- if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
2819
- }
2820
- orderExpressions.push(desc(idField));
2821
- if (orderExpressions.length > 0) queryOpts.orderBy = orderExpressions;
2822
- const limitValue = options.searchString ? options.limit || 50 : options.limit;
2823
- if (limitValue) queryOpts.limit = limitValue;
2824
- if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
2825
- return queryOpts;
3055
+ getQueryBuilder(tableName) {
3056
+ return this.db.query?.[tableName];
2826
3057
  }
2827
3058
  /**
2828
- * Extract cursor pagination conditions from startAfter options.
2829
- */
2830
- buildCursorConditions(table, idField, idInfo, options, collectionPath) {
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) {
2831
3284
  if (!options.startAfter) return [];
2832
3285
  const cursor = options.startAfter;
2833
3286
  if (options.orderBy) {
@@ -2895,6 +3348,7 @@ var FetchService = class {
2895
3348
  const idInfo = idInfoArray[0];
2896
3349
  const idField = table[idInfo.fieldName];
2897
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;
2898
3352
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
2899
3353
  const tableName = getTableName(table);
2900
3354
  const qb = this.getQueryBuilder(tableName);
@@ -2905,7 +3359,7 @@ var FetchService = class {
2905
3359
  with: withConfig
2906
3360
  });
2907
3361
  if (!row) return void 0;
2908
- const flatRow = toCmsRow(row, collection, this.registry);
3362
+ const flatRow = toFlatRow(row, collection, this.registry);
2909
3363
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
2910
3364
  return flatRow;
2911
3365
  } catch (e) {
@@ -2913,6 +3367,7 @@ var FetchService = class {
2913
3367
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2914
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.");
2915
3369
  }
3370
+ if (reachedDatabase(e)) throw e;
2916
3371
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
2917
3372
  }
2918
3373
  const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
@@ -2960,12 +3415,13 @@ var FetchService = class {
2960
3415
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
2961
3416
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
2962
3417
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
2963
- return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
3418
+ return (await qb.findMany(queryOpts)).map((row) => toFlatRow(row, collection, this.registry));
2964
3419
  } catch (e) {
2965
3420
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2966
3421
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
2967
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.");
2968
3423
  }
3424
+ if (reachedDatabase(e)) throw e;
2969
3425
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
2970
3426
  }
2971
3427
  let vectorMeta;
@@ -3023,7 +3479,7 @@ var FetchService = class {
3023
3479
  /**
3024
3480
  * Fallback path used when db.query is unavailable.
3025
3481
  *
3026
- * The primary path runs the results through `toCmsRow`, which maps
3482
+ * The primary path runs the results through `toFlatRow`, which maps
3027
3483
  * relations from what drizzle already nested — no query per row. This one
3028
3484
  * has no nesting to read, so it resolves relations itself, in batches.
3029
3485
  *
@@ -3116,6 +3572,10 @@ var FetchService = class {
3116
3572
  const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
3117
3573
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
3118
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
+ }
3119
3579
  if (allConditions.length > 0) {
3120
3580
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
3121
3581
  if (finalCondition) query = query.where(finalCondition);
@@ -3181,6 +3641,7 @@ var FetchService = class {
3181
3641
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
3182
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.");
3183
3643
  }
3644
+ if (reachedDatabase(e)) throw e;
3184
3645
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
3185
3646
  }
3186
3647
  const rows = await this.fetchRowsWithConditionsRaw(collectionPath, options);
@@ -3226,6 +3687,7 @@ var FetchService = class {
3226
3687
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
3227
3688
  const idInfo = idInfoArray[0];
3228
3689
  const idField = table[idInfo.fieldName];
3690
+ if (!idCanAddressTable(id, table, idInfoArray)) return null;
3229
3691
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
3230
3692
  const tableName = getTableName(table);
3231
3693
  const qb = this.getQueryBuilder(tableName);
@@ -3244,6 +3706,7 @@ var FetchService = class {
3244
3706
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
3245
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.");
3246
3708
  }
3709
+ if (reachedDatabase(e)) throw e;
3247
3710
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
3248
3711
  }
3249
3712
  const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
@@ -3284,346 +3747,123 @@ var FetchService = class {
3284
3747
  const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
3285
3748
  let vectorMeta;
3286
3749
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
3287
- let query = vectorMeta ? this.db.select({
3288
- table_row: table,
3289
- _distance: vectorMeta.distanceSelect
3290
- }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3291
- const allConditions = [];
3292
- if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
3293
- if (options.searchString) {
3294
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
3295
- if (searchConditions.length === 0) return [];
3296
- allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3297
- }
3298
- if (options.filter) {
3299
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3300
- if (filterConditions.length > 0) allConditions.push(...filterConditions);
3301
- }
3302
- if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
3303
- if (allConditions.length > 0) {
3304
- const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
3305
- if (finalCondition) query = query.where(finalCondition);
3306
- }
3307
- const orderExpressions = [];
3308
- if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
3309
- else if (options.orderBy) {
3310
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3311
- if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3312
- }
3313
- orderExpressions.push(desc(idField));
3314
- if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
3315
- const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
3316
- if (limitValue) query = query.limit(limitValue);
3317
- if (options.offset && options.offset > 0) query = query.offset(options.offset);
3318
- const rawResults = await query;
3319
- if (vectorMeta) return rawResults.map((r) => ({
3320
- ...r.table_row,
3321
- _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
3322
- }));
3323
- return rawResults;
3324
- }
3325
- /**
3326
- * Check if the Drizzle instance has the relational query API available
3327
- * for a given collection path.
3328
- * Note: Primary path now uses inline `getQueryBuilder()` checks.
3329
- */
3330
- hasDrizzleQueryAPI(collectionPath) {
3331
- if (!this.getQueryBuilder("__probe__")) return false;
3332
- const tableName = getTableName(getTableForCollection(getCollectionByPath(collectionPath, this.registry), this.registry));
3333
- return !!this.getQueryBuilder(tableName);
3334
- }
3335
- /**
3336
- * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
3337
- * for efficient JOIN-based relation loading.
3338
- * Returns null if the API is not available or the query fails.
3339
- * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
3340
- */
3341
- async fetchWithDrizzleQuery(collectionPath, collection, options, include, idInfo, idInfoArray) {
3342
- try {
3343
- const table = getTableForCollection(collection, this.registry);
3344
- const tableName = getTableName(table);
3345
- const queryTarget = this.getQueryBuilder(tableName);
3346
- if (!queryTarget?.findMany) return null;
3347
- const resolvedRelations = resolveCollectionRelations(collection);
3348
- const withConfig = {};
3349
- for (const [key, relation] of Object.entries(resolvedRelations)) if (include[0] === "*" || include.includes(key)) {
3350
- const drizzleRelName = relation.relationName || key;
3351
- withConfig[drizzleRelName] = true;
3352
- }
3353
- const queryOpts = { with: withConfig };
3354
- if (options.limit) queryOpts.limit = options.limit;
3355
- if (options.filter) {
3356
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3357
- if (filterConditions.length > 0) queryOpts.where = and(...filterConditions);
3358
- }
3359
- if (options.orderBy) {
3360
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3361
- if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
3362
- }
3363
- return (await queryTarget.findMany(queryOpts)).map((row) => {
3364
- const flat = {};
3365
- for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
3366
- const keys = Object.keys(item);
3367
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
3368
- if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
3369
- return { ...item };
3370
- });
3371
- else if (typeof v === "object" && v !== null) flat[k] = { ...v };
3372
- else flat[k] = v;
3373
- return flat;
3374
- });
3375
- } catch (e) {
3376
- logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
3377
- return null;
3378
- }
3379
- }
3380
- /**
3381
- * Fallback path used when db.query is unavailable.
3382
- * The primary path uses db.query.findMany with `with` config, which
3383
- * loads all relations in a single query.
3384
- *
3385
- * Batch fetch many-to-many related rows for multiple parent IDs.
3386
- * Groups results by parent ID to avoid N+1.
3387
- */
3388
- async batchFetchManyRelatedRows(parentCollectionPath, parentIds, relationKey) {
3389
- if (parentIds.length === 0) return /* @__PURE__ */ new Map();
3390
- const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
3391
- if (!relation) {
3392
- logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
3393
- return /* @__PURE__ */ new Map();
3394
- }
3395
- const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
3396
- const flatMap = /* @__PURE__ */ new Map();
3397
- for (const [key, rows] of entityMap) flatMap.set(key, rows.map((e) => ({
3398
- ...e.values,
3399
- id: e.id
3400
- })));
3401
- return flatMap;
3402
- }
3403
- };
3404
- //#endregion
3405
- //#region src/utils/pg-error-utils.ts
3406
- /**
3407
- * Shared PostgreSQL error extraction and user-friendly message formatting.
3408
- *
3409
- * Drizzle wraps native PG errors in a `.cause` chain. These utilities
3410
- * unwrap that chain to get the real PostgreSQL error (identified by a
3411
- * 5-character alphanumeric `code` such as `42P01`) and translate it into
3412
- * a message that is safe and helpful to show to end-users.
3413
- */
3414
- /**
3415
- * Return the error when it is a deliberate 4xx, otherwise null.
3416
- *
3417
- * A thrown `ApiError` is a decision the server made about the request, not a
3418
- * database failure: its message and code are already written for the client.
3419
- */
3420
- function asClientFacingError(error) {
3421
- if (!(error instanceof Error)) return null;
3422
- const e = error;
3423
- if (typeof e.statusCode !== "number" || e.statusCode < 400 || e.statusCode >= 500) return null;
3424
- return e;
3425
- }
3426
- /**
3427
- * Extract the underlying PostgreSQL error from a Drizzle wrapper.
3428
- * Drizzle wraps PG errors in a `cause` property — this function
3429
- * recursively walks the chain until it finds an object with a PG
3430
- * error code (5-char alphanumeric, e.g. `42P01`).
3431
- */
3432
- function extractPgError(error) {
3433
- if (!error || typeof error !== "object") return null;
3434
- if (!(error instanceof Error)) {
3435
- if ("cause" in error && error.cause && typeof error.cause === "object") return extractPgError(error.cause);
3436
- return null;
3437
- }
3438
- if ("code" in error && typeof error.code === "string" && /^[0-9A-Z]{5}$/.test(error.code)) return error;
3439
- if (error.cause && typeof error.cause === "object") return extractPgError(error.cause);
3440
- return null;
3441
- }
3442
- /**
3443
- * Walk the error cause chain and return the deepest meaningful message.
3444
- */
3445
- function extractCauseMessage(error) {
3446
- if (!error || typeof error !== "object") return null;
3447
- if (!(error instanceof Error)) return null;
3448
- if (error.cause && typeof error.cause === "object") {
3449
- const deeper = extractCauseMessage(error.cause);
3450
- if (deeper) return deeper;
3451
- if (error.cause instanceof Error && error.cause.message) return error.cause.message;
3452
- }
3453
- return null;
3454
- }
3455
- /**
3456
- * Codes that mean "this connection will never work as configured".
3457
- *
3458
- * A wrong password or a database that does not exist is a settled fact about
3459
- * the connection string, not a transient fault — retrying produces the same
3460
- * answer forever.
3461
- */
3462
- var UNRECOVERABLE_CONNECT_CODES = /* @__PURE__ */ new Set([
3463
- "28P01",
3464
- "28000",
3465
- "3D000",
3466
- "42501"
3467
- ]);
3468
- /**
3469
- * Describe a failed connection attempt in terms a developer can act on.
3470
- *
3471
- * The error a caller catches is Drizzle's wrapper: its message is
3472
- * `Failed query: SELECT 1` and its stack runs through drizzle internals, while
3473
- * the sentence that says what is actually wrong — "password authentication
3474
- * failed for user …", "database … does not exist" — sits in `.cause`. Logging
3475
- * the wrapper, as the bootstrapper used to, tells a developer with a typo in
3476
- * their `DATABASE_URL` nothing at all.
3477
- */
3478
- function classifyConnectFailure(error) {
3479
- const pgError = extractPgError(error);
3480
- const reason = pgError?.message ?? extractCauseMessage(error) ?? (error instanceof Error ? error.message : String(error));
3481
- return {
3482
- fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
3483
- reason,
3484
- code: pgError?.code
3485
- };
3486
- }
3487
- /**
3488
- * Detect whether an error is specifically a role-switching permission failure
3489
- * (e.g. "permission denied to set role" or "must be member of role"),
3490
- * as opposed to a table-level permission denial.
3491
- *
3492
- * This is used by the backend driver to auto-disable role switching when the
3493
- * connection user lacks SET ROLE privileges, rather than surfacing a confusing
3494
- * error to the Studio SQL Editor user.
3495
- */
3496
- function isRoleSwitchingPermissionError(error) {
3497
- const pgError = extractPgError(error);
3498
- if (!pgError || pgError.code !== "42501") return false;
3499
- const msg = pgError.message.toLowerCase();
3500
- return msg.includes("set role") || msg.includes("member of role");
3501
- }
3502
- /**
3503
- * Translate a raw PostgreSQL error into a user-friendly message.
3504
- *
3505
- * @param pgError - The extracted PostgreSQL error (from {@link extractPgError})
3506
- * @param context - A human-readable context string (e.g. collection slug or path)
3507
- * @returns An object with a `message` safe for the client and the PG `code`.
3508
- */
3509
- function pgErrorToFriendlyMessage(pgError, context) {
3510
- const detail = pgError.detail;
3511
- const hint = pgError.hint;
3512
- const constraint = pgError.constraint;
3513
- const column = pgError.column;
3514
- const table = pgError.table;
3515
- const dataType = pgError.dataType;
3516
- const pgMessage = pgError.message || "Unknown database error";
3517
- const code = pgError.code || "UNKNOWN";
3518
- const suffix = hint ? ` Hint: ${hint}` : "";
3519
- const tableRef = table ?? context;
3520
- switch (pgError.code) {
3521
- case "23503": return {
3522
- message: detail ? `Foreign key constraint violated: ${detail}${suffix}` : `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
3523
- code
3524
- };
3525
- case "23505": return {
3526
- message: detail ? `Duplicate value: ${detail}${suffix}` : `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
3527
- code
3528
- };
3529
- case "23502": return {
3530
- message: `Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`,
3531
- code
3532
- };
3533
- case "23514": return {
3534
- message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
3535
- code
3536
- };
3537
- case "22P02": return {
3538
- message: `Invalid data format in "${context}": ${pgMessage}${suffix}`,
3539
- code
3540
- };
3541
- case "22001": return {
3542
- message: `Value too long for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
3543
- code
3544
- };
3545
- case "22003": return {
3546
- message: `Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
3547
- code
3548
- };
3549
- case "42703": return {
3550
- message: `Unknown column in "${tableRef}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
3551
- code
3552
- };
3553
- case "42P01": return {
3554
- message: `Table not found for "${context}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
3555
- code
3556
- };
3557
- case "42501": return {
3558
- message: `Permission denied on "${tableRef}". Check your database credentials and RLS policies.${suffix}`,
3559
- code
3560
- };
3561
- case "28000": return {
3562
- message: `Authorization failed for "${context}". Check your database credentials.${suffix}`,
3563
- code
3564
- };
3565
- default: {
3566
- const parts = [`Database error in "${context}" [${code}]: ${pgMessage}`];
3567
- if (detail) parts.push(`Detail: ${detail}`);
3568
- if (column) parts.push(`Column: ${column}`);
3569
- if (dataType) parts.push(`Data type: ${dataType}`);
3570
- if (constraint) parts.push(`Constraint: ${constraint}`);
3571
- if (hint) parts.push(`Hint: ${hint}`);
3572
- return {
3573
- message: parts.join(". "),
3574
- code
3575
- };
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));
3576
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;
3577
3787
  }
3578
- }
3579
- /**
3580
- * Sanitize any error into a message safe and helpful for the client.
3581
- *
3582
- * A deliberate 4xx (`ApiError`) passes through untouched — the server already
3583
- * decided what the client should read. Otherwise the PG error is extracted
3584
- * from the Drizzle cause chain, falling back to a generic message that
3585
- * doesn't leak SQL.
3586
- *
3587
- * @param error - The raw caught error
3588
- * @param context - A human-readable context string (e.g. collection path)
3589
- * @returns An object with `message` (user-friendly) and optional `code`
3590
- * (the `ApiError` code, or the PG SQLSTATE).
3591
- */
3592
- function sanitizeErrorForClient(error, context) {
3593
- const clientError = asClientFacingError(error);
3594
- if (clientError) {
3595
- const line = `[API ${clientError.statusCode} ${clientError.code ?? "BAD_REQUEST"}] in "${context}": ${clientError.message}`;
3596
- if (clientError.expected) logger.debug(line);
3597
- else logger.warn(`⚠️ ${line}`);
3598
- return {
3599
- message: clientError.message,
3600
- ...clientError.code && { code: clientError.code }
3601
- };
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);
3602
3797
  }
3603
- const pgError = extractPgError(error);
3604
- if (pgError) {
3605
- logger.error(`[PG ${pgError.code}] Error in "${context}"`, {
3606
- code: pgError.code,
3607
- message: pgError.message,
3608
- detail: pgError.detail,
3609
- hint: pgError.hint,
3610
- column: pgError.column,
3611
- table: pgError.table,
3612
- constraint: pgError.constraint,
3613
- dataType: pgError.dataType,
3614
- drizzleMessage: error instanceof Error ? error.message : String(error)
3615
- });
3616
- return pgErrorToFriendlyMessage(pgError, context);
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
+ }
3617
3842
  }
3618
- logger.error(`Database error in "${context}" (no PG error extracted)`, {
3619
- error: error instanceof Error ? error.message : String(error),
3620
- stack: error instanceof Error ? error.stack : void 0,
3621
- cause: error instanceof Error && error.cause ? error.cause instanceof Error ? error.cause.message : String(error.cause) : void 0
3622
- });
3623
- const causeMessage = extractCauseMessage(error);
3624
- if (causeMessage) return { message: `Database error in "${context}": ${causeMessage}` };
3625
- return { message: `Could not load data for "${context}". Check server logs for details.` };
3626
- }
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
+ };
3627
3867
  //#endregion
3628
3868
  //#region src/services/PersistService.ts
3629
3869
  /**
@@ -3747,10 +3987,11 @@ var PersistService = class {
3747
3987
  } else {
3748
3988
  const targetColumnName = this.resolveParentForeignKeyColumn(hop);
3749
3989
  if (targetColumnName) {
3750
- const parsedParentId = parentIdForWrite();
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.`);
3751
3992
  const existingValue = effectiveValues[targetColumnName];
3752
- if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
3753
- effectiveValues[targetColumnName] = parsedParentId;
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;
3754
3995
  }
3755
3996
  }
3756
3997
  }
@@ -4024,19 +4265,34 @@ function validateIdentifier(value, label) {
4024
4265
  if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error(`Invalid ${label}: only letters, digits, underscores, and hyphens are allowed.`);
4025
4266
  }
4026
4267
  /**
4027
- * Sanitize a user-provided branch name to a safe PostgreSQL identifier.
4028
- * Only allows alphanumeric characters and underscores.
4268
+ * Postgres truncates identifiers at NAMEDATALEN-1 = 63 bytes, and does it
4269
+ * silently. The prefix comes out of the same budget.
4029
4270
  */
4030
- function sanitizeBranchName(name) {
4031
- return name.replace(/[^a-zA-Z0-9_]/g, "");
4271
+ var MAX_BRANCH_NAME_LENGTH = 60;
4272
+ /**
4273
+ * Check a user-provided branch name, and otherwise leave it exactly as given.
4274
+ *
4275
+ * This used to strip everything outside [a-zA-Z0-9_], so `my-feature` was
4276
+ * quietly created as `myfeature`: the name you typed was not the name `list`
4277
+ * gave back. Nothing ever needed that. Every identifier this service builds is
4278
+ * double-quoted (see `CREATE DATABASE` below), which is what makes a hyphen
4279
+ * safe, and `validateIdentifier` has always accepted hyphens for the `--from`
4280
+ * source database — the two disagreed about the same character class.
4281
+ *
4282
+ * Refusing a name we cannot represent is better than representing a different
4283
+ * one, which is also why the length is checked here rather than left to
4284
+ * Postgres, whose answer to an over-long identifier is a silent rename.
4285
+ */
4286
+ function assertValidBranchName(name) {
4287
+ validateIdentifier(name, "branch name");
4288
+ if (name.length > MAX_BRANCH_NAME_LENGTH) throw new Error(`Branch name "${name}" is too long: ${name.length} characters, maximum ${MAX_BRANCH_NAME_LENGTH}. Postgres truncates identifiers past 63 bytes, which would give the branch a name you did not choose.`);
4032
4289
  }
4033
4290
  /**
4034
4291
  * Convert a user-facing branch name to the actual PostgreSQL database name.
4035
4292
  */
4036
4293
  function toBranchDbName(name) {
4037
- const sanitized = sanitizeBranchName(name);
4038
- if (!sanitized) throw new Error("Branch name must contain at least one alphanumeric character.");
4039
- return `${BRANCH_DB_PREFIX}${sanitized}`;
4294
+ assertValidBranchName(name);
4295
+ return `${BRANCH_DB_PREFIX}${name}`;
4040
4296
  }
4041
4297
  var BranchService = class {
4042
4298
  db;
@@ -4073,9 +4329,8 @@ var BranchService = class {
4073
4329
  async createBranch(name, options) {
4074
4330
  if (options?.source) validateIdentifier(options.source, "source database name");
4075
4331
  const dbName = toBranchDbName(name);
4076
- const sanitizedName = sanitizeBranchName(name);
4077
4332
  const sourceDb = options?.source || this.poolManager.defaultDatabaseName;
4078
- if ((await this.db.execute(sql`SELECT name FROM rebase.branches WHERE name = ${sanitizedName} OR db_name = ${dbName}`)).rows.length > 0) throw new Error(`Branch "${sanitizedName}" already exists.`);
4333
+ if ((await this.db.execute(sql`SELECT name FROM rebase.branches WHERE name = ${name} OR db_name = ${dbName}`)).rows.length > 0) throw new Error(`Branch "${name}" already exists.`);
4079
4334
  await this.poolManager.disconnectDatabase(sourceDb);
4080
4335
  const safeDbName = dbName.replace(/"/g, "\"\"");
4081
4336
  const safeSourceDb = sourceDb.replace(/"/g, "\"\"");
@@ -4086,10 +4341,10 @@ var BranchService = class {
4086
4341
  throw describeBranchDdlError(err, dbName);
4087
4342
  }
4088
4343
  const now = /* @__PURE__ */ new Date();
4089
- await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
4090
- VALUES (${sanitizedName}, ${dbName}, ${sourceDb}, ${now.toISOString()})`);
4344
+ await this.db.execute(sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
4345
+ VALUES (${name}, ${dbName}, ${sourceDb}, ${now.toISOString()})`);
4091
4346
  return {
4092
- name: sanitizedName,
4347
+ name,
4093
4348
  parentDatabase: sourceDb,
4094
4349
  createdAt: now
4095
4350
  };
@@ -4099,19 +4354,21 @@ var BranchService = class {
4099
4354
  * Cannot delete the main/default database.
4100
4355
  */
4101
4356
  async deleteBranch(name) {
4102
- const sanitizedName = sanitizeBranchName(name);
4103
- const dbName = toBranchDbName(name);
4357
+ assertValidBranchName(name);
4358
+ if (toBranchDbName(name) === this.poolManager.defaultDatabaseName) throw new Error("Cannot delete the main database.");
4359
+ const existingRows = (await this.db.execute(sql`SELECT db_name FROM rebase.branches WHERE name = ${name}`)).rows;
4360
+ if (existingRows.length === 0) throw new Error(`Branch "${name}" not found.`);
4361
+ const dbName = existingRows[0].db_name;
4104
4362
  if (dbName === this.poolManager.defaultDatabaseName) throw new Error("Cannot delete the main database.");
4105
- if ((await this.db.execute(sql`SELECT db_name FROM rebase.branches WHERE name = ${sanitizedName}`)).rows.length === 0) throw new Error(`Branch "${sanitizedName}" not found.`);
4106
4363
  await this.poolManager.disconnectDatabase(dbName);
4107
4364
  const safeDbName = dbName.replace(/"/g, "\"\"");
4108
4365
  try {
4109
4366
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
4110
4367
  } catch (err) {
4111
- if (extractPgError(err)?.code === PG_OBJECT_IN_USE) throw new Error(`Cannot delete branch "${sanitizedName}": the database has active connections. Close other clients and try again.`);
4368
+ if (extractPgError(err)?.code === PG_OBJECT_IN_USE) throw new Error(`Cannot delete branch "${name}": the database has active connections. Close other clients and try again.`);
4112
4369
  throw describeBranchDdlError(err, dbName);
4113
4370
  }
4114
- await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`);
4371
+ await this.db.execute(sql`DELETE FROM rebase.branches WHERE name = ${name}`);
4115
4372
  }
4116
4373
  /**
4117
4374
  * List all branches recorded in the metadata table.
@@ -4138,20 +4395,21 @@ var BranchService = class {
4138
4395
  * Get info about a specific branch.
4139
4396
  */
4140
4397
  async getBranchInfo(name) {
4141
- const sanitizedName = sanitizeBranchName(name);
4398
+ assertValidBranchName(name);
4142
4399
  const rows = (await this.db.execute(sql`
4143
- SELECT
4400
+ SELECT
4144
4401
  b.name,
4402
+ b.db_name,
4145
4403
  b.parent_db,
4146
4404
  b.created_at
4147
4405
  FROM rebase.branches b
4148
- WHERE b.name = ${sanitizedName}
4406
+ WHERE b.name = ${name}
4149
4407
  `)).rows;
4150
4408
  if (rows.length === 0) return void 0;
4151
4409
  const row = rows[0];
4152
4410
  let sizeBytes;
4153
4411
  try {
4154
- const dbName = toBranchDbName(sanitizedName);
4412
+ const dbName = row.db_name;
4155
4413
  const sizeRows = (await this.db.execute(sql`SELECT pg_database_size(${dbName}) as size_bytes`)).rows;
4156
4414
  if (sizeRows.length > 0 && sizeRows[0].size_bytes != null) sizeBytes = Number(sizeRows[0].size_bytes);
4157
4415
  } catch {}
@@ -5028,9 +5286,10 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5028
5286
  async checkUniqueField(path, name, value, id, collection) {
5029
5287
  return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
5030
5288
  }
5031
- async count({ path, collection, filter, searchString }) {
5289
+ async count({ path, collection, filter, logical, searchString }) {
5032
5290
  return this.dataService.count(path, {
5033
5291
  filter,
5292
+ logical,
5034
5293
  searchString
5035
5294
  });
5036
5295
  }
@@ -5399,7 +5658,7 @@ var DatabasePoolManager = class {
5399
5658
  const url = new URL(this.rootConnectionString);
5400
5659
  url.pathname = `/${databaseName}`;
5401
5660
  const pool = new Pool({
5402
- connectionString: url.toString(),
5661
+ connectionString: pinSearchPath(url.toString()),
5403
5662
  max: 10,
5404
5663
  idleTimeoutMillis: 1e4,
5405
5664
  allowExitOnIdle: true
@@ -5749,6 +6008,7 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5749
6008
  const isId = isIdProperty(propName, prop, collection);
5750
6009
  let baseType = numProp.validation?.integer || isId ? `integer("${colName}")` : `numeric("${colName}")`;
5751
6010
  if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
6011
+ else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
5752
6012
  else baseType = `${numProp.columnType}("${colName}")`;
5753
6013
  if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
5754
6014
  else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
@@ -5987,8 +6247,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5987
6247
  const tableVarName = getTableVarName(tableName);
5988
6248
  if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
5989
6249
  const targetCollection = relation.target();
5990
- const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : void 0) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : void 0);
5991
- const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
6250
+ const tableCreator = "pgTable";
5992
6251
  const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
5993
6252
  const { sourceColumn, targetColumn } = relation.through;
5994
6253
  const refOptions = `{ onDelete: \"${relation.onDelete ?? "cascade"}\" }`;
@@ -6102,8 +6361,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
6102
6361
  if (!emittedRelationNames.has(deduplicationKey)) {
6103
6362
  const otherTableVar = getTableVarName(getTableName$1(otherCollection));
6104
6363
  const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
6364
+ const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
6105
6365
  const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
6106
- tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${getPrimaryKeyName(otherCollection)}],\n relationName: \"${drizzleRelationName}\"\n })`);
6366
+ tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${referencedKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
6107
6367
  emittedRelationNames.add(deduplicationKey);
6108
6368
  }
6109
6369
  }
@@ -7261,14 +7521,6 @@ function resolveChannelBusSetting(configured) {
7261
7521
  return raw === "memory" ? { type: "memory" } : { type: "postgres" };
7262
7522
  }
7263
7523
  /**
7264
- * @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
7265
- * supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
7266
- * config-only callers keep their exact types.
7267
- */
7268
- function resolveChannelBusConfig(configured) {
7269
- return resolveChannelBusSetting(configured);
7270
- }
7271
- /**
7272
7524
  * Produce the bus a setting asks for.
7273
7525
  *
7274
7526
  * An instance is handed straight back — constructing it was the application's
@@ -7745,7 +7997,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7745
7997
  if (this.driver) {
7746
7998
  const collection = this.registry.getCollectionByPath(notifyPath);
7747
7999
  const activeAuth = authContext || {
7748
- uid: "anon",
8000
+ uid: "anonymous",
7749
8001
  roles: ["anon"]
7750
8002
  };
7751
8003
  return await this.db.transaction(async (tx) => {
@@ -7874,7 +8126,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7874
8126
  if (this.driver) {
7875
8127
  const collection = this.registry.getCollectionByPath(notifyPath);
7876
8128
  const activeAuth = authContext || {
7877
- uid: "anon",
8129
+ uid: "anonymous",
7878
8130
  roles: ["anon"]
7879
8131
  };
7880
8132
  return await this.db.transaction(async (tx) => {
@@ -8879,7 +9131,7 @@ function createBackupCron(config) {
8879
9131
  enabled: config.enabled ?? true,
8880
9132
  timeoutSeconds: 3600,
8881
9133
  async handler({ log }) {
8882
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-DH9kPg-E.js").then((n) => n.r);
9134
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-CD8o_1Sl.js").then((n) => n.r);
8883
9135
  const { destination } = config;
8884
9136
  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 }).`);
8885
9137
  log(`Starting backup of "${dbName}"…`);
@@ -9010,6 +9262,11 @@ function findRelationDefects(collections, registry) {
9010
9262
  problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
9011
9263
  fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
9012
9264
  });
9265
+ if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) defects.push({
9266
+ ...at,
9267
+ problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
9268
+ 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)}`
9269
+ });
9013
9270
  break;
9014
9271
  case "manyToMany": {
9015
9272
  const { table, sourceColumn, targetColumn } = relation.through;
@@ -9761,21 +10018,6 @@ async function ensureAuthTablesExist(db, collection) {
9761
10018
  }
9762
10019
  //#endregion
9763
10020
  //#region src/auth/services.ts
9764
- function getColumnKey(table, ...keys) {
9765
- if (!table) return void 0;
9766
- for (const key of keys) {
9767
- if (key in table) return key;
9768
- const snake = toSnakeCase(key);
9769
- if (snake in table) return snake;
9770
- const camel = camelCase(key);
9771
- if (camel in table) return camel;
9772
- }
9773
- }
9774
- function getColumn(table, ...keys) {
9775
- if (!table) return void 0;
9776
- const key = getColumnKey(table, ...keys);
9777
- return key ? table[key] : void 0;
9778
- }
9779
10021
  /**
9780
10022
  * The single definition of what an email address looks like in storage.
9781
10023
  *
@@ -9790,9 +10032,25 @@ function getColumn(table, ...keys) {
9790
10032
  *
9791
10033
  * Whitespace goes too: a trailing space survives the fold and reproduces the
9792
10034
  * problem exactly.
10035
+ *
10036
+ * Re-exported rather than defined here: `@rebasepro/server` and
10037
+ * `@rebasepro/server-mongo` write this column too, and a second copy of this
10038
+ * rule is the defect it exists to prevent.
9793
10039
  */
9794
- function normalizeEmail(email) {
9795
- return typeof email === "string" ? email.trim().toLowerCase() : email;
10040
+ function getColumnKey(table, ...keys) {
10041
+ if (!table) return void 0;
10042
+ for (const key of keys) {
10043
+ if (key in table) return key;
10044
+ const snake = toSnakeCase(key);
10045
+ if (snake in table) return snake;
10046
+ const camel = camelCase(key);
10047
+ if (camel in table) return camel;
10048
+ }
10049
+ }
10050
+ function getColumn(table, ...keys) {
10051
+ if (!table) return void 0;
10052
+ const key = getColumnKey(table, ...keys);
10053
+ return key ? table[key] : void 0;
9796
10054
  }
9797
10055
  /**
9798
10056
  * PostgreSQL implementation of UserRepository.
@@ -11133,6 +11391,31 @@ function humanize(snakeName) {
11133
11391
  return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
11134
11392
  }
11135
11393
  //#endregion
11394
+ //#region src/schema/introspect-db-types.ts
11395
+ /**
11396
+ * The PostgreSQL type → Rebase property type mapping.
11397
+ *
11398
+ * Split out of `introspect-db-logic` so that the structural analysis can use it
11399
+ * without importing the generator, which imports the analysis. Re-exported from
11400
+ * `introspect-db-logic` so existing callers keep their import path.
11401
+ */
11402
+ /**
11403
+ * Map a PostgreSQL data type to a Rebase property type.
11404
+ */
11405
+ function mapPgType(dataType) {
11406
+ const dt = dataType.toLowerCase();
11407
+ if (dt === "interval") return "string";
11408
+ if (dt === "array" || dt.startsWith("_")) return "array";
11409
+ 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";
11410
+ if (dt.includes("bool")) return "boolean";
11411
+ if (dt.includes("time") || dt.includes("date")) return "date";
11412
+ if (dt === "json" || dt === "jsonb") return "map";
11413
+ if (dt === "bytea") return "binary";
11414
+ if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
11415
+ if (dt === "uuid") return "string";
11416
+ return "string";
11417
+ }
11418
+ //#endregion
11136
11419
  //#region src/schema/introspect-db-logic.ts
11137
11420
  var IRREGULAR_SINGULARS = {
11138
11421
  people: "person",
@@ -11148,6 +11431,36 @@ var IRREGULAR_SINGULARS = {
11148
11431
  criteria: "criterion",
11149
11432
  phenomena: "phenomenon"
11150
11433
  };
11434
+ /**
11435
+ * Plurals in "-ves" whose singular really ends in f/fe, and which of the two.
11436
+ *
11437
+ * A blanket "-ves" -> "-f" rule gets `knives` -> `knif`, and mangles every
11438
+ * ordinary "-ive" noun that happens to be plural along with it: `archives` ->
11439
+ * `archif`, `objectives` -> `objectif`. The set of English words that genuinely
11440
+ * swap f/fe for ves is small and closed, so it is listed rather than guessed —
11441
+ * anything else ending in "ves" drops the trailing 's' like any other plural.
11442
+ *
11443
+ * Matched on the whole word, not on the suffix: `olives` ends in `lives`.
11444
+ */
11445
+ var VES_SINGULAR_ENDINGS = {
11446
+ calves: "f",
11447
+ dwarves: "f",
11448
+ elves: "f",
11449
+ halves: "f",
11450
+ hooves: "f",
11451
+ leaves: "f",
11452
+ loaves: "f",
11453
+ scarves: "f",
11454
+ selves: "f",
11455
+ sheaves: "f",
11456
+ shelves: "f",
11457
+ thieves: "f",
11458
+ wharves: "f",
11459
+ wolves: "f",
11460
+ knives: "fe",
11461
+ lives: "fe",
11462
+ wives: "fe"
11463
+ };
11151
11464
  /** Words ending in 's' that are already singular. */
11152
11465
  var UNCOUNTABLE = /* @__PURE__ */ new Set([
11153
11466
  "status",
@@ -11186,7 +11499,7 @@ function singularize(word) {
11186
11499
  if (UNCOUNTABLE.has(lower)) return word;
11187
11500
  if (lower.endsWith("ices") && lower.length > 5) return word.slice(0, -4) + "ex";
11188
11501
  if (lower.endsWith("ies") && lower.length > 3) return word.slice(0, -3) + "y";
11189
- if (lower.endsWith("ves")) return word.slice(0, -3) + "f";
11502
+ if (VES_SINGULAR_ENDINGS[lower]) return word.slice(0, -3) + VES_SINGULAR_ENDINGS[lower];
11190
11503
  if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) return word.slice(0, -2);
11191
11504
  if (lower.endsWith("ses") && !lower.endsWith("sses")) return word.slice(0, -1);
11192
11505
  if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
@@ -11207,22 +11520,6 @@ function getIconForTable(tableName) {
11207
11520
  if (table.includes("comment") || table.includes("review") || table.includes("feedback")) return "MessageCircle";
11208
11521
  return "Database";
11209
11522
  }
11210
- /**
11211
- * Map a PostgreSQL data type to a Rebase property type.
11212
- */
11213
- function mapPgType(dataType) {
11214
- const dt = dataType.toLowerCase();
11215
- if (dt === "interval") return "string";
11216
- if (dt === "array" || dt.startsWith("_")) return "array";
11217
- 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";
11218
- if (dt.includes("bool")) return "boolean";
11219
- if (dt.includes("time") || dt.includes("date")) return "date";
11220
- if (dt === "json" || dt === "jsonb") return "map";
11221
- if (dt === "bytea") return "binary";
11222
- if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
11223
- if (dt === "uuid") return "string";
11224
- return "string";
11225
- }
11226
11523
  function buildEnumMap(enumValues) {
11227
11524
  const enumMap = /* @__PURE__ */ new Map();
11228
11525
  for (const ev of enumValues) {
@@ -11242,6 +11539,20 @@ function buildTablesMap(tables, columns, pks, fks) {
11242
11539
  });
11243
11540
  return tablesMap;
11244
11541
  }
11542
+ /**
11543
+ * Join tables, identified by column name.
11544
+ *
11545
+ * Superseded for the CLI by `classifyTables` in `./introspect-db-structure`,
11546
+ * which asks the database instead: two single-column keys, unique together, no
11547
+ * payload column, nothing referencing the table. This rule folds away
11548
+ * `northwind.order_details` — which has the key shape and carries unit price,
11549
+ * quantity and discount — because it recognises `id`, `created_at` and
11550
+ * `updated_at` by name and calls everything else a foreign key.
11551
+ *
11552
+ * Still used by `./introspect-runtime`, which builds collections in memory from
11553
+ * a narrower set of catalog queries and has no unique-constraint or row-count
11554
+ * data to reason with.
11555
+ */
11245
11556
  function identifyJoinTables(tablesMap) {
11246
11557
  const joinTables = /* @__PURE__ */ new Set();
11247
11558
  for (const [tableName, meta] of tablesMap.entries()) if (meta.fks.length === 2) {
@@ -11620,6 +11931,32 @@ function isEconnrefused(err) {
11620
11931
  * Implements the `BackendBootstrapper` interface for PostgreSQL.
11621
11932
  */
11622
11933
  /**
11934
+ * Which table name the boot-time drift check should look for, for one collection.
11935
+ *
11936
+ * A declared `table` IS the table name, not a hint to be second-guessed. This
11937
+ * used to ask the registry whether it had indexed the declared name and fall
11938
+ * back to the SLUG when it had not — but "the registry does not know this table"
11939
+ * is exactly the condition the drift check exists to report, so the fallback
11940
+ * fired precisely when it was most harmful.
11941
+ *
11942
+ * A collection with `slug: "usage-daily", table: "usage_daily"` was reported as
11943
+ * missing table `usage-daily`: a name that does not exist, should never exist,
11944
+ * and that nobody can find by looking. Worse, the remediation the caller prints
11945
+ * says to run `rebase db push` — which would then CREATE that invented table
11946
+ * beside the correct one, the same "second copy" hazard the misplaced-schema
11947
+ * branch further down exists to prevent. Seen in production, where a correctly
11948
+ * migrated database reported drift on every boot.
11949
+ *
11950
+ * The slug is used only when nothing was declared, which is the config shape
11951
+ * where the slug genuinely is the table name.
11952
+ *
11953
+ * Exported for its own test: the caller needs a live pool and a real database,
11954
+ * and this is the part that was wrong.
11955
+ */
11956
+ function resolveDriftCheckName(col, registeredTableNames) {
11957
+ return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
11958
+ }
11959
+ /**
11623
11960
  * Default PostgreSQL bootstrapper.
11624
11961
  *
11625
11962
  * Use it to register Postgres with `initializeRebaseBackend()`:
@@ -11708,7 +12045,7 @@ function createPostgresBootstrapper(pgConfig) {
11708
12045
  let readDb;
11709
12046
  const readUrl = process.env.DATABASE_READ_URL;
11710
12047
  if (readUrl && readUrl !== pgConfig.connectionString) try {
11711
- const { createReadReplicaConnection } = await import("./connection-B5Wndbr1.js").then((n) => n.t);
12048
+ const { createReadReplicaConnection } = await import("./connection-BuZ97wsr.js").then((n) => n.t);
11712
12049
  readDb = createReadReplicaConnection(readUrl, mergedSchema).db;
11713
12050
  logger.info("📖 [PostgresBootstrapper] Read replica connection established");
11714
12051
  } catch (err) {
@@ -11810,39 +12147,59 @@ function createPostgresBootstrapper(pgConfig) {
11810
12147
  try {
11811
12148
  const registeredCollections = registry.getCollections();
11812
12149
  if (registeredCollections.length > 0) {
11813
- const schemasList = Array.from(new Set(registeredCollections.map((c) => "schema" in c && c.schema ? c.schema : "public"))).map((s) => `'${s}'`).join(",");
11814
12150
  const result = await schemaAwareDb.execute(sql.raw(`
11815
12151
  SELECT table_name, table_schema
11816
12152
  FROM information_schema.tables
11817
- WHERE table_schema IN (${schemasList})
12153
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
11818
12154
  AND table_type = 'BASE TABLE'
11819
12155
  `));
12156
+ const tablesByName = /* @__PURE__ */ new Map();
12157
+ for (const row of result.rows) {
12158
+ const schemas = tablesByName.get(row.table_name) ?? [];
12159
+ schemas.push(row.table_schema);
12160
+ tablesByName.set(row.table_name, schemas);
12161
+ }
11820
12162
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
11821
12163
  const missing = [];
11822
12164
  for (const col of registeredCollections) {
11823
12165
  if (col.auth?.enabled) continue;
11824
12166
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
11825
- const declaredTable = isRelationalCollectionConfig(col) ? col.table : void 0;
11826
- const tableName = registry.hasTableForCollection(declaredTable ?? col.slug) ? declaredTable ?? col.slug : col.slug;
11827
- const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
12167
+ const checkName = resolveDriftCheckName(col, registry.getTableNames());
11828
12168
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
11829
12169
  if (!dbTables.has(fullCheckName)) missing.push({
11830
12170
  slug: col.slug,
11831
- table: fullCheckName
12171
+ table: fullCheckName,
12172
+ foundIn: (tablesByName.get(checkName) ?? []).filter((s) => s !== schemaName)
11832
12173
  });
11833
12174
  }
11834
12175
  if (missing.length > 0) {
11835
- const lines = missing.map((m) => ` • collection "${m.slug}" → table "${m.table}"`);
12176
+ 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(", ")}` : ""));
12177
+ const misplaced = missing.filter((m) => m.foundIn.length > 0);
12178
+ const misplacedHelp = misplaced.length === 0 ? [] : [
12179
+ " Those tables exist — in the wrong schema. Unqualified SQL run by a",
12180
+ " role whose name matches a schema (`rebase` is both, by default)",
12181
+ " resolves through search_path's \"$user\" and creates there. Move them:",
12182
+ ...misplaced.map((m) => ` ALTER TABLE "${m.foundIn[0]}"."${m.table.split(".").pop()}" SET SCHEMA "${m.table.includes(".") ? m.table.split(".")[0] : "public"}";`),
12183
+ " and qualify the SQL that created them, or pin search_path in DATABASE_URL",
12184
+ " (`?options=-c%20search_path%3Dpublic`).",
12185
+ ""
12186
+ ];
11836
12187
  logger.warn([
11837
12188
  "",
11838
12189
  "⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
11839
12190
  ...lines,
11840
12191
  "",
12192
+ ...misplacedHelp,
11841
12193
  " This runtime applies the collection schema at boot unless",
11842
12194
  " REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
11843
12195
  " log lines above — this drift means that step was off, skipped, or failed.",
11844
- " • Managed cloud: redeploy (or restart) with REBASE_MIGRATE_ON_BOOT",
11845
- " unset or \"ensure\"; the runtime applies the schema to the tenant DB.",
12196
+ " • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
12197
+ " \"ensure\"; the runtime applies the schema to the tenant DB.",
12198
+ " If the log above says the driver does not implement collection-table",
12199
+ " creation, THIS driver is too old to do it. A driver is installed from",
12200
+ " your bundle's dependencies, not supplied by the platform image, so a",
12201
+ " newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
12202
+ " in your project's package.json and redeploy.",
11846
12203
  " • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
11847
12204
  " against DATABASE_URL.",
11848
12205
  ""
@@ -11931,7 +12288,7 @@ function createPostgresBootstrapper(pgConfig) {
11931
12288
  */
11932
12289
  async ensureCollectionSchema(collections, driverResult, log) {
11933
12290
  const internals = driverResult.internals;
11934
- const { ensureCollectionTables } = await import("./ensure-collection-tables-DmOzh_G6.js");
12291
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-CBQdOETu.js");
11935
12292
  const plan = await ensureCollectionTables({ async query(text) {
11936
12293
  const result = await internals.db.execute(sql.raw(text));
11937
12294
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
@@ -11956,7 +12313,7 @@ function createPostgresBootstrapper(pgConfig) {
11956
12313
  */
11957
12314
  async ensureCollectionPolicies(collections, driverResult, log) {
11958
12315
  const internals = driverResult.internals;
11959
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-t875gSJF.js");
12316
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-ViG8XiPn.js");
11960
12317
  const outcome = await ensureCollectionPolicies({ async query(text) {
11961
12318
  const result = await internals.db.execute(sql.raw(text));
11962
12319
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
@@ -11970,7 +12327,7 @@ function createPostgresBootstrapper(pgConfig) {
11970
12327
  },
11971
12328
  mountRoutes(app, basePath, driverResult) {},
11972
12329
  async initializeWebsockets(server, realtimeService, driver, config, adapter) {
11973
- const { createPostgresWebSocket } = await import("./websocket-BKcGvILX.js").then((n) => n.n);
12330
+ const { createPostgresWebSocket } = await import("./websocket-B2LsrINK.js").then((n) => n.n);
11974
12331
  createPostgresWebSocket(server, realtimeService, driver, config, adapter);
11975
12332
  }
11976
12333
  };
@@ -11996,8 +12353,8 @@ function createPostgresAdapter(pgConfig) {
11996
12353
  async initializeHistory(config, driverResult) {
11997
12354
  if (bootstrapper.initializeHistory) return bootstrapper.initializeHistory(config, driverResult);
11998
12355
  },
11999
- initializeWebsockets(server, realtimeService, driver, config) {
12000
- if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config);
12356
+ initializeWebsockets(server, realtimeService, driver, config, adapter) {
12357
+ if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config, adapter);
12001
12358
  },
12002
12359
  ensureCollectionSchema: bootstrapper.ensureCollectionSchema ? (collections, driverResult, log) => bootstrapper.ensureCollectionSchema(collections, driverResult, log) : void 0,
12003
12360
  ensureCollectionPolicies: bootstrapper.ensureCollectionPolicies ? (collections, driverResult, log) => bootstrapper.ensureCollectionPolicies(collections, driverResult, log) : void 0,
@@ -12010,6 +12367,6 @@ function createPostgresAdapter(pgConfig) {
12010
12367
  };
12011
12368
  }
12012
12369
  //#endregion
12013
- 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, resolveChannelBusConfig, resolveChannelBusSetting, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
12370
+ 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 };
12014
12371
 
12015
12372
  //# sourceMappingURL=index.es.js.map