@rebasepro/server-postgres 0.12.1-canary.gdfba2a1 → 0.12.1-canary.ge1bb178

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 (87) 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-BrUVgjz3.js +57 -0
  13. package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
  14. package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
  15. package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
  16. package/dist/index.es.js +529 -142
  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-policies.d.ts +60 -0
  21. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  22. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  23. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  24. package/dist/schema/introspect-db-logic.d.ts +94 -5
  25. package/dist/schema/introspect-db-queries.d.ts +119 -0
  26. package/dist/schema/introspect-db-structure.d.ts +263 -0
  27. package/dist/schema/introspect-db-types.d.ts +11 -0
  28. package/dist/services/FetchService.d.ts +4 -1
  29. package/dist/services/RelationService.d.ts +24 -1
  30. package/dist/services/channel-bus/index.d.ts +1 -7
  31. package/dist/services/collection-helpers.d.ts +24 -1
  32. package/dist/services/dataService.d.ts +3 -1
  33. package/dist/services/row-pipeline.d.ts +1 -1
  34. package/dist/{src-DihrDFuP.js → src-CzbghKwf.js} +189 -101
  35. package/dist/src-CzbghKwf.js.map +1 -0
  36. package/dist/utils/connection-string.d.ts +29 -0
  37. package/dist/utils/drizzle-conditions.d.ts +5 -4
  38. package/dist/utils/pg-error-utils.d.ts +19 -0
  39. package/dist/{websocket-BKcGvILX.js → websocket-B2LsrINK.js} +8 -6
  40. package/dist/websocket-B2LsrINK.js.map +1 -0
  41. package/package.json +6 -6
  42. package/src/PostgresAdapter.ts +21 -2
  43. package/src/PostgresBackendDriver.ts +4 -0
  44. package/src/PostgresBootstrapper.ts +192 -33
  45. package/src/auth/services.ts +6 -3
  46. package/src/backup/backup-cli.ts +41 -2
  47. package/src/backup/backup-service.ts +38 -5
  48. package/src/backup/pg-tools.ts +96 -3
  49. package/src/cli-helpers.ts +70 -0
  50. package/src/cli.ts +44 -26
  51. package/src/collections/validate-relations.ts +15 -0
  52. package/src/connection.ts +73 -0
  53. package/src/data-transformer.ts +9 -3
  54. package/src/databasePoolManager.ts +5 -2
  55. package/src/schema/ensure-collection-policies.ts +105 -0
  56. package/src/schema/ensure-collection-tables.test.ts +105 -9
  57. package/src/schema/ensure-collection-tables.ts +220 -32
  58. package/src/schema/generate-drizzle-schema-logic.ts +16 -3
  59. package/src/schema/generate-postgres-ddl-logic.ts +375 -16
  60. package/src/schema/introspect-db-constraints.ts +385 -0
  61. package/src/schema/introspect-db-inference.ts +18 -8
  62. package/src/schema/introspect-db-logic.ts +385 -71
  63. package/src/schema/introspect-db-queries.ts +326 -0
  64. package/src/schema/introspect-db-structure.ts +670 -0
  65. package/src/schema/introspect-db-types.ts +56 -0
  66. package/src/schema/introspect-db.ts +37 -80
  67. package/src/schema/introspect-runtime.test.ts +56 -8
  68. package/src/schema/introspect-runtime.ts +31 -9
  69. package/src/security/policy-drift.test.ts +11 -3
  70. package/src/services/FetchService.ts +76 -14
  71. package/src/services/PersistService.ts +20 -6
  72. package/src/services/RelationService.ts +249 -48
  73. package/src/services/channel-bus/index.ts +0 -9
  74. package/src/services/collection-helpers.ts +40 -1
  75. package/src/services/dataService.ts +3 -1
  76. package/src/services/realtimeService.ts +3 -3
  77. package/src/services/row-pipeline.ts +1 -1
  78. package/src/utils/connection-string.ts +58 -0
  79. package/src/utils/drizzle-conditions.ts +31 -6
  80. package/src/utils/pg-error-utils.ts +46 -0
  81. package/src/websocket.ts +18 -9
  82. package/dist/backup-service-DH9kPg-E.js.map +0 -1
  83. package/dist/connection-B5Wndbr1.js.map +0 -1
  84. package/dist/ensure-collection-tables-CIHSH962.js +0 -304
  85. package/dist/ensure-collection-tables-CIHSH962.js.map +0 -1
  86. package/dist/src-DihrDFuP.js.map +0 -1
  87. 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 { C as updateDateAutoValues, D as mergeDeep, E as getPolicyNamesForRule, M as hasForeignKeyOnTarget, N as isManyToMany, O as camelCase, P as Vector, S as normalizeToEntityRelation, _ 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, k as toSnakeCase, 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-CzbghKwf.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.
@@ -281,6 +315,35 @@ function requirePrimaryKeys(collection, registry) {
281
315
  return keys;
282
316
  }
283
317
  /**
318
+ * The column on the *source* table that a `hasOne`/`hasMany` link points at.
319
+ *
320
+ * `sourceKey` is authored when the two sides join on a natural key — an
321
+ * external identity id, a SKU — and left off when they join on the row id,
322
+ * which is the overwhelming majority. That makes `undefined` the only optional
323
+ * field on a resolved relation, so it gets exactly one reader: this function.
324
+ * Every consumer that needs the column asks here, and none of them re-derives
325
+ * "or else the primary key" for itself. That is the whole point — the fallback
326
+ * chains this codebase removed from relation resolution were dangerous because
327
+ * they were *duplicated* and could disagree, not because they existed.
328
+ */
329
+ function sourceKeyField(relation, sourceCollection, registry) {
330
+ if (relation.sourceKey) return relation.sourceKey;
331
+ return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
332
+ }
333
+ /**
334
+ * Whether this link joins on something other than the source's primary key.
335
+ *
336
+ * Callers that hold a parent *id* — which is most of them, since an id is what
337
+ * a URL carries — must translate it to the source key's value before it can be
338
+ * compared with the target's foreign key. Those that hold the parent *row*, or
339
+ * that build a correlated subquery over the source table, can read the column
340
+ * directly and skip the lookup.
341
+ */
342
+ function joinsOnNaturalKey(relation, sourceCollection, registry) {
343
+ if (!relation.sourceKey) return false;
344
+ return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
345
+ }
346
+ /**
284
347
  * Collections whose key the *browser* cannot resolve, and what it will do
285
348
  * instead.
286
349
  *
@@ -451,7 +514,9 @@ var DrizzleConditionBuilder = class {
451
514
  case "hasMany": {
452
515
  const fkColumn = targetTable[relation.foreignKeyOnTarget];
453
516
  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);
517
+ if (!relation.sourceKey) return eq(fkColumn, parentId);
518
+ const { table, idColumn } = parent();
519
+ return sql`${fkColumn} = (SELECT ${sql.identifier(relation.sourceKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
455
520
  }
456
521
  case "belongsTo": {
457
522
  const { table, idColumn } = parent();
@@ -541,12 +606,16 @@ var DrizzleConditionBuilder = class {
541
606
  column: foreignKey
542
607
  };
543
608
  }
544
- if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) return {
545
- kind: "relation",
546
- relation,
547
- registry,
548
- sourceIdColumn
549
- };
609
+ if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
610
+ const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey ? columnAt(relation.sourceKey) : sourceIdColumn;
611
+ 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.`);
612
+ return {
613
+ kind: "relation",
614
+ relation,
615
+ registry,
616
+ sourceIdColumn: correlationColumn
617
+ };
618
+ }
550
619
  }
551
620
  for (const guess of [`${field}_id`, generateForeignKeyName(field)]) {
552
621
  const foreignKey = columnAt(guess);
@@ -1345,8 +1414,9 @@ async function parseDataFromServer(data, collection, db, registry) {
1345
1414
  } else if (hasForeignKeyOnTarget(relation) && db && registry) try {
1346
1415
  const targetCollection = relation.target();
1347
1416
  const targetTable = registry.getTable(getTableName$1(targetCollection));
1348
- const currentId = buildCompositeId(data, getPrimaryKeys(collection, registry));
1349
- if (targetTable && currentId) {
1417
+ const pks = getPrimaryKeys(collection, registry);
1418
+ const currentId = relation.sourceKey ? data[relation.sourceKey] : buildCompositeId(data, pks);
1419
+ if (targetTable && currentId !== void 0 && currentId !== null && currentId !== "") {
1350
1420
  const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
1351
1421
  if (foreignKeyColumn) {
1352
1422
  const relatedRows = await db.select().from(targetTable).where(eq(foreignKeyColumn, currentId)).limit(relation.cardinality === "one" ? 1 : 100);
@@ -1614,6 +1684,33 @@ function normalizeDbValues(data, collection) {
1614
1684
  //#endregion
1615
1685
  //#region src/services/RelationService.ts
1616
1686
  /**
1687
+ * The ids in a to-many relation write, whatever shape the caller sent.
1688
+ *
1689
+ * A membership list is written as either the related rows (`[{ id: 1 }]`, what
1690
+ * the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
1691
+ * what anyone writing the API by hand sends). Only the first was read, via a
1692
+ * blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
1693
+ * on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
1694
+ * and on a string-keyed one it did not surface at all — `String(undefined)`
1695
+ * wrote a junction row pointing at the literal `"undefined"`, which no read
1696
+ * would ever match. Both shapes are accepted here, in one place, because both
1697
+ * call sites had the same assumption.
1698
+ *
1699
+ * An element that carries no key is refused rather than skipped: dropping it
1700
+ * would silently write a shorter membership list than the caller asked for.
1701
+ */
1702
+ function relationTargetIds(value, relationName, collectionSlug) {
1703
+ if (!Array.isArray(value)) return [];
1704
+ return value.map((element, index) => {
1705
+ if (typeof element === "string" || typeof element === "number") return element;
1706
+ if (element && typeof element === "object") {
1707
+ const id = element.id;
1708
+ if (typeof id === "string" || typeof id === "number") return id;
1709
+ }
1710
+ 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}.`);
1711
+ });
1712
+ }
1713
+ /**
1617
1714
  * Typed wrapper for Drizzle dynamic query innerJoin.
1618
1715
  * Drizzle's `$dynamic()` queries lose the `innerJoin` method from
1619
1716
  * their static type, but it exists at runtime. This helper bridges
@@ -1691,8 +1788,67 @@ var RelationService = class {
1691
1788
  * reading. Left alone it would silently match on the first key column and
1692
1789
  * hand a tenant's rows to its neighbour — say so instead.
1693
1790
  */
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.`);
1791
+ assertSingleKeyAddressable(parentCollection, parentPks, via, relation) {
1792
+ if (relation && hasForeignKeyOnTarget(relation) && relation.sourceKey) return;
1793
+ 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.`);
1794
+ }
1795
+ /**
1796
+ * What the target's foreign key holds, for each of these parent rows.
1797
+ *
1798
+ * Ordinarily the parent's id, and then this is free. When the relation
1799
+ * declares a `sourceKey` the two are different values, and the mapping
1800
+ * between them lives in the source table — so it costs one SELECT, issued
1801
+ * once for the whole batch rather than per parent.
1802
+ *
1803
+ * Both directions come back because both are needed and deriving one from
1804
+ * the other by hand is how a batch loader ends up attributing a child to the
1805
+ * wrong parent: reads translate id → key to build the WHERE, and then
1806
+ * translate key → id to attribute each row that comes back.
1807
+ */
1808
+ /**
1809
+ * The value a related row's foreign key must hold to belong to this parent.
1810
+ *
1811
+ * `undefined` when the parent's source key is null — which is not an error
1812
+ * here, only in the callers that were about to write it. Exposed for
1813
+ * {@link PersistService}, which stamps this onto a child created under a
1814
+ * nested path and would otherwise write the id and lose the row.
1815
+ */
1816
+ async parentKeyValue(parentCollection, relation, parentId, db = this.db) {
1817
+ const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
1818
+ return keyByParentId.get(String(parentId));
1819
+ }
1820
+ async resolveSourceKeys(parentCollection, relation, parentIds, db = this.db) {
1821
+ const keyByParentId = /* @__PURE__ */ new Map();
1822
+ const parentIdByKey = /* @__PURE__ */ new Map();
1823
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
1824
+ if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
1825
+ for (const id of parentIds) {
1826
+ const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
1827
+ keyByParentId.set(String(id), value);
1828
+ parentIdByKey.set(String(value), id);
1829
+ }
1830
+ return {
1831
+ keyByParentId,
1832
+ parentIdByKey
1833
+ };
1834
+ }
1835
+ const field = sourceKeyField(relation, parentCollection, this.registry);
1836
+ const parentTable = getTableForCollection(parentCollection, this.registry);
1837
+ 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.`);
1838
+ const rows = await db.select().from(parentTable).where(this.parentKeyCondition(parentTable, parentPks, parentIds));
1839
+ for (const row of rows) {
1840
+ const keyValue = row[field];
1841
+ if (keyValue === null || keyValue === void 0) continue;
1842
+ const parentId = buildCompositeId(row, parentPks);
1843
+ keyByParentId.set(String(parentId), keyValue);
1844
+ const existing = parentIdByKey.get(String(keyValue));
1845
+ 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.`);
1846
+ parentIdByKey.set(String(keyValue), parentId);
1847
+ }
1848
+ return {
1849
+ keyByParentId,
1850
+ parentIdByKey
1851
+ };
1696
1852
  }
1697
1853
  /**
1698
1854
  * Fetch rows related to a parent row through a specific relation
@@ -1751,6 +1907,8 @@ var RelationService = class {
1751
1907
  }
1752
1908
  return rows;
1753
1909
  }
1910
+ const matchValue = hasForeignKeyOnTarget(relation) ? (await this.resolveSourceKeys(parentCollection, relation, [parentId])).keyByParentId.get(String(parentId)) : parsedParentId;
1911
+ if (matchValue === void 0) return [];
1754
1912
  let query = this.db.select().from(targetTable).$dynamic();
1755
1913
  const additionalFilters = [];
1756
1914
  if (options.searchString) {
@@ -1759,7 +1917,7 @@ var RelationService = class {
1759
1917
  const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
1760
1918
  if (searchCombined) additionalFilters.push(searchCombined);
1761
1919
  }
1762
- query = applyDynamicRelationQuery(query, query, relation, parsedParentId, targetTable, parentTable, parentIdCol, idField, this.registry, additionalFilters);
1920
+ query = applyDynamicRelationQuery(query, query, relation, matchValue, targetTable, parentTable, parentIdCol, idField, this.registry, additionalFilters);
1763
1921
  if (options.limit) query = query.limit(options.limit);
1764
1922
  const results = await query;
1765
1923
  if (!results || !Array.isArray(results)) return [];
@@ -1802,8 +1960,10 @@ var RelationService = class {
1802
1960
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
1803
1961
  if (!parentTable) throw new Error("Parent table not found");
1804
1962
  const parentIdCol = parentTable[parentIdInfo.fieldName];
1963
+ const matchValue = hasForeignKeyOnTarget(relation) ? (await this.resolveSourceKeys(parentCollection, relation, [parentId])).keyByParentId.get(String(parentId)) : parsedParentId;
1964
+ if (matchValue === void 0) return 0;
1805
1965
  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);
1966
+ query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, matchValue, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
1807
1967
  const result = await query;
1808
1968
  return Number(result[0]?.count || 0);
1809
1969
  }
@@ -1930,17 +2090,24 @@ var RelationService = class {
1930
2090
  }
1931
2091
  return resultMap;
1932
2092
  }
1933
- this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
2093
+ this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName, relation);
2094
+ const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation) ? await this.resolveSourceKeys(parentCollection, relation, parentIds) : {
2095
+ keyByParentId: /* @__PURE__ */ new Map(),
2096
+ parentIdByKey: /* @__PURE__ */ new Map()
2097
+ };
2098
+ const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
2099
+ if (matchValues.length === 0) return /* @__PURE__ */ new Map();
1934
2100
  let query = this.db.select().from(targetTable).$dynamic();
1935
- query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
2101
+ query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
1936
2102
  const results = await query;
1937
2103
  const resultMap = /* @__PURE__ */ new Map();
1938
- const parentIdSet = new Set(parsedParentIds.map(String));
1939
2104
  for (const row of results) {
1940
2105
  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));
2106
+ if (!hasForeignKeyOnTarget(relation)) continue;
2107
+ const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
2108
+ if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
2109
+ const parentId = parentIdByKey.get(String(foreignKeyValue));
2110
+ if (parentId !== void 0) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
1944
2111
  }
1945
2112
  return resultMap;
1946
2113
  }
@@ -2018,17 +2185,24 @@ var RelationService = class {
2018
2185
  }
2019
2186
  return resultMap;
2020
2187
  }
2021
- this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName);
2188
+ this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName, relation);
2189
+ const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation) ? await this.resolveSourceKeys(parentCollection, relation, parentIds) : {
2190
+ keyByParentId: /* @__PURE__ */ new Map(),
2191
+ parentIdByKey: /* @__PURE__ */ new Map()
2192
+ };
2193
+ const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
2194
+ if (matchValues.length === 0) return /* @__PURE__ */ new Map();
2022
2195
  let query = this.db.select().from(targetTable).$dynamic();
2023
- query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
2196
+ query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
2024
2197
  const results = await query;
2025
2198
  const resultMap = /* @__PURE__ */ new Map();
2026
- const parentIdSet = new Set(parsedParentIds.map(String));
2027
2199
  for (const row of results) {
2028
2200
  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))) {
2201
+ if (!hasForeignKeyOnTarget(relation)) continue;
2202
+ const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
2203
+ if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
2204
+ const parentId = parentIdByKey.get(String(foreignKeyValue));
2205
+ if (parentId !== void 0) {
2032
2206
  const key = String(parentId);
2033
2207
  const arr = resultMap.get(key) || [];
2034
2208
  arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
@@ -2045,7 +2219,7 @@ var RelationService = class {
2045
2219
  for (const [key, value] of Object.entries(relationValues)) {
2046
2220
  const relation = findRelation(resolvedRelations, key);
2047
2221
  if (!relation || relation.cardinality !== "many") continue;
2048
- const targetEntityIds = value && Array.isArray(value) ? value.map((rel) => rel.id) : [];
2222
+ const targetEntityIds = relationTargetIds(value, key, collection.slug);
2049
2223
  const targetCollection = relation.target();
2050
2224
  if (relation.kind === "via") {
2051
2225
  const parentTableName = getTableName$1(collection);
@@ -2127,14 +2301,13 @@ var RelationService = class {
2127
2301
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
2128
2302
  continue;
2129
2303
  }
2130
- const parentPks = requirePrimaryKeys(collection, this.registry);
2131
- const parentIdInfo = parentPks[0];
2132
- const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
2304
+ const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
2305
+ 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
2306
  if (targetEntityIds.length > 0) {
2134
2307
  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));
2308
+ await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
2309
+ await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
2310
+ } else await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(fkCol, parentKeyValue));
2138
2311
  } else logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
2139
2312
  }
2140
2313
  }
@@ -2149,8 +2322,7 @@ var RelationService = class {
2149
2322
  const targetTable = getTableForCollection(targetCollection, this.registry);
2150
2323
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2151
2324
  const targetIdInfo = targetPks[0];
2152
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
2153
- const sourceIdInfo = sourcePks[0];
2325
+ requirePrimaryKeys(sourceCollection, this.registry)[0];
2154
2326
  if (relation.kind === "via") {
2155
2327
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
2156
2328
  continue;
@@ -2172,13 +2344,14 @@ var RelationService = class {
2172
2344
  logger.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
2173
2345
  continue;
2174
2346
  }
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));
2347
+ const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
2348
+ 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.`);
2349
+ if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
2177
2350
  else {
2178
2351
  const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
2179
2352
  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));
2353
+ await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
2354
+ await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
2182
2355
  }
2183
2356
  } catch (e) {
2184
2357
  logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
@@ -2225,7 +2398,7 @@ var RelationService = class {
2225
2398
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
2226
2399
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2227
2400
  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) => ({
2401
+ const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2229
2402
  [sourceJunctionColumn.name]: parsedSourceId,
2230
2403
  [targetJunctionColumn.name]: targetId
2231
2404
  }));
@@ -2269,7 +2442,7 @@ var RelationService = class {
2269
2442
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
2270
2443
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2271
2444
  const targetIdInfo = targetPks[0];
2272
- const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2445
+ const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2273
2446
  [sourceJunctionColumn.name]: parsedSourceId,
2274
2447
  [targetJunctionColumn.name]: targetId
2275
2448
  }));
@@ -2484,7 +2657,7 @@ function relationTargetAddress(targetRow, targetCollection, registry) {
2484
2657
  * expects real types. The row's own address is *not* among the columns — it is
2485
2658
  * derived by the consumer from the collection's primary keys.
2486
2659
  */
2487
- function toCmsRow(row, collection, registry) {
2660
+ function toFlatRow(row, collection, registry) {
2488
2661
  const resolvedRelations = resolveCollectionRelations(collection);
2489
2662
  const normalized = normalizeDbValues(row, collection);
2490
2663
  for (const [key, relation] of Object.entries(resolvedRelations)) {
@@ -2681,17 +2854,35 @@ var FetchService = class {
2681
2854
  const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
2682
2855
  const direct = columnAt(orderBy);
2683
2856
  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
- }
2857
+ const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : void 0;
2858
+ if (declaredRelation?.kind === "belongsTo") {
2859
+ const foreignKey = columnAt(declaredRelation.localKey);
2860
+ if (foreignKey) return foreignKey;
2690
2861
  }
2691
2862
  for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
2692
2863
  const foreignKey = columnAt(guess);
2693
2864
  if (foreignKey) return foreignKey;
2694
2865
  }
2866
+ const collectionName = collection?.slug ?? collection?.name;
2867
+ const onCollection = collectionName ? ` on collection '${collectionName}'` : "";
2868
+ 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", {
2869
+ field: orderBy,
2870
+ kind: declaredRelation.kind,
2871
+ ...collectionName && { collection: collectionName }
2872
+ });
2873
+ if (getUnknownFilterFieldsMode() === "warn") {
2874
+ 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.`);
2875
+ return;
2876
+ }
2877
+ let validFields = [];
2878
+ try {
2879
+ validFields = Object.keys(getTableColumns(table)).sort();
2880
+ } catch {}
2881
+ throw ApiError.badRequest(`Unknown orderBy field '${orderBy}'${onCollection}` + (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""), "UNKNOWN_ORDER_BY_FIELD", {
2882
+ field: orderBy,
2883
+ ...collectionName && { collection: collectionName },
2884
+ ...validFields.length > 0 && { validFields }
2885
+ });
2695
2886
  }
2696
2887
  /**
2697
2888
  * Build the `with` config for Drizzle's relational query API.
@@ -2905,7 +3096,7 @@ var FetchService = class {
2905
3096
  with: withConfig
2906
3097
  });
2907
3098
  if (!row) return void 0;
2908
- const flatRow = toCmsRow(row, collection, this.registry);
3099
+ const flatRow = toFlatRow(row, collection, this.registry);
2909
3100
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
2910
3101
  return flatRow;
2911
3102
  } catch (e) {
@@ -2960,7 +3151,7 @@ var FetchService = class {
2960
3151
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
2961
3152
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
2962
3153
  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));
3154
+ return (await qb.findMany(queryOpts)).map((row) => toFlatRow(row, collection, this.registry));
2964
3155
  } catch (e) {
2965
3156
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
2966
3157
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
@@ -3023,7 +3214,7 @@ var FetchService = class {
3023
3214
  /**
3024
3215
  * Fallback path used when db.query is unavailable.
3025
3216
  *
3026
- * The primary path runs the results through `toCmsRow`, which maps
3217
+ * The primary path runs the results through `toFlatRow`, which maps
3027
3218
  * relations from what drizzle already nested — no query per row. This one
3028
3219
  * has no nesting to read, so it resolves relations itself, in batches.
3029
3220
  *
@@ -3116,6 +3307,10 @@ var FetchService = class {
3116
3307
  const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
3117
3308
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
3118
3309
  }
3310
+ if (options.logical) {
3311
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, effectivePath, this.filterContext(effectivePath, table));
3312
+ if (logicalCondition) allConditions.push(logicalCondition);
3313
+ }
3119
3314
  if (allConditions.length > 0) {
3120
3315
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
3121
3316
  if (finalCondition) query = query.where(finalCondition);
@@ -3453,6 +3648,38 @@ function extractCauseMessage(error) {
3453
3648
  return null;
3454
3649
  }
3455
3650
  /**
3651
+ * Codes that mean "this connection will never work as configured".
3652
+ *
3653
+ * A wrong password or a database that does not exist is a settled fact about
3654
+ * the connection string, not a transient fault — retrying produces the same
3655
+ * answer forever.
3656
+ */
3657
+ var UNRECOVERABLE_CONNECT_CODES = /* @__PURE__ */ new Set([
3658
+ "28P01",
3659
+ "28000",
3660
+ "3D000",
3661
+ "42501"
3662
+ ]);
3663
+ /**
3664
+ * Describe a failed connection attempt in terms a developer can act on.
3665
+ *
3666
+ * The error a caller catches is Drizzle's wrapper: its message is
3667
+ * `Failed query: SELECT 1` and its stack runs through drizzle internals, while
3668
+ * the sentence that says what is actually wrong — "password authentication
3669
+ * failed for user …", "database … does not exist" — sits in `.cause`. Logging
3670
+ * the wrapper, as the bootstrapper used to, tells a developer with a typo in
3671
+ * their `DATABASE_URL` nothing at all.
3672
+ */
3673
+ function classifyConnectFailure(error) {
3674
+ const pgError = extractPgError(error);
3675
+ const reason = pgError?.message ?? extractCauseMessage(error) ?? (error instanceof Error ? error.message : String(error));
3676
+ return {
3677
+ fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
3678
+ reason,
3679
+ code: pgError?.code
3680
+ };
3681
+ }
3682
+ /**
3456
3683
  * Detect whether an error is specifically a role-switching permission failure
3457
3684
  * (e.g. "permission denied to set role" or "must be member of role"),
3458
3685
  * as opposed to a table-level permission denial.
@@ -3715,10 +3942,11 @@ var PersistService = class {
3715
3942
  } else {
3716
3943
  const targetColumnName = this.resolveParentForeignKeyColumn(hop);
3717
3944
  if (targetColumnName) {
3718
- const parsedParentId = parentIdForWrite();
3945
+ const parentKeyValue = hasForeignKeyOnTarget(hop.relation) ? await this.relationService.parentKeyValue(hop.parentCollection, hop.relation, hop.parentId) : parentIdForWrite();
3946
+ 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.`);
3719
3947
  const existingValue = effectiveValues[targetColumnName];
3720
- if (existingValue !== void 0 && existingValue !== null && existingValue !== parsedParentId) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
3721
- effectiveValues[targetColumnName] = parsedParentId;
3948
+ if (existingValue !== void 0 && existingValue !== null && existingValue !== parentKeyValue) logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent key '${parentKeyValue}'.`);
3949
+ effectiveValues[targetColumnName] = parentKeyValue;
3722
3950
  }
3723
3951
  }
3724
3952
  }
@@ -4996,9 +5224,10 @@ var PostgresBackendDriver = class PostgresBackendDriver {
4996
5224
  async checkUniqueField(path, name, value, id, collection) {
4997
5225
  return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
4998
5226
  }
4999
- async count({ path, collection, filter, searchString }) {
5227
+ async count({ path, collection, filter, logical, searchString }) {
5000
5228
  return this.dataService.count(path, {
5001
5229
  filter,
5230
+ logical,
5002
5231
  searchString
5003
5232
  });
5004
5233
  }
@@ -5367,7 +5596,7 @@ var DatabasePoolManager = class {
5367
5596
  const url = new URL(this.rootConnectionString);
5368
5597
  url.pathname = `/${databaseName}`;
5369
5598
  const pool = new Pool({
5370
- connectionString: url.toString(),
5599
+ connectionString: pinSearchPath(url.toString()),
5371
5600
  max: 10,
5372
5601
  idleTimeoutMillis: 1e4,
5373
5602
  allowExitOnIdle: true
@@ -5955,8 +6184,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
5955
6184
  const tableVarName = getTableVarName(tableName);
5956
6185
  if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
5957
6186
  const targetCollection = relation.target();
5958
- const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : void 0) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : void 0);
5959
- const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
6187
+ const tableCreator = "pgTable";
5960
6188
  const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
5961
6189
  const { sourceColumn, targetColumn } = relation.through;
5962
6190
  const refOptions = `{ onDelete: \"${relation.onDelete ?? "cascade"}\" }`;
@@ -6070,8 +6298,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
6070
6298
  if (!emittedRelationNames.has(deduplicationKey)) {
6071
6299
  const otherTableVar = getTableVarName(getTableName$1(otherCollection));
6072
6300
  const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
6301
+ const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
6073
6302
  const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
6074
- tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${getPrimaryKeyName(otherCollection)}],\n relationName: \"${drizzleRelationName}\"\n })`);
6303
+ tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${referencedKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
6075
6304
  emittedRelationNames.add(deduplicationKey);
6076
6305
  }
6077
6306
  }
@@ -7229,14 +7458,6 @@ function resolveChannelBusSetting(configured) {
7229
7458
  return raw === "memory" ? { type: "memory" } : { type: "postgres" };
7230
7459
  }
7231
7460
  /**
7232
- * @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
7233
- * supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
7234
- * config-only callers keep their exact types.
7235
- */
7236
- function resolveChannelBusConfig(configured) {
7237
- return resolveChannelBusSetting(configured);
7238
- }
7239
- /**
7240
7461
  * Produce the bus a setting asks for.
7241
7462
  *
7242
7463
  * An instance is handed straight back — constructing it was the application's
@@ -7713,7 +7934,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7713
7934
  if (this.driver) {
7714
7935
  const collection = this.registry.getCollectionByPath(notifyPath);
7715
7936
  const activeAuth = authContext || {
7716
- uid: "anon",
7937
+ uid: "anonymous",
7717
7938
  roles: ["anon"]
7718
7939
  };
7719
7940
  return await this.db.transaction(async (tx) => {
@@ -7842,7 +8063,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7842
8063
  if (this.driver) {
7843
8064
  const collection = this.registry.getCollectionByPath(notifyPath);
7844
8065
  const activeAuth = authContext || {
7845
- uid: "anon",
8066
+ uid: "anonymous",
7846
8067
  roles: ["anon"]
7847
8068
  };
7848
8069
  return await this.db.transaction(async (tx) => {
@@ -8847,7 +9068,7 @@ function createBackupCron(config) {
8847
9068
  enabled: config.enabled ?? true,
8848
9069
  timeoutSeconds: 3600,
8849
9070
  async handler({ log }) {
8850
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-DH9kPg-E.js").then((n) => n.r);
9071
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-CD8o_1Sl.js").then((n) => n.r);
8851
9072
  const { destination } = config;
8852
9073
  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 }).`);
8853
9074
  log(`Starting backup of "${dbName}"…`);
@@ -8978,6 +9199,11 @@ function findRelationDefects(collections, registry) {
8978
9199
  problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
8979
9200
  fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
8980
9201
  });
9202
+ if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) defects.push({
9203
+ ...at,
9204
+ problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
9205
+ 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)}`
9206
+ });
8981
9207
  break;
8982
9208
  case "manyToMany": {
8983
9209
  const { table, sourceColumn, targetColumn } = relation.through;
@@ -9729,21 +9955,6 @@ async function ensureAuthTablesExist(db, collection) {
9729
9955
  }
9730
9956
  //#endregion
9731
9957
  //#region src/auth/services.ts
9732
- function getColumnKey(table, ...keys) {
9733
- if (!table) return void 0;
9734
- for (const key of keys) {
9735
- if (key in table) return key;
9736
- const snake = toSnakeCase(key);
9737
- if (snake in table) return snake;
9738
- const camel = camelCase(key);
9739
- if (camel in table) return camel;
9740
- }
9741
- }
9742
- function getColumn(table, ...keys) {
9743
- if (!table) return void 0;
9744
- const key = getColumnKey(table, ...keys);
9745
- return key ? table[key] : void 0;
9746
- }
9747
9958
  /**
9748
9959
  * The single definition of what an email address looks like in storage.
9749
9960
  *
@@ -9758,9 +9969,25 @@ function getColumn(table, ...keys) {
9758
9969
  *
9759
9970
  * Whitespace goes too: a trailing space survives the fold and reproduces the
9760
9971
  * problem exactly.
9972
+ *
9973
+ * Re-exported rather than defined here: `@rebasepro/server` and
9974
+ * `@rebasepro/server-mongo` write this column too, and a second copy of this
9975
+ * rule is the defect it exists to prevent.
9761
9976
  */
9762
- function normalizeEmail(email) {
9763
- return typeof email === "string" ? email.trim().toLowerCase() : email;
9977
+ function getColumnKey(table, ...keys) {
9978
+ if (!table) return void 0;
9979
+ for (const key of keys) {
9980
+ if (key in table) return key;
9981
+ const snake = toSnakeCase(key);
9982
+ if (snake in table) return snake;
9983
+ const camel = camelCase(key);
9984
+ if (camel in table) return camel;
9985
+ }
9986
+ }
9987
+ function getColumn(table, ...keys) {
9988
+ if (!table) return void 0;
9989
+ const key = getColumnKey(table, ...keys);
9990
+ return key ? table[key] : void 0;
9764
9991
  }
9765
9992
  /**
9766
9993
  * PostgreSQL implementation of UserRepository.
@@ -11101,6 +11328,31 @@ function humanize(snakeName) {
11101
11328
  return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
11102
11329
  }
11103
11330
  //#endregion
11331
+ //#region src/schema/introspect-db-types.ts
11332
+ /**
11333
+ * The PostgreSQL type → Rebase property type mapping.
11334
+ *
11335
+ * Split out of `introspect-db-logic` so that the structural analysis can use it
11336
+ * without importing the generator, which imports the analysis. Re-exported from
11337
+ * `introspect-db-logic` so existing callers keep their import path.
11338
+ */
11339
+ /**
11340
+ * Map a PostgreSQL data type to a Rebase property type.
11341
+ */
11342
+ function mapPgType(dataType) {
11343
+ const dt = dataType.toLowerCase();
11344
+ if (dt === "interval") return "string";
11345
+ if (dt === "array" || dt.startsWith("_")) return "array";
11346
+ 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";
11347
+ if (dt.includes("bool")) return "boolean";
11348
+ if (dt.includes("time") || dt.includes("date")) return "date";
11349
+ if (dt === "json" || dt === "jsonb") return "map";
11350
+ if (dt === "bytea") return "binary";
11351
+ if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
11352
+ if (dt === "uuid") return "string";
11353
+ return "string";
11354
+ }
11355
+ //#endregion
11104
11356
  //#region src/schema/introspect-db-logic.ts
11105
11357
  var IRREGULAR_SINGULARS = {
11106
11358
  people: "person",
@@ -11116,6 +11368,36 @@ var IRREGULAR_SINGULARS = {
11116
11368
  criteria: "criterion",
11117
11369
  phenomena: "phenomenon"
11118
11370
  };
11371
+ /**
11372
+ * Plurals in "-ves" whose singular really ends in f/fe, and which of the two.
11373
+ *
11374
+ * A blanket "-ves" -> "-f" rule gets `knives` -> `knif`, and mangles every
11375
+ * ordinary "-ive" noun that happens to be plural along with it: `archives` ->
11376
+ * `archif`, `objectives` -> `objectif`. The set of English words that genuinely
11377
+ * swap f/fe for ves is small and closed, so it is listed rather than guessed —
11378
+ * anything else ending in "ves" drops the trailing 's' like any other plural.
11379
+ *
11380
+ * Matched on the whole word, not on the suffix: `olives` ends in `lives`.
11381
+ */
11382
+ var VES_SINGULAR_ENDINGS = {
11383
+ calves: "f",
11384
+ dwarves: "f",
11385
+ elves: "f",
11386
+ halves: "f",
11387
+ hooves: "f",
11388
+ leaves: "f",
11389
+ loaves: "f",
11390
+ scarves: "f",
11391
+ selves: "f",
11392
+ sheaves: "f",
11393
+ shelves: "f",
11394
+ thieves: "f",
11395
+ wharves: "f",
11396
+ wolves: "f",
11397
+ knives: "fe",
11398
+ lives: "fe",
11399
+ wives: "fe"
11400
+ };
11119
11401
  /** Words ending in 's' that are already singular. */
11120
11402
  var UNCOUNTABLE = /* @__PURE__ */ new Set([
11121
11403
  "status",
@@ -11154,7 +11436,7 @@ function singularize(word) {
11154
11436
  if (UNCOUNTABLE.has(lower)) return word;
11155
11437
  if (lower.endsWith("ices") && lower.length > 5) return word.slice(0, -4) + "ex";
11156
11438
  if (lower.endsWith("ies") && lower.length > 3) return word.slice(0, -3) + "y";
11157
- if (lower.endsWith("ves")) return word.slice(0, -3) + "f";
11439
+ if (VES_SINGULAR_ENDINGS[lower]) return word.slice(0, -3) + VES_SINGULAR_ENDINGS[lower];
11158
11440
  if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) return word.slice(0, -2);
11159
11441
  if (lower.endsWith("ses") && !lower.endsWith("sses")) return word.slice(0, -1);
11160
11442
  if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
@@ -11175,22 +11457,6 @@ function getIconForTable(tableName) {
11175
11457
  if (table.includes("comment") || table.includes("review") || table.includes("feedback")) return "MessageCircle";
11176
11458
  return "Database";
11177
11459
  }
11178
- /**
11179
- * Map a PostgreSQL data type to a Rebase property type.
11180
- */
11181
- function mapPgType(dataType) {
11182
- const dt = dataType.toLowerCase();
11183
- if (dt === "interval") return "string";
11184
- if (dt === "array" || dt.startsWith("_")) return "array";
11185
- 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";
11186
- if (dt.includes("bool")) return "boolean";
11187
- if (dt.includes("time") || dt.includes("date")) return "date";
11188
- if (dt === "json" || dt === "jsonb") return "map";
11189
- if (dt === "bytea") return "binary";
11190
- if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
11191
- if (dt === "uuid") return "string";
11192
- return "string";
11193
- }
11194
11460
  function buildEnumMap(enumValues) {
11195
11461
  const enumMap = /* @__PURE__ */ new Map();
11196
11462
  for (const ev of enumValues) {
@@ -11210,6 +11476,20 @@ function buildTablesMap(tables, columns, pks, fks) {
11210
11476
  });
11211
11477
  return tablesMap;
11212
11478
  }
11479
+ /**
11480
+ * Join tables, identified by column name.
11481
+ *
11482
+ * Superseded for the CLI by `classifyTables` in `./introspect-db-structure`,
11483
+ * which asks the database instead: two single-column keys, unique together, no
11484
+ * payload column, nothing referencing the table. This rule folds away
11485
+ * `northwind.order_details` — which has the key shape and carries unit price,
11486
+ * quantity and discount — because it recognises `id`, `created_at` and
11487
+ * `updated_at` by name and calls everything else a foreign key.
11488
+ *
11489
+ * Still used by `./introspect-runtime`, which builds collections in memory from
11490
+ * a narrower set of catalog queries and has no unique-constraint or row-count
11491
+ * data to reason with.
11492
+ */
11213
11493
  function identifyJoinTables(tablesMap) {
11214
11494
  const joinTables = /* @__PURE__ */ new Set();
11215
11495
  for (const [tableName, meta] of tablesMap.entries()) if (meta.fks.length === 2) {
@@ -11332,11 +11612,27 @@ function buildProperties(meta, enumMap) {
11332
11612
  return properties;
11333
11613
  }
11334
11614
  /**
11335
- * Owning relations, derived from this table's foreign keys. Mirrors the shape
11336
- * `generateCollectionFile` emits, except `target` uses the slug string form
11337
- * rather than a thunk to a module import.
11338
- */
11339
- function buildRelations(meta, slugByTable) {
11615
+ * Owning relations, derived from this table's foreign keys — the same shape
11616
+ * `generateCollectionFile` writes into a collection file: a `relation` property
11617
+ * whose nested descriptor carries `kind`, a `target` thunk and the `localKey`.
11618
+ *
11619
+ * The shape is load-bearing, not cosmetic. `resolveCollectionRelations` reads
11620
+ * relations from `property.relation` and `resolveRelation` requires `target` to
11621
+ * be a thunk; this used to emit `target`/`cardinality`/`localKey` flat on the
11622
+ * property with the slug as a bare string, which satisfies neither. Nothing
11623
+ * threw — the resolver simply skipped every such property and reported that the
11624
+ * collection had no relations. So an introspected BaaS collection had its FK
11625
+ * columns removed from `properties` (they "surface as relations") and then no
11626
+ * resolvable relation to surface as, which is why writing the FK column
11627
+ * directly came back as `has no field 'product_id'`: `assertKnownWriteFields`
11628
+ * learns that column from the resolved relation's `localKey`.
11629
+ *
11630
+ * The thunk closes over the collections being built in this same pass rather
11631
+ * than importing a module, which is what a runtime introspection has instead of
11632
+ * generated files. It is called lazily, after the map is fully populated, so a
11633
+ * table may reference one introspected later.
11634
+ */
11635
+ function buildRelations(meta, slugByTable, collectionBySlug) {
11340
11636
  const relations = {};
11341
11637
  for (const fk of meta.fks) {
11342
11638
  const targetSlug = slugByTable.get(fk.foreign_table_name);
@@ -11346,10 +11642,11 @@ function buildRelations(meta, slugByTable) {
11346
11642
  relations[key] = {
11347
11643
  name: humanize(key),
11348
11644
  type: "relation",
11349
- target: targetSlug,
11350
- cardinality: "one",
11351
- direction: "owning",
11352
- localKey: fk.column_name
11645
+ relation: {
11646
+ kind: "belongsTo",
11647
+ target: () => collectionBySlug.get(targetSlug),
11648
+ localKey: fk.column_name
11649
+ }
11353
11650
  };
11354
11651
  }
11355
11652
  return relations;
@@ -11364,6 +11661,7 @@ function buildCollectionsFromSchema({ tablesMap, enumMap, joinTables }, pgSchema
11364
11661
  const slugByTable = /* @__PURE__ */ new Map();
11365
11662
  for (const tableName of tablesMap.keys()) if (!joinTables.has(tableName)) slugByTable.set(tableName, tableName);
11366
11663
  const collections = [];
11664
+ const collectionBySlug = /* @__PURE__ */ new Map();
11367
11665
  for (const [tableName, meta] of tablesMap) {
11368
11666
  if (joinTables.has(tableName)) continue;
11369
11667
  const collectionName = humanize(tableName);
@@ -11376,10 +11674,11 @@ function buildCollectionsFromSchema({ tablesMap, enumMap, joinTables }, pgSchema
11376
11674
  icon: getIconForTable(tableName),
11377
11675
  properties: {
11378
11676
  ...buildProperties(meta, enumMap),
11379
- ...buildRelations(meta, slugByTable)
11677
+ ...buildRelations(meta, slugByTable, collectionBySlug)
11380
11678
  }
11381
11679
  };
11382
11680
  collections.push(collection);
11681
+ collectionBySlug.set(tableName, collection);
11383
11682
  }
11384
11683
  return collections;
11385
11684
  }
@@ -11569,6 +11868,32 @@ function isEconnrefused(err) {
11569
11868
  * Implements the `BackendBootstrapper` interface for PostgreSQL.
11570
11869
  */
11571
11870
  /**
11871
+ * Which table name the boot-time drift check should look for, for one collection.
11872
+ *
11873
+ * A declared `table` IS the table name, not a hint to be second-guessed. This
11874
+ * used to ask the registry whether it had indexed the declared name and fall
11875
+ * back to the SLUG when it had not — but "the registry does not know this table"
11876
+ * is exactly the condition the drift check exists to report, so the fallback
11877
+ * fired precisely when it was most harmful.
11878
+ *
11879
+ * A collection with `slug: "usage-daily", table: "usage_daily"` was reported as
11880
+ * missing table `usage-daily`: a name that does not exist, should never exist,
11881
+ * and that nobody can find by looking. Worse, the remediation the caller prints
11882
+ * says to run `rebase db push` — which would then CREATE that invented table
11883
+ * beside the correct one, the same "second copy" hazard the misplaced-schema
11884
+ * branch further down exists to prevent. Seen in production, where a correctly
11885
+ * migrated database reported drift on every boot.
11886
+ *
11887
+ * The slug is used only when nothing was declared, which is the config shape
11888
+ * where the slug genuinely is the table name.
11889
+ *
11890
+ * Exported for its own test: the caller needs a live pool and a real database,
11891
+ * and this is the part that was wrong.
11892
+ */
11893
+ function resolveDriftCheckName(col, registeredTableNames) {
11894
+ return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
11895
+ }
11896
+ /**
11572
11897
  * Default PostgreSQL bootstrapper.
11573
11898
  *
11574
11899
  * Use it to register Postgres with `initializeRebaseBackend()`:
@@ -11639,14 +11964,25 @@ function createPostgresBootstrapper(pgConfig) {
11639
11964
  logger.error(message);
11640
11965
  throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
11641
11966
  }
11642
- logger.error("❌ Failed to connect to PostgreSQL", { error: err });
11967
+ const { fatal, reason, code } = classifyConnectFailure(err);
11968
+ const detail = code ? ` [${code}]` : "";
11969
+ if (fatal) {
11970
+ logger.error(`
11971
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11972
+ ❌ PostgreSQL refused the connection
11973
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11974
+
11975
+ ${reason}${detail}\n\n The server is reachable, so this is the credentials or the\n database name in DATABASE_URL — check them in your .env.\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
11976
+ throw new Error(`PostgreSQL refused the connection: ${reason}${detail}`);
11977
+ }
11978
+ logger.error(`❌ Failed to connect to PostgreSQL: ${reason}${detail}`, { error: err });
11643
11979
  logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
11644
11980
  }
11645
11981
  const realtimeService = new RealtimeService(schemaAwareDb, registry);
11646
11982
  let readDb;
11647
11983
  const readUrl = process.env.DATABASE_READ_URL;
11648
11984
  if (readUrl && readUrl !== pgConfig.connectionString) try {
11649
- const { createReadReplicaConnection } = await import("./connection-B5Wndbr1.js").then((n) => n.t);
11985
+ const { createReadReplicaConnection } = await import("./connection-BuZ97wsr.js").then((n) => n.t);
11650
11986
  readDb = createReadReplicaConnection(readUrl, mergedSchema).db;
11651
11987
  logger.info("📖 [PostgresBootstrapper] Read replica connection established");
11652
11988
  } catch (err) {
@@ -11748,40 +12084,61 @@ function createPostgresBootstrapper(pgConfig) {
11748
12084
  try {
11749
12085
  const registeredCollections = registry.getCollections();
11750
12086
  if (registeredCollections.length > 0) {
11751
- const schemasList = Array.from(new Set(registeredCollections.map((c) => "schema" in c && c.schema ? c.schema : "public"))).map((s) => `'${s}'`).join(",");
11752
12087
  const result = await schemaAwareDb.execute(sql.raw(`
11753
12088
  SELECT table_name, table_schema
11754
12089
  FROM information_schema.tables
11755
- WHERE table_schema IN (${schemasList})
12090
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
11756
12091
  AND table_type = 'BASE TABLE'
11757
12092
  `));
12093
+ const tablesByName = /* @__PURE__ */ new Map();
12094
+ for (const row of result.rows) {
12095
+ const schemas = tablesByName.get(row.table_name) ?? [];
12096
+ schemas.push(row.table_schema);
12097
+ tablesByName.set(row.table_name, schemas);
12098
+ }
11758
12099
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
11759
12100
  const missing = [];
11760
12101
  for (const col of registeredCollections) {
11761
12102
  if (col.auth?.enabled) continue;
11762
12103
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
11763
- const declaredTable = isRelationalCollectionConfig(col) ? col.table : void 0;
11764
- const tableName = registry.hasTableForCollection(declaredTable ?? col.slug) ? declaredTable ?? col.slug : col.slug;
11765
- const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
12104
+ const checkName = resolveDriftCheckName(col, registry.getTableNames());
11766
12105
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
11767
12106
  if (!dbTables.has(fullCheckName)) missing.push({
11768
12107
  slug: col.slug,
11769
- table: fullCheckName
12108
+ table: fullCheckName,
12109
+ foundIn: (tablesByName.get(checkName) ?? []).filter((s) => s !== schemaName)
11770
12110
  });
11771
12111
  }
11772
12112
  if (missing.length > 0) {
11773
- const lines = missing.map((m) => ` • collection "${m.slug}" → table "${m.table}"`);
12113
+ 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(", ")}` : ""));
12114
+ const misplaced = missing.filter((m) => m.foundIn.length > 0);
12115
+ const misplacedHelp = misplaced.length === 0 ? [] : [
12116
+ " Those tables exist — in the wrong schema. Unqualified SQL run by a",
12117
+ " role whose name matches a schema (`rebase` is both, by default)",
12118
+ " resolves through search_path's \"$user\" and creates there. Move them:",
12119
+ ...misplaced.map((m) => ` ALTER TABLE "${m.foundIn[0]}"."${m.table.split(".").pop()}" SET SCHEMA "${m.table.includes(".") ? m.table.split(".")[0] : "public"}";`),
12120
+ " and qualify the SQL that created them, or pin search_path in DATABASE_URL",
12121
+ " (`?options=-c%20search_path%3Dpublic`).",
12122
+ ""
12123
+ ];
11774
12124
  logger.warn([
11775
12125
  "",
11776
- "┌──────────────────────────────────────────────────────────────┐",
11777
- "│ ⚠️ SCHEMA DRIFT — Missing tables in database │",
11778
- "├──────────────────────────────────────────────────────────────┤",
11779
- ...lines.map((l) => `│ ${l.padEnd(60)}│`),
11780
- "├──────────────────────────────────────────────────────────────┤",
11781
- "│ Run one of: │",
11782
- "│ pnpm db:push (dev — fast, no migration files) │",
11783
- "│ pnpm db:migrate (prod — creates migration files) │",
11784
- "└──────────────────────────────────────────────────────────────┘",
12126
+ "⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
12127
+ ...lines,
12128
+ "",
12129
+ ...misplacedHelp,
12130
+ " This runtime applies the collection schema at boot unless",
12131
+ " REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
12132
+ " log lines above — this drift means that step was off, skipped, or failed.",
12133
+ " • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
12134
+ " \"ensure\"; the runtime applies the schema to the tenant DB.",
12135
+ " If the log above says the driver does not implement collection-table",
12136
+ " creation, THIS driver is too old to do it. A driver is installed from",
12137
+ " your bundle's dependencies, not supplied by the platform image, so a",
12138
+ " newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
12139
+ " in your project's package.json and redeploy.",
12140
+ " • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
12141
+ " against DATABASE_URL.",
11785
12142
  ""
11786
12143
  ].join("\n"));
11787
12144
  }
@@ -11868,18 +12225,46 @@ function createPostgresBootstrapper(pgConfig) {
11868
12225
  */
11869
12226
  async ensureCollectionSchema(collections, driverResult, log) {
11870
12227
  const internals = driverResult.internals;
11871
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CIHSH962.js");
11872
- return { applied: (await ensureCollectionTables({ async query(text) {
12228
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-Da2oGkX2.js");
12229
+ const plan = await ensureCollectionTables({ async query(text) {
12230
+ const result = await internals.db.execute(sql.raw(text));
12231
+ return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
12232
+ } }, collections, log);
12233
+ for (const failure of plan.failures) logger.warn(`🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
12234
+ return { applied: plan.actions.length - plan.failures.length };
12235
+ },
12236
+ /**
12237
+ * Apply the collections' RLS policies — ENABLE ROW LEVEL SECURITY and the
12238
+ * `securityRules` compiled to `CREATE POLICY` — so a freshly provisioned
12239
+ * database serves data instead of denying every user-context read.
12240
+ *
12241
+ * The companion to {@link ensureCollectionSchema}: that creates the
12242
+ * tables, this makes them servable. Boot runs it *after* auth
12243
+ * initialization, because the generated policies call `auth.uid()` /
12244
+ * `auth.roles()`, and `CREATE POLICY` validates those functions exist.
12245
+ *
12246
+ * Runs through the same drizzle handle, one statement at a time (that
12247
+ * handle speaks the extended query protocol, which rejects multi-command
12248
+ * strings). Failures are per-table and non-fatal: a table left un-policed
12249
+ * stays RLS-enabled, so it denies rather than leaks.
12250
+ */
12251
+ async ensureCollectionPolicies(collections, driverResult, log) {
12252
+ const internals = driverResult.internals;
12253
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-BrUVgjz3.js");
12254
+ const outcome = await ensureCollectionPolicies({ async query(text) {
11873
12255
  const result = await internals.db.execute(sql.raw(text));
11874
12256
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
11875
- } }, collections, log)).actions.length };
12257
+ } }, collections, log);
12258
+ for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
12259
+ for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — it stays locked (denies) until this is resolved: ${failure.error}`);
12260
+ return { applied: outcome.policiesApplied };
11876
12261
  },
11877
12262
  getAdmin(driverResult) {
11878
12263
  return driverResult.internals.driver.admin;
11879
12264
  },
11880
12265
  mountRoutes(app, basePath, driverResult) {},
11881
12266
  async initializeWebsockets(server, realtimeService, driver, config, adapter) {
11882
- const { createPostgresWebSocket } = await import("./websocket-BKcGvILX.js").then((n) => n.n);
12267
+ const { createPostgresWebSocket } = await import("./websocket-B2LsrINK.js").then((n) => n.n);
11883
12268
  createPostgresWebSocket(server, realtimeService, driver, config, adapter);
11884
12269
  }
11885
12270
  };
@@ -11905,9 +12290,11 @@ function createPostgresAdapter(pgConfig) {
11905
12290
  async initializeHistory(config, driverResult) {
11906
12291
  if (bootstrapper.initializeHistory) return bootstrapper.initializeHistory(config, driverResult);
11907
12292
  },
11908
- initializeWebsockets(server, realtimeService, driver, config) {
11909
- if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config);
12293
+ initializeWebsockets(server, realtimeService, driver, config, adapter) {
12294
+ if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config, adapter);
11910
12295
  },
12296
+ ensureCollectionSchema: bootstrapper.ensureCollectionSchema ? (collections, driverResult, log) => bootstrapper.ensureCollectionSchema(collections, driverResult, log) : void 0,
12297
+ ensureCollectionPolicies: bootstrapper.ensureCollectionPolicies ? (collections, driverResult, log) => bootstrapper.ensureCollectionPolicies(collections, driverResult, log) : void 0,
11911
12298
  getAdmin(driverResult) {
11912
12299
  if (bootstrapper.getAdmin) return bootstrapper.getAdmin(driverResult);
11913
12300
  },
@@ -11917,6 +12304,6 @@ function createPostgresAdapter(pgConfig) {
11917
12304
  };
11918
12305
  }
11919
12306
  //#endregion
11920
- 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 };
12307
+ 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 };
11921
12308
 
11922
12309
  //# sourceMappingURL=index.es.js.map