@rebasepro/server-postgres 0.21.2-canary.g1ea48be → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/{BranchService-CucnFcSE.js → BranchService-DGPL6G_C.js} +2 -2
  2. package/dist/{BranchService-CucnFcSE.js.map → BranchService-DGPL6G_C.js.map} +1 -1
  3. package/dist/PostgresBackendDriver.d.ts +41 -1
  4. package/dist/auth/services.d.ts +27 -8
  5. package/dist/backup/backup-cli.d.ts +22 -0
  6. package/dist/{backup-cli-CkjsJEcu.js → backup-cli-24v4OlSp.js} +17 -14
  7. package/dist/backup-cli-24v4OlSp.js.map +1 -0
  8. package/dist/{backup-service-BNLwvxuy.js → backup-service-HQ9GC4tN.js} +2 -2
  9. package/dist/{backup-service-BNLwvxuy.js.map → backup-service-HQ9GC4tN.js.map} +1 -1
  10. package/dist/{cli-errors-Dka89exj.js → cli-errors-B8qHg02P.js} +6 -1
  11. package/dist/cli-errors-B8qHg02P.js.map +1 -0
  12. package/dist/cli-flags-BglvpjHv.js +138 -0
  13. package/dist/cli-flags-BglvpjHv.js.map +1 -0
  14. package/dist/cli-flags.d.ts +28 -0
  15. package/dist/cli-helpers.d.ts +6 -0
  16. package/dist/cli-scratch-database.d.ts +34 -0
  17. package/dist/cli.js +97 -261
  18. package/dist/cli.js.map +1 -1
  19. package/dist/{column-plan-helpers-CpILzHJS.js → column-plan-helpers-DF-8dTVa.js} +16 -20
  20. package/dist/column-plan-helpers-DF-8dTVa.js.map +1 -0
  21. package/dist/{doctor-Gzzd8wTq.js → doctor-Cb2thZ8s.js} +8 -7
  22. package/dist/doctor-Cb2thZ8s.js.map +1 -0
  23. package/dist/{ensure-collection-policies-B4IDFtQ-.js → ensure-collection-policies-RHUcEp4v.js} +5 -5
  24. package/dist/{ensure-collection-policies-B4IDFtQ-.js.map → ensure-collection-policies-RHUcEp4v.js.map} +1 -1
  25. package/dist/{ensure-collection-tables-Cr2ye5JC.js → ensure-collection-tables-BEEjn5cn.js} +53 -22
  26. package/dist/ensure-collection-tables-BEEjn5cn.js.map +1 -0
  27. package/dist/{ensure-tables-BnEvEJPr.js → ensure-tables-Dhn9KM3B.js} +2 -2
  28. package/dist/{ensure-tables-BnEvEJPr.js.map → ensure-tables-Dhn9KM3B.js.map} +1 -1
  29. package/dist/{generate-drizzle-schema-logic-DFa9qy9u.js → generate-drizzle-schema-logic-BfzK7UQd.js} +2 -2
  30. package/dist/{generate-drizzle-schema-logic-DFa9qy9u.js.map → generate-drizzle-schema-logic-BfzK7UQd.js.map} +1 -1
  31. package/dist/{generate-drizzle-schema-D2MDdJt-.js → generate-drizzle-schema-yzY_BLhr.js} +2 -2
  32. package/dist/{generate-drizzle-schema-D2MDdJt-.js.map → generate-drizzle-schema-yzY_BLhr.js.map} +1 -1
  33. package/dist/{generate-postgres-ddl-logic-CR2xcS7e.js → generate-postgres-ddl-logic-Bt2d2mRH.js} +2 -2
  34. package/dist/{generate-postgres-ddl-logic-CR2xcS7e.js.map → generate-postgres-ddl-logic-Bt2d2mRH.js.map} +1 -1
  35. package/dist/index.es.js +1882 -392
  36. package/dist/index.es.js.map +1 -1
  37. package/dist/{introspect-db-logic-C6LQdTxj.js → introspect-db-logic-WMuAfxvw.js} +323 -4
  38. package/dist/introspect-db-logic-WMuAfxvw.js.map +1 -0
  39. package/dist/{plan-schema-Hgl62S-w.js → plan-schema-C0fxM8dY.js} +170 -38
  40. package/dist/plan-schema-C0fxM8dY.js.map +1 -0
  41. package/dist/{policy-drift-B0GDRh7_.js → policy-drift-DljYdrpW.js} +2 -2
  42. package/dist/{policy-drift-B0GDRh7_.js.map → policy-drift-DljYdrpW.js.map} +1 -1
  43. package/dist/{rls-bootstrap-sql-BHA6jLtj.js → rls-bootstrap-sql-B8EclDyM.js} +320 -17
  44. package/dist/rls-bootstrap-sql-B8EclDyM.js.map +1 -0
  45. package/dist/schema/atlas-argv.d.ts +15 -0
  46. package/dist/schema/column-plan-helpers.d.ts +23 -4
  47. package/dist/schema/destructive-sql.d.ts +52 -1
  48. package/dist/schema/doctor-cli.js +3 -3
  49. package/dist/schema/generate-drizzle-schema.js +1 -1
  50. package/dist/schema/generate-postgres-ddl.js +1 -1
  51. package/dist/schema/introspect-db.js +1 -312
  52. package/dist/schema/introspect-db.js.map +1 -1
  53. package/dist/schema/plan/diff-plan.d.ts +3 -2
  54. package/dist/schema/plan/plan-schema.d.ts +10 -3
  55. package/dist/schema/plan/types.d.ts +6 -1
  56. package/dist/schema/search-column.d.ts +21 -1
  57. package/dist/services/FetchService.d.ts +147 -30
  58. package/dist/services/PersistService.d.ts +56 -5
  59. package/dist/services/RelationService.d.ts +42 -1
  60. package/dist/services/RelationWriteService.d.ts +22 -1
  61. package/dist/services/cdc/CdcListener.d.ts +6 -1
  62. package/dist/services/collection-helpers.d.ts +11 -0
  63. package/dist/services/dataService.d.ts +19 -21
  64. package/dist/services/field-op-sql.d.ts +71 -0
  65. package/dist/services/junction-writes.d.ts +19 -3
  66. package/dist/services/pg-notify-listener.d.ts +7 -0
  67. package/dist/services/read-field-access.d.ts +19 -0
  68. package/dist/services/read-scope.d.ts +100 -0
  69. package/dist/services/realtimeService.d.ts +77 -3
  70. package/dist/services/row-pipeline.d.ts +5 -0
  71. package/dist/services/soft-delete.d.ts +0 -2
  72. package/dist/services/write-transaction-scope.d.ts +39 -0
  73. package/dist/utils/drizzle-conditions.d.ts +63 -8
  74. package/dist/websocket.d.ts +10 -3
  75. package/package.json +9 -9
  76. package/dist/backup-cli-CkjsJEcu.js.map +0 -1
  77. package/dist/cli-errors-Dka89exj.js.map +0 -1
  78. package/dist/column-plan-helpers-CpILzHJS.js.map +0 -1
  79. package/dist/doctor-Gzzd8wTq.js.map +0 -1
  80. package/dist/ensure-collection-tables-Cr2ye5JC.js.map +0 -1
  81. package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
  82. package/dist/plan-schema-Hgl62S-w.js.map +0 -1
  83. package/dist/rls-bootstrap-sql-BHA6jLtj.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -2,27 +2,28 @@ import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
3
  import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
4
4
  import { a as createDirectDatabaseConnection, c as guardPoolAgainstDirtyRelease, l as pinSearchPath, o as createPostgresDatabaseConnection, r as cappedPoolMax, s as createReadReplicaConnection, t as DatabasePoolManager, u as poolMaxCeiling } from "./databasePoolManager-Bj5FbeAs.js";
5
- import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-Hgl62S-w.js";
6
- import { d as extractPgError, f as isRoleSwitchingPermissionError, g as sanitizeErrorForClient, h as reachedDatabase, i as diagnoseDbError, l as classifyConnectFailure, m as pgErrorToFriendlyMessage, n as deepestErrorMessage, o as isEconnrefused, p as isRowLevelSecurityDenial, s as parseHostInfo, u as extractCauseMessage } from "./cli-errors-Dka89exj.js";
7
- import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-CucnFcSE.js";
5
+ import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-C0fxM8dY.js";
6
+ import { d as extractPgError, f as isRoleSwitchingPermissionError, g as sanitizeErrorForClient, h as reachedDatabase, i as diagnoseDbError, l as classifyConnectFailure, m as pgErrorToFriendlyMessage, n as deepestErrorMessage, o as isEconnrefused, p as isRowLevelSecurityDenial, s as parseHostInfo, u as extractCauseMessage } from "./cli-errors-B8qHg02P.js";
7
+ import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-DGPL6G_C.js";
8
8
  import { c as warnOnLegacyRlsFunctions, i as ensureAppRole, l as warnOnRoleSchemaCollision, n as applyAuthContext, o as validatePolicyPgRoles, r as detectConnectionPosture, s as warnOnAnonymousGrants, t as REBASE_USER_ROLE } from "./rls-enforcement-C6Xk0lA6.js";
9
- import { l as resolveColumnName } from "./column-plan-helpers-CpILzHJS.js";
9
+ import { u as resolveColumnName } from "./column-plan-helpers-DF-8dTVa.js";
10
10
  import { i as isAuthCollection, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
11
- import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-CR2xcS7e.js";
12
- import { t as generateSchema } from "./generate-drizzle-schema-logic-DFa9qy9u.js";
13
- import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-Cr2ye5JC.js";
14
- import { t as main } from "./generate-drizzle-schema-D2MDdJt-.js";
15
- 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 getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as selectBackupsToPrune, h as validateDump, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as listBackups, m as uploadBackup, n as applyGlobals, o as discardPartialDump, p as restoreDump, s as ensureDatabaseExists, t as BackupToolError, u as preflight, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-BNLwvxuy.js";
16
- import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-BnEvEJPr.js";
17
- import { c as identifyJoinTables, d as singularize, h as humanize, i as buildTablesMap, m as mapPgType, r as buildEnumMap, s as getIconForTable } from "./introspect-db-logic-C6LQdTxj.js";
18
- import { Client } from "pg";
19
- import { ApiError, assertFieldOpsValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, declaredErrorAnswer, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
20
- import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, not, notInArray, or, relations, sql } from "drizzle-orm";
11
+ import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-Bt2d2mRH.js";
12
+ import { t as generateSchema } from "./generate-drizzle-schema-logic-BfzK7UQd.js";
13
+ import { c as identifyJoinTables, d as singularize, h as humanize, i as buildTablesMap, m as mapPgType, r as buildEnumMap, s as getIconForTable } from "./introspect-db-logic-WMuAfxvw.js";
14
+ import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-BEEjn5cn.js";
15
+ import { t as main } from "./generate-drizzle-schema-yzY_BLhr.js";
16
+ 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 getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as selectBackupsToPrune, h as validateDump, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as listBackups, m as uploadBackup, n as applyGlobals, o as discardPartialDump, p as restoreDump, s as ensureDatabaseExists, t as BackupToolError, u as preflight, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-HQ9GC4tN.js";
17
+ import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-Dhn9KM3B.js";
18
+ import { Client, Pool } from "pg";
19
+ import { drizzle } from "drizzle-orm/node-postgres";
20
+ import { ApiError, RUNTIME_DEFAULT_MAX_BODY_SIZE, assertFieldOpsValid, assertNestedWriteAllowed, assertNoFieldOpsOnCreate, assertQueryFieldsReadable, assertUserCreationBodyValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, createUserThroughAuthCollection, declaredErrorAnswer, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveConflictTarget, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
21
+ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, is, isNotNull, isNull, isTable, lt, not, notInArray, or, relations, sql } from "drizzle-orm";
22
+ import { PgArray, PgNumeric, PgTable, PgTimestamp, PgTimestampString, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
21
23
  import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, JUNCTION_PIVOT_KEY, ListLimitError, MAX_INCLUDE_DEPTH, Vector, declaredDatabaseExtensions, encodeRelationAggregateSort, hasForeignKeyOnTarget, isChannelBusInstance, isChannelBusInstance as isChannelBusInstance$1, isManyToMany, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, parseEnvBoolean, parseRelationAggregateSort, resolveClientListLimit } from "@rebasepro/types";
22
- import { COMPOSITE_ID_SEPARATOR, COMPOSITE_ID_SEPARATOR as COMPOSITE_ID_SEPARATOR$1, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, OrderBySpecError, REBASE_USER_ROLE as REBASE_USER_ROLE$1, applyDefaultValuesOnCreate, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, encodeCursor, fieldKeyForColumn, findRelation, firstSqlRow, getColumnName, getDeclaredPrimaryKeys, getJunctionCollectionConfig, getJunctionConfigForRelation, getTableName as getTableName$1, getTenantConfig, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeToEntityRelation, parseIdValues, parseOrderBySpecStrict, requireCallbackClient, requireCallbackCollection, resolveCollectionRelations, resolveJunctionSpecs, resolveTenantWrite, revokeInternalTableSql, sqlRows, tenantBypassRoles, toCallbackError, toFilterTuples, updateDateAutoValues, updateUserAutoValues } from "@rebasepro/common";
23
- import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName, mergeDeep, toSnakeCase, toWireKey, unref } from "@rebasepro/utils";
24
+ import { COMPOSITE_ID_SEPARATOR, COMPOSITE_ID_SEPARATOR as COMPOSITE_ID_SEPARATOR$1, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, OrderBySpecError, REBASE_USER_ROLE as REBASE_USER_ROLE$1, applyDefaultValuesOnCreate, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, encodeCursor, fieldKeyForColumn, findRelation, firstSqlRow, getColumnName, getDeclaredPrimaryKeys, getJunctionCollectionConfig, getJunctionConfigForRelation, getTableName as getTableName$1, getTenantConfig, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeToEntityRelation, parseIdValues, parseIdValues as parseIdValues$1, parseOrderBySpecStrict, requireCallbackClient, requireCallbackCollection, resolveCollectionRelations, resolveJunctionSpecs, resolveTenantWrite, restoresSoftDeletedRow, revokeInternalTableSql, softDeleteFieldOf, sqlRows, tenantBypassRoles, toCallbackError, toFilterTuples, updateDateAutoValues, updateUserAutoValues } from "@rebasepro/common";
25
+ import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName, splitSearchTerms, toSnakeCase, toWireKey, unref } from "@rebasepro/utils";
24
26
  import { AsyncLocalStorage } from "node:async_hooks";
25
- import { PgArray, PgNumeric, PgTable, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
26
27
  import fs from "fs";
27
28
  import { WebSocket, WebSocketServer } from "ws";
28
29
  import { EventEmitter } from "events";
@@ -119,6 +120,24 @@ function idCanAddressTable(id, table, idInfoArray) {
119
120
  }));
120
121
  }
121
122
  /**
123
+ * The condition that names one row: every key column equal to its part of the
124
+ * address.
125
+ *
126
+ * Every key column, not the first. A composite-key read that matched only the
127
+ * leading column answered `p1:::bob` with whichever `p1` row Postgres reached
128
+ * first, and a delete then judged that row's `beforeDelete` while deleting bob —
129
+ * the writes have always matched the whole key, so the reads must too.
130
+ */
131
+ function rowIdentityCondition(table, idInfoArray, id, collectionPath) {
132
+ const parsed = parseIdValues(id, idInfoArray);
133
+ const conditions = idInfoArray.map((info) => {
134
+ const column = table[info.fieldName];
135
+ if (!column) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
136
+ return eq(column, parsed[info.fieldName]);
137
+ });
138
+ return conditions.length === 1 ? conditions[0] : and(...conditions);
139
+ }
140
+ /**
122
141
  * The columns of a table's composite primary-key constraint, or `[]`.
123
142
  *
124
143
  * Fails open for anything that is not a real Drizzle table — a test double, a
@@ -408,6 +427,15 @@ function deriveRowAddress(row, collection, registry) {
408
427
  */
409
428
  var PG_TRGM_WORD_SIMILARITY_DEFAULT = .6;
410
429
  /**
430
+ * What a substring-only hit contributes to `_score` under
431
+ * {@link SearchMode} `"hybrid"`.
432
+ *
433
+ * Below the smallest `ts_rank` a real lexeme match can produce (a single
434
+ * D-weighted hit is 0.1 × 0.0607927 ≈ 0.006), so a row found by the indexed
435
+ * half always outranks one found only by substring.
436
+ */
437
+ var HYBRID_SUBSTRING_SCORE = .001;
438
+ /**
411
439
  * A user's search term, made safe to drop inside a `%…%` LIKE pattern.
412
440
  *
413
441
  * The term is already a bind parameter, so this is not about injection. It is
@@ -1693,13 +1721,20 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1693
1721
  * - **Declared** — one `@@ websearch_to_tsquery` against the generated
1694
1722
  * `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
1695
1723
  * inside JSONB and arrays, and uses the GIN index.
1696
- * - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
1697
- * string properties, with the term escaped (see {@link escapeLikePattern})
1698
- * so it is matched as the literal text the user typed.
1724
+ * - **Not declared** — `ILIKE '%term%'` per term, OR-ed across top-level
1725
+ * string properties and AND-ed across the terms, with each term escaped
1726
+ * (see {@link escapeLikePattern}) so it is matched as the literal text
1727
+ * the user typed.
1728
+ *
1729
+ * The second is the default and stays the default, and it AND-es terms for
1730
+ * the same reason `websearch_to_tsquery` does: a person typing two words
1731
+ * into a search box means both, and a name lives in two columns. Matching
1732
+ * the whole string per column instead — which is what this did — cannot
1733
+ * find `sebastian melendez` on a row with `first_name`/`last_name`, and
1734
+ * could not find `sebastian ` at all.
1699
1735
  *
1700
- * The second is the default and stays the default. A collection that has
1701
- * not opted in compiles to exactly the SQL it compiled to before this
1702
- * branch existed, which is the only reason it is safe to have added it.
1736
+ * Returns at most one condition. Every caller ORs what it gets back, which
1737
+ * is right across columns and wrong across terms, so the AND is built here.
1703
1738
  *
1704
1739
  * `collection` is optional so that the callers which genuinely have no
1705
1740
  * collection in hand — nested paths, derived views — keep working; without
@@ -1711,16 +1746,24 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1711
1746
  if (ftsCondition) return [ftsCondition];
1712
1747
  let declaredStringProperties = 0;
1713
1748
  const viewer = currentFieldViewer();
1749
+ const searchableColumns = [];
1714
1750
  for (const [key, prop] of Object.entries(properties)) {
1715
1751
  const p = prop;
1716
1752
  if (!canReadField(prop, viewer)) continue;
1717
1753
  if (p.type === "string" && !p.enum && p.isId !== "uuid") {
1718
1754
  declaredStringProperties++;
1719
1755
  const fieldColumn = table[key];
1720
- if (fieldColumn && supportsILike(fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
1756
+ if (fieldColumn && supportsILike(fieldColumn)) searchableColumns.push(fieldColumn);
1721
1757
  }
1722
1758
  }
1723
- if (declaredStringProperties > 0 && searchConditions.length === 0) logger.warn(`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string property(ies) but none compiled to a searchable column, so this search can only return nothing. Check that the generated schema's column types are text/varchar/char.`);
1759
+ if (declaredStringProperties > 0 && searchableColumns.length === 0) logger.warn(`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string property(ies) but none compiled to a searchable column, so this search can only return nothing. Check that the generated schema's column types are text/varchar/char.`);
1760
+ if (searchableColumns.length === 0) return [];
1761
+ const terms = splitSearchTerms(searchString);
1762
+ const perTerm = (terms.length > 0 ? terms : [searchString]).map((term) => {
1763
+ const pattern = `%${escapeLikePattern(term)}%`;
1764
+ return DrizzleConditionBuilder.combineConditionsWithOr(searchableColumns.map((column) => ilike(column, pattern)));
1765
+ });
1766
+ searchConditions.push(DrizzleConditionBuilder.combineConditionsWithAnd(perTerm));
1724
1767
  return searchConditions;
1725
1768
  }
1726
1769
  /**
@@ -1748,12 +1791,66 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1748
1791
  const column = table[spec.column];
1749
1792
  if (!column) return;
1750
1793
  const exact = sql`${column} @@ ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)}`;
1751
- if (!spec.fuzzy) return exact;
1794
+ const substring = spec.mode === "hybrid" ? DrizzleConditionBuilder.buildFoldedSubstringCondition(searchString, spec) : void 0;
1795
+ const matched = substring ? sql`(${exact} OR ${substring})` : exact;
1796
+ if (!spec.fuzzy) return matched;
1752
1797
  const fuzzyColumn = table[spec.fuzzy.column];
1753
- if (!fuzzyColumn) return exact;
1798
+ if (!fuzzyColumn) return matched;
1754
1799
  const needle = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
1755
1800
  const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
1756
- return sql`(${exact} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
1801
+ return sql`(${matched} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
1802
+ }
1803
+ /**
1804
+ * The substring half of {@link SearchMode} `"hybrid"`: every term found
1805
+ * somewhere in the declared fields, with accents folded on both sides.
1806
+ *
1807
+ * Terms are AND-ed and fields OR-ed, exactly as the ILIKE default does and
1808
+ * for the same reason — a person typing two words means both, and a name
1809
+ * lives in two columns (`splitSearchTerms`). What it does *not* share with
1810
+ * the default is its reach: these are the declared `search.fields`, so it
1811
+ * folds accents, joins `text[]` and walks into JSONB through the same
1812
+ * IMMUTABLE helpers the generated column uses.
1813
+ *
1814
+ * The needle is folded with the same function as the document, so
1815
+ * `munoz` and `Muñoz` meet in the middle rather than one side being
1816
+ * normalized and the other not — the subtle way to build a search that
1817
+ * matches nothing.
1818
+ *
1819
+ * Interpolated as raw SQL because the field expressions are generated (they
1820
+ * name columns and helper functions, which are identifiers, not values);
1821
+ * the *term* is a bind parameter, escaped for LIKE first — see
1822
+ * {@link escapeLikePattern} for what that escape is for.
1823
+ */
1824
+ static buildFoldedSubstringCondition(searchString, spec) {
1825
+ if (spec.fields.length === 0) return void 0;
1826
+ const perTerm = DrizzleConditionBuilder.foldedNeedles(searchString).map((needle) => DrizzleConditionBuilder.combineConditionsWithOr(spec.fields.map((f) => sql`${sql.raw(f.foldedTextSql)} ILIKE ${needle}`)));
1827
+ return DrizzleConditionBuilder.combineConditionsWithAnd(perTerm);
1828
+ }
1829
+ /**
1830
+ * Whether one text expression holds **any** term of the search string.
1831
+ *
1832
+ * The per-field question, where {@link buildFoldedSubstringCondition} asks
1833
+ * the per-row one: `OR` across terms rather than `AND`, because a two-word
1834
+ * query satisfied across two fields has each field carrying one term. Used
1835
+ * by `_matches` to name the fields that caused a substring hit.
1836
+ */
1837
+ static foldedSubstringOf(searchString, text) {
1838
+ return DrizzleConditionBuilder.combineConditionsWithOr(DrizzleConditionBuilder.foldedNeedles(searchString).map((needle) => sql`${text} ILIKE ${needle}`));
1839
+ }
1840
+ /**
1841
+ * One accent-folded `%term%` bind parameter per search term.
1842
+ *
1843
+ * The needle is folded with the same function as the document, so `munoz`
1844
+ * and `Muñoz` meet in the middle instead of one side being normalized and
1845
+ * the other not — the subtle way to build a search that matches nothing.
1846
+ *
1847
+ * A string with no terms at all is only whitespace, and stays the single
1848
+ * literal pattern it is on the ILIKE path rather than becoming "no rows",
1849
+ * so both paths answer a stray space the same way.
1850
+ */
1851
+ static foldedNeedles(searchString) {
1852
+ const terms = splitSearchTerms(searchString);
1853
+ return (terms.length > 0 ? terms : [searchString]).map((term) => sql`${sql.raw(SEARCH_UNACCENT_FN)}(${`%${escapeLikePattern(term)}%`})`);
1757
1854
  }
1758
1855
  /**
1759
1856
  * `websearch_to_tsquery(<config>, <normalized search string>)`.
@@ -1800,17 +1897,18 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1800
1897
  if (!table[spec.column]) return void 0;
1801
1898
  const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
1802
1899
  const config = sql`${spec.language}`;
1803
- const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`);
1900
+ const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)}, ${sql.raw(f.foldedTextSql)})`);
1901
+ const matchedField = spec.mode === "hybrid" ? sql`(to_tsvector(${config}::regconfig, f.txt) @@ ${query} OR ${DrizzleConditionBuilder.foldedSubstringOf(searchString, sql`f.folded`)})` : sql`to_tsvector(${config}::regconfig, f.txt) @@ ${query}`;
1804
1902
  return sql`(
1805
1903
  SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
1806
- FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
1904
+ FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt, folded)
1807
1905
  CROSS JOIN LATERAL (
1808
1906
  SELECT jsonb_build_object(
1809
1907
  'field', f.path,
1810
1908
  'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
1811
1909
  'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
1812
1910
  ) AS m
1813
- WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
1911
+ WHERE ${matchedField}
1814
1912
  ) s
1815
1913
  )`;
1816
1914
  }
@@ -1828,18 +1926,27 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1828
1926
  if (!spec) return void 0;
1829
1927
  const column = table[spec.column];
1830
1928
  if (!column) return void 0;
1831
- const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
1929
+ const base = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
1930
+ const rank = spec.mode === "hybrid" ? (() => {
1931
+ const substring = DrizzleConditionBuilder.buildFoldedSubstringCondition(searchString, spec);
1932
+ return substring ? sql`(${base} + CASE WHEN ${substring} THEN ${sql.raw(`${HYBRID_SUBSTRING_SCORE}::real`)} ELSE 0 END)` : base;
1933
+ })() : base;
1832
1934
  if (!spec.fuzzy) return rank;
1833
1935
  const fuzzyColumn = table[spec.fuzzy.column];
1834
1936
  if (!fuzzyColumn) return rank;
1835
1937
  return sql`(${rank} + public.word_similarity(${spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`}, ${fuzzyColumn}))`;
1836
1938
  }
1837
1939
  /**
1838
- * Build a unique field check condition
1940
+ * Build a unique field check condition: rows holding `value`, other than
1941
+ * the one `excludeRow` names.
1942
+ *
1943
+ * `excludeRow` is the whole identity of the row being saved — every key
1944
+ * column — so excluding one member of a composite-key table does not
1945
+ * exclude every row sharing its first key part.
1839
1946
  */
1840
- static buildUniqueFieldCondition(fieldColumn, value, idColumn, excludeId) {
1947
+ static buildUniqueFieldCondition(fieldColumn, value, excludeRow) {
1841
1948
  const conditions = [eq(fieldColumn, value)];
1842
- if (excludeId && idColumn) conditions.push(sql`${idColumn} != ${excludeId}`);
1949
+ if (excludeRow) conditions.push(not(excludeRow));
1843
1950
  return conditions;
1844
1951
  }
1845
1952
  /**
@@ -2116,6 +2223,11 @@ function toRestValues(row, collection) {
2116
2223
  * an `update` that echoes the row back overwrite the real value with the null it
2117
2224
  * was handed.
2118
2225
  *
2226
+ * A withheld to-one relation takes its foreign key with it. `bandId: 7` names
2227
+ * the salary band exactly as `band: { id: 7 }` does, and the row carries the
2228
+ * column beside the relation — so withholding only the relation withheld
2229
+ * nothing.
2230
+ *
2119
2231
  * `_matches` is filtered rather than deleted: it is the list of fields a text
2120
2232
  * search hit, and a field the caller cannot read must not appear in it even
2121
2233
  * though the array itself is theirs to see.
@@ -2125,13 +2237,22 @@ function stripUnreadable(row, collection) {
2125
2237
  if (!properties) return row;
2126
2238
  const viewer = currentFieldViewer();
2127
2239
  const hidden = /* @__PURE__ */ new Set();
2240
+ const hide = (name) => {
2241
+ hidden.add(name);
2242
+ delete row[name];
2243
+ };
2244
+ let relations;
2128
2245
  for (const [key, property] of Object.entries(properties)) {
2129
2246
  if (canReadField(property, viewer)) continue;
2130
- hidden.add(key);
2131
- delete row[key];
2132
- if (property.columnName) {
2133
- hidden.add(property.columnName);
2134
- delete row[property.columnName];
2247
+ hide(key);
2248
+ if (property.columnName) hide(property.columnName);
2249
+ if (property.type === "relation") {
2250
+ relations ??= resolveCollectionRelations(collection);
2251
+ const relation = findRelation(relations, key);
2252
+ if (relation?.kind === "belongsTo") {
2253
+ hide(fieldKeyForColumn(collection, relation.localKey));
2254
+ hide(relation.localKey);
2255
+ }
2135
2256
  }
2136
2257
  }
2137
2258
  if (hidden.size > 0 && Array.isArray(row._matches)) row._matches = row._matches.filter((match) => !hidden.has(typeof match === "string" ? match : String(match?.field)));
@@ -2336,8 +2457,10 @@ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
2336
2457
  * the form runs under RLS, so a user who may edit the parent but cannot see
2337
2458
  * some of the linked rows gets a shorter list — and writing it back deleted
2338
2459
  * the links they were never shown. The select that drives the diff runs in
2339
- * this same transaction under the same policies, so a link the caller cannot
2340
- * read is in neither list and survives the save.
2460
+ * this same transaction under the same policies, and through
2461
+ * `visibleTargets` — the target's `beforeQuery` scope and soft delete, which
2462
+ * RLS knows nothing of — so a link the caller cannot read is in neither list
2463
+ * and survives the save.
2341
2464
  * - **Junction payload columns.** A junction carrying its own columns
2342
2465
  * (`position`, `role`, `created_at`) lost them on every save, because every
2343
2466
  * row was re-inserted with only the two keys. Untouched links are left alone.
@@ -2360,8 +2483,8 @@ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
2360
2483
  * that is what keeps a plain `[1, 2, 3]` membership write from wiping the
2361
2484
  * `role`s off the links it did not mention.
2362
2485
  */
2363
- async function applyJunctionMembership(tx, binding, parentId, links) {
2364
- const existingRows = await tx.select({ targetId: binding.targetColumn }).from(binding.table).where(eq(binding.parentColumn, parentId));
2486
+ async function applyJunctionMembership(tx, binding, parentId, links, visibleTargets) {
2487
+ const existingRows = visibleTargets ? await tx.select({ targetId: binding.targetColumn }).from(binding.table).innerJoin(visibleTargets.table, eq(binding.targetColumn, visibleTargets.idColumn)).where(and(eq(binding.parentColumn, parentId), visibleTargets.condition)) : await tx.select({ targetId: binding.targetColumn }).from(binding.table).where(eq(binding.parentColumn, parentId));
2365
2488
  const existingById = /* @__PURE__ */ new Map();
2366
2489
  for (const row of existingRows) {
2367
2490
  if (row.targetId === null || row.targetId === void 0) continue;
@@ -2420,6 +2543,8 @@ async function removeLinks(tx, binding, parentId, removed) {
2420
2543
  if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
2421
2544
  throw ApiError.forbidden(`Not allowed to remove ${removed.length - deleted} of ${removed.length} link(s) for relation '${binding.label}': a row-level security policy rejected the write.`, "WRITE_DENIED");
2422
2545
  }
2546
+ //#endregion
2547
+ //#region src/services/soft-delete.ts
2423
2548
  /**
2424
2549
  * The soft-delete field this collection uses, or `undefined` if it has none.
2425
2550
  *
@@ -2429,9 +2554,8 @@ async function removeLinks(tx, binding, parentId, removed) {
2429
2554
  * worth avoiding — deletes would silently start removing rows.
2430
2555
  */
2431
2556
  function resolveSoftDelete(collection) {
2432
- const declared = collection?.softDelete;
2433
- if (!declared) return void 0;
2434
- const field = typeof declared === "object" && declared.field || "deletedAt";
2557
+ const field = softDeleteFieldOf(collection);
2558
+ if (!field) return void 0;
2435
2559
  return {
2436
2560
  field,
2437
2561
  columnName: ((collection?.properties)?.[field])?.columnName ?? toSnakeCase(field)
@@ -2488,6 +2612,128 @@ function andSoftDelete(where, collection, table, withDeleted) {
2488
2612
  return where ? and(where, condition) : condition;
2489
2613
  }
2490
2614
  //#endregion
2615
+ //#region src/services/read-scope.ts
2616
+ /**
2617
+ * `beforeQuery`: the one place a collection callback can change *which* rows a
2618
+ * read asks for.
2619
+ *
2620
+ * `afterRead` runs over rows that have already been fetched, so until this
2621
+ * existed an application developer who needed the read itself narrowed — a
2622
+ * tenant scope, a visibility window, a per-role row filter — had two options:
2623
+ * patch this driver, or `rebase eject`.
2624
+ *
2625
+ * Two properties are load-bearing, and both belong to the *shape* of the thing
2626
+ * rather than to how carefully each call site was written.
2627
+ *
2628
+ * ## It can only narrow
2629
+ *
2630
+ * A hook returns a {@link QueryNarrowing} — a declarative filter, optionally a
2631
+ * logical group — which is AND-ed into the query the caller sent. `AND(q, c)`
2632
+ * is a subset of `q` for every `c`, so there is no `c` a hook can return that
2633
+ * widens a read. The obvious alternative shape, `(query) => query`, can: a
2634
+ * hook that drops a condition on the way through runs the query without it,
2635
+ * and on an RLS data plane a dropped condition returns everything the policies
2636
+ * happen to allow. That is the same reasoning behind
2637
+ * `UnknownFilterFieldsMode` defaulting to `"error"`, and it is why that mode is
2638
+ * **forced** to `"error"` for a hook's own filter here whatever the
2639
+ * process-wide setting is: a scope condition naming a renamed column has to
2640
+ * refuse the request, never be dropped from it.
2641
+ *
2642
+ * ## It cannot be skipped
2643
+ *
2644
+ * The hooks are resolved *by the read path itself*, out of the registry that
2645
+ * read already holds — not handed to it by a caller who might forget. So a
2646
+ * read that compiles a WHERE compiles this one too, or it throws.
2647
+ *
2648
+ * The one thing a read path cannot derive is the identity the hook runs as:
2649
+ * `FetchService`, `RelationService` and `DataService` carry no user. That
2650
+ * arrives as a {@link ReadCallContextProvider}, threaded from the driver that
2651
+ * constructed them — the driver is the only object that knows both the caller
2652
+ * and the connection, and it constructs a service per transaction anyway, so
2653
+ * there is exactly one place per instance to pass it.
2654
+ *
2655
+ * Absence is the interesting case, and it fails closed: a service built with no
2656
+ * provider refuses any read of a collection that declares `beforeQuery`, with
2657
+ * a message naming the collection. So a future construction site that forgets
2658
+ * the provider breaks loudly on the first scoped collection instead of quietly
2659
+ * serving every row — which is the failure mode this whole module exists to
2660
+ * rule out. A deployment with no `beforeQuery` anywhere never reaches any of
2661
+ * this: the check is one map lookup and the compiled SQL is byte-identical.
2662
+ *
2663
+ * @module
2664
+ */
2665
+ /**
2666
+ * The `beforeQuery` hooks that apply to one collection, in the order they run.
2667
+ *
2668
+ * Global first, then the collection's own — the same order `afterRead` and
2669
+ * `beforeSave` use. The order changes nothing about the outcome here (both are
2670
+ * AND-ed in, so neither can relax the other) and is kept because a hook that
2671
+ * logs or measures should see the same sequence everywhere.
2672
+ *
2673
+ * There is no property tier: a property callback shapes one value on a row, and
2674
+ * a row filter is not a value.
2675
+ */
2676
+ function resolveBeforeQuery(registry, collection) {
2677
+ const hooks = [];
2678
+ const global = registry?.getGlobalCallbacks();
2679
+ if (global?.beforeQuery) hooks.push(global.beforeQuery);
2680
+ const own = collection?.callbacks;
2681
+ if (own?.beforeQuery) hooks.push(own.beforeQuery);
2682
+ return hooks;
2683
+ }
2684
+ /** Whether anything at all would run for this collection. */
2685
+ function hasBeforeQuery(registry, collection) {
2686
+ return resolveBeforeQuery(registry, collection).length > 0;
2687
+ }
2688
+ /**
2689
+ * The conditions every `beforeQuery` hook for this collection asks to add.
2690
+ *
2691
+ * Returned as a list rather than pre-combined so a caller pushes them onto the
2692
+ * same `allConditions` array its own filter goes into. Every caller AND-es
2693
+ * that array, which is what makes these additive — the hook is not trusted to
2694
+ * narrow, it is only ever given a way to.
2695
+ */
2696
+ async function beforeQueryConditions(env, collection, path, table, description, filterContext) {
2697
+ const hooks = resolveBeforeQuery(env.registry, collection);
2698
+ if (hooks.length === 0) return [];
2699
+ if (!collection) throw new Error(`[beforeQuery] "${path}" resolves to no registered collection, so a global \`beforeQuery\` cannot be compiled against it. Register the collection, or narrow the hook to the collections it applies to.`);
2700
+ if (!env.callContext) throw new Error(`[beforeQuery] "${collection.slug}" declares a \`beforeQuery\` hook, and this read was compiled by a service that was built without a call-context provider, so the hook could not run. Serving the read unnarrowed is not an option — a hook that does not run widens the result set. Construct the service through the driver (which passes one), or pass a provider explicitly.`);
2701
+ const context = env.callContext();
2702
+ const unknownFields = "error";
2703
+ const options = {
2704
+ ...filterContext,
2705
+ unknownFields
2706
+ };
2707
+ const conditions = [];
2708
+ for (const hook of hooks) {
2709
+ const narrowing = await hook({
2710
+ collection,
2711
+ path,
2712
+ operation: description.operation,
2713
+ query: description.query,
2714
+ context
2715
+ });
2716
+ if (!narrowing) continue;
2717
+ if (!narrowing.filter && !narrowing.logical) throw new Error(`[beforeQuery] "${collection.slug}" returned an object with neither \`filter\` nor \`logical\`. Return nothing (or \`undefined\`) to add no conditions; an empty object reads as a scope that was meant to be built and was not.`);
2718
+ if (narrowing.filter) conditions.push(...DrizzleConditionBuilder.buildFilterConditions(narrowing.filter, table, path, options));
2719
+ if (narrowing.logical) {
2720
+ const logical = DrizzleConditionBuilder.buildLogicalConditions(narrowing.logical, table, path, options);
2721
+ if (logical) conditions.push(logical);
2722
+ }
2723
+ }
2724
+ return conditions;
2725
+ }
2726
+ /**
2727
+ * {@link beforeQueryConditions}, pre-combined, for the read paths that hold a
2728
+ * single `SQL` rather than a list of them — a single get, a relation loader.
2729
+ */
2730
+ async function beforeQueryCondition(env, collection, path, table, description, filterContext) {
2731
+ const conditions = await beforeQueryConditions(env, collection, path, table, description, filterContext);
2732
+ if (conditions.length === 0) return void 0;
2733
+ if (conditions.length === 1) return conditions[0];
2734
+ return and(...conditions);
2735
+ }
2736
+ //#endregion
2491
2737
  //#region src/services/RelationService.ts
2492
2738
  /**
2493
2739
  * Typed wrapper for Drizzle dynamic query innerJoin.
@@ -2522,6 +2768,17 @@ function narrowed(base, narrow) {
2522
2768
  return narrow ? and(base, narrow) : base;
2523
2769
  }
2524
2770
  /**
2771
+ * Two optional conditions, AND-ed: either, both, or neither.
2772
+ *
2773
+ * The include's own `where` and the target collection's `beforeQuery` arrive
2774
+ * separately and are both optional, and they have to reach the loaders as one
2775
+ * condition because that is the shape every relation kind threads through.
2776
+ */
2777
+ function narrowBoth(a, b) {
2778
+ if (a && b) return and(a, b);
2779
+ return a ?? b;
2780
+ }
2781
+ /**
2525
2782
  * The registry has no table for a collection a relation just asked about.
2526
2783
  *
2527
2784
  * "Parent table not found" was the whole message, four times over, and it names
@@ -2538,9 +2795,58 @@ function unregisteredTable(collectionSlug, table, role) {
2538
2795
  var RelationService = class {
2539
2796
  db;
2540
2797
  registry;
2541
- constructor(db, registry) {
2798
+ callContext;
2799
+ constructor(db, registry, callContext) {
2542
2800
  this.db = db;
2543
2801
  this.registry = registry;
2802
+ this.callContext = callContext;
2803
+ }
2804
+ /**
2805
+ * The **target** collection's `beforeQuery` narrowing, for rows reached
2806
+ * through a relation.
2807
+ *
2808
+ * The target's, not the parent's, because these are the target's rows: a
2809
+ * scope on `comments` has to hold whether they are listed at
2810
+ * `/comments` or loaded as `post.comments`. Without this an `include` is a
2811
+ * way around every row filter a collection declares — the exact hole
2812
+ * `stripUnreadable` was once missing from the relation-ref branch.
2813
+ *
2814
+ * Folded into the `narrow` condition the loaders already thread through
2815
+ * every relation kind, so a `via` join, a junction, a foreign key and the
2816
+ * dynamic relation builder all apply it or none of them do.
2817
+ */
2818
+ narrowTargetRead(parentCollection, relation, parentId) {
2819
+ const targetCollection = relation.target();
2820
+ const targetTable = getTableForCollection(targetCollection, this.registry);
2821
+ return beforeQueryCondition({
2822
+ registry: this.registry,
2823
+ callContext: this.callContext
2824
+ }, targetCollection, targetCollection.slug, targetTable, {
2825
+ operation: "relation",
2826
+ query: { relatedTo: {
2827
+ parentSlug: parentCollection.slug,
2828
+ parentId,
2829
+ relationName: relation.relationName
2830
+ } }
2831
+ }, {
2832
+ collection: targetCollection,
2833
+ registry: this.registry
2834
+ });
2835
+ }
2836
+ /**
2837
+ * The target rows a caller may see through `relation`: the target's
2838
+ * `beforeQuery` scope and its soft delete, as one condition on the target
2839
+ * table, or `undefined` when neither applies.
2840
+ *
2841
+ * What every loader here narrows a read by, exposed for the membership
2842
+ * writes. A write that diffs a new link set against "what is linked now"
2843
+ * has to diff against what the caller could have read — otherwise saving
2844
+ * back the list they were shown unlinks every row they were not.
2845
+ */
2846
+ async visibleTargetCondition(parentCollection, relation, parentId) {
2847
+ const targetCollection = relation.target();
2848
+ const targetTable = getTableForCollection(targetCollection, this.registry);
2849
+ return andSoftDelete(await this.narrowTargetRead(parentCollection, relation, parentId), targetCollection, targetTable);
2544
2850
  }
2545
2851
  /**
2546
2852
  * One target row, as the {@link RelatedRow} everything here returns.
@@ -2601,11 +2907,11 @@ var RelationService = class {
2601
2907
  return col;
2602
2908
  };
2603
2909
  if (parentPks.length === 1) {
2604
- const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
2910
+ const values = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentPks[0].fieldName]);
2605
2911
  return inArray(columnFor(parentPks[0].fieldName), values);
2606
2912
  }
2607
2913
  return or(...parentIds.map((id) => {
2608
- const values = parseIdValues(id, parentPks);
2914
+ const values = parseIdValues$1(id, parentPks);
2609
2915
  return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
2610
2916
  }));
2611
2917
  }
@@ -2656,7 +2962,7 @@ var RelationService = class {
2656
2962
  const parentPks = requirePrimaryKeys(parentCollection, this.registry);
2657
2963
  if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
2658
2964
  for (const id of parentIds) {
2659
- const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
2965
+ const value = parseIdValues$1(id, parentPks)[parentPks[0].fieldName];
2660
2966
  keyByParentId.set(String(id), value);
2661
2967
  parentIdByKey.set(String(value), id);
2662
2968
  }
@@ -2706,11 +3012,12 @@ var RelationService = class {
2706
3012
  const idField = targetTable[idInfo[0].fieldName];
2707
3013
  const parentPks = requirePrimaryKeys(parentCollection, this.registry);
2708
3014
  const parentIdInfo = parentPks[0];
2709
- const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
3015
+ const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
2710
3016
  const parentTableName = getTableName$1(parentCollection);
2711
3017
  const parentTable = this.registry.getTable(parentTableName);
2712
3018
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
2713
3019
  const parentIdCol = parentTable[parentIdInfo.fieldName];
3020
+ const relatedNarrowing = await this.narrowTargetRead(parentCollection, relation, parentId);
2714
3021
  if (relation.kind === "via") {
2715
3022
  let query = this.db.select().from(parentTable).$dynamic();
2716
3023
  let currentTable = parentTable;
@@ -2730,7 +3037,7 @@ var RelationService = class {
2730
3037
  currentTable = joinTable;
2731
3038
  }
2732
3039
  const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
2733
- query = query.where(eq(parentIdField, parsedParentId));
3040
+ query = query.where(narrowed(eq(parentIdField, parsedParentId), andSoftDelete(relatedNarrowing, targetCollection, targetTable)));
2734
3041
  if (options.limit) query = query.limit(options.limit);
2735
3042
  const results = await query;
2736
3043
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
@@ -2747,6 +3054,7 @@ var RelationService = class {
2747
3054
  const additionalFilters = [];
2748
3055
  const relatedSoftDelete = softDeleteCondition(targetCollection, targetTable);
2749
3056
  if (relatedSoftDelete) additionalFilters.push(relatedSoftDelete);
3057
+ if (relatedNarrowing) additionalFilters.push(relatedNarrowing);
2750
3058
  if (options.searchString) {
2751
3059
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
2752
3060
  if (searchConditions.length === 0) return [];
@@ -2792,7 +3100,7 @@ var RelationService = class {
2792
3100
  const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
2793
3101
  const parentPks = requirePrimaryKeys(parentCollection, this.registry);
2794
3102
  const parentIdInfo = parentPks[0];
2795
- const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
3103
+ const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
2796
3104
  const parentTableName = getTableName$1(parentCollection);
2797
3105
  const parentTable = this.registry.getTable(parentTableName);
2798
3106
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
@@ -2801,6 +3109,8 @@ var RelationService = class {
2801
3109
  if (matchValue === void 0) return 0;
2802
3110
  const countSoftDelete = softDeleteCondition(targetCollection, targetTable);
2803
3111
  if (countSoftDelete) additionalFilters = [...additionalFilters, countSoftDelete];
3112
+ const countNarrowing = await this.narrowTargetRead(parentCollection, relation, parentId);
3113
+ if (countNarrowing) additionalFilters = [...additionalFilters, countNarrowing];
2804
3114
  let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
2805
3115
  query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, matchValue, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
2806
3116
  const result = await query;
@@ -2817,7 +3127,7 @@ var RelationService = class {
2817
3127
  async isRelated(hop, targetId) {
2818
3128
  const targetTable = getTableForCollection(hop.targetCollection, this.registry);
2819
3129
  const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
2820
- const parsedTargetId = parseIdValues(targetId, targetPks);
3130
+ const parsedTargetId = parseIdValues$1(targetId, targetPks);
2821
3131
  const identity = targetPks.map((pk) => {
2822
3132
  const column = targetTable[pk.fieldName];
2823
3133
  if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
@@ -2847,8 +3157,8 @@ var RelationService = class {
2847
3157
  const parentTable = this.registry.getTable(parentTableName);
2848
3158
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
2849
3159
  const parentIdCol = parentTable[parentIdInfo.fieldName];
2850
- const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
2851
- const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
3160
+ const parsedParentIds = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentIdInfo.fieldName]);
3161
+ const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
2852
3162
  if (relation.kind === "via") {
2853
3163
  let query = this.db.select().from(parentTable).$dynamic();
2854
3164
  let currentTable = parentTable;
@@ -2951,8 +3261,8 @@ var RelationService = class {
2951
3261
  const parentTable = this.registry.getTable(parentTableName);
2952
3262
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
2953
3263
  const parentIdCol = parentTable[parentIdInfo.fieldName];
2954
- const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
2955
- const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
3264
+ const parsedParentIds = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentIdInfo.fieldName]);
3265
+ const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
2956
3266
  if (relation.kind === "via") {
2957
3267
  let query = this.db.select().from(parentTable).$dynamic();
2958
3268
  let currentTable = parentTable;
@@ -3652,11 +3962,13 @@ function numericAggregateAliases(aggregates, properties) {
3652
3962
  var FetchService = class FetchService {
3653
3963
  db;
3654
3964
  registry;
3965
+ callContext;
3655
3966
  relationService;
3656
- constructor(db, registry) {
3967
+ constructor(db, registry, callContext) {
3657
3968
  this.db = db;
3658
3969
  this.registry = registry;
3659
- this.relationService = new RelationService(db, registry);
3970
+ this.callContext = callContext;
3971
+ this.relationService = new RelationService(db, registry, callContext);
3660
3972
  }
3661
3973
  /**
3662
3974
  * Get the relational query builder for a given table name.
@@ -3690,6 +4002,63 @@ var FetchService = class FetchService {
3690
4002
  };
3691
4003
  }
3692
4004
  /**
4005
+ * The conditions this collection's `beforeQuery` hooks ask to add, for a
4006
+ * read that is about to be compiled.
4007
+ *
4008
+ * Resolved here, from the registry this service already holds, rather than
4009
+ * handed in by a caller — so a read path that builds a WHERE cannot serve
4010
+ * one of these collections without the hook. `[]` when nothing is declared,
4011
+ * which leaves the compiled SQL byte-identical.
4012
+ *
4013
+ * Every caller pushes the result onto the same `allConditions` array its
4014
+ * own filter goes into, and every one of those arrays is AND-ed. That is
4015
+ * what keeps a hook additive: it is not trusted to narrow, it is only ever
4016
+ * given a way to.
4017
+ */
4018
+ narrowRead(collectionPath, collection, table, operation, query) {
4019
+ return beforeQueryConditions({
4020
+ registry: this.registry,
4021
+ callContext: this.callContext
4022
+ }, collection, collectionPath, table, {
4023
+ operation,
4024
+ query
4025
+ }, this.filterContext(collectionPath, table));
4026
+ }
4027
+ /** {@link narrowRead}, pre-combined, for the reads that hold one `SQL`. */
4028
+ narrowReadCondition(collectionPath, collection, table, operation, query) {
4029
+ return beforeQueryCondition({
4030
+ registry: this.registry,
4031
+ callContext: this.callContext
4032
+ }, collection, collectionPath, table, {
4033
+ operation,
4034
+ query
4035
+ }, this.filterContext(collectionPath, table));
4036
+ }
4037
+ /**
4038
+ * The read the hooks are shown, from the options a read path was given.
4039
+ *
4040
+ * Transport is left out on purpose — a `databaseId`, an `include` tree, a
4041
+ * `withDeleted` flag are not part of "which rows". What is in is what a
4042
+ * scope might reasonably branch on.
4043
+ */
4044
+ static describeRead(options) {
4045
+ const orderBy = normalizeDriverOrderBy(options.orderBy, options.order);
4046
+ return {
4047
+ filter: options.filter,
4048
+ logical: options.logical,
4049
+ searchString: options.searchString,
4050
+ limit: options.limit,
4051
+ offset: options.offset,
4052
+ orderBy: orderBy && orderBy.length > 0 ? orderBy : void 0,
4053
+ fields: options.fields,
4054
+ relatedTo: options.relatedTo ? {
4055
+ parentSlug: options.relatedTo.parentCollection.slug,
4056
+ parentId: options.relatedTo.parentId,
4057
+ relationName: options.relatedTo.relation.relationName
4058
+ } : void 0
4059
+ };
4060
+ }
4061
+ /**
3693
4062
  * The table column this collection's rows are keyed by, or `undefined`.
3694
4063
  *
3695
4064
  * `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
@@ -3733,6 +4102,11 @@ var FetchService = class FetchService {
3733
4102
  * rows in arbitrary order, and paging over that repeats and skips rows.
3734
4103
  */
3735
4104
  static SCORE_FIELD = "_score";
4105
+ /**
4106
+ * The distance a vector search attaches to every row it serves, and to no
4107
+ * other. Like `_score` it is computed per query and stored nowhere.
4108
+ */
4109
+ static DISTANCE_FIELD = "_distance";
3736
4110
  resolveOrderTarget(table, orderBy, collection, searchString) {
3737
4111
  if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
3738
4112
  const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
@@ -3816,15 +4190,17 @@ var FetchService = class FetchService {
3816
4190
  return key.direction === "asc";
3817
4191
  }
3818
4192
  /**
3819
- * The full `ORDER BY`: the caller's keys, then the id.
4193
+ * The full `ORDER BY`: the caller's keys, then the primary key.
3820
4194
  *
3821
- * The id is always last and always descending. It is not decoration — it is
3822
- * what makes the ordering *total*, and a cursor over a non-total order
3823
- * repeats and skips rows among the ties. Every keyset comparison built by
3824
- * {@link buildCursorConditions} ends on the same `id DESC`, and the two have
3825
- * to agree: they did not, and an ascending sort paged with `id >` against an
3826
- * `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
3827
- * every page after the first.
4195
+ * The key is always last and always descending, every column of it. It is
4196
+ * not decoration — it is what makes the ordering *total*, and a cursor over
4197
+ * a non-total order repeats and skips rows among the ties. Every keyset
4198
+ * comparison built by {@link buildCursorConditions} ends on the same key
4199
+ * `DESC`, and the two have to agree: they did not, and an ascending sort
4200
+ * paged with `id >` against an `ORDER BY … , id DESC`, so rows sharing a
4201
+ * sort value were dropped from every page after the first. Nor is the
4202
+ * first column of a composite key enough: it ties every row that shares
4203
+ * it, which is exactly the rows a cursor then skipped.
3828
4204
  *
3829
4205
  * Where the NULLs go is written out rather than inherited. Postgres already
3830
4206
  * defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this
@@ -3835,13 +4211,13 @@ var FetchService = class FetchService {
3835
4211
  * relation reaches nothing from, which is precisely the "nobody waiting"
3836
4212
  * end of a queue.
3837
4213
  */
3838
- buildOrderExpressions(keys, idField, tieBreakOnId = true) {
4214
+ buildOrderExpressions(keys, keyColumns) {
3839
4215
  const expressions = keys.map((key) => {
3840
4216
  const nullsLast = FetchService.nullsLast(key);
3841
4217
  if (key.direction === "asc") return nullsLast ? sql`${key.target} ASC NULLS LAST` : sql`${key.target} ASC NULLS FIRST`;
3842
4218
  return nullsLast ? sql`${key.target} DESC NULLS LAST` : sql`${key.target} DESC NULLS FIRST`;
3843
4219
  });
3844
- if (tieBreakOnId) expressions.push(desc(idField));
4220
+ for (const column of keyColumns) expressions.push(desc(column));
3845
4221
  return expressions;
3846
4222
  }
3847
4223
  resolveOrderByField(table, orderBy, collection) {
@@ -3884,40 +4260,6 @@ var FetchService = class FetchService {
3884
4260
  });
3885
4261
  }
3886
4262
  /**
3887
- * Build the `with` config for Drizzle's relational query API.
3888
- * Converts collection relations to a Drizzle-compatible `with` object.
3889
- *
3890
- * When `include` is provided, only those relations are loaded.
3891
- * When `include` is absent, ALL relations are loaded (the admin path).
3892
- *
3893
- * Automatically detects many-to-many junction tables and nests
3894
- * the target relation so actual row data is returned.
3895
- */
3896
- buildWithConfig(collection, include) {
3897
- const resolvedRelations = resolveCollectionRelations(collection);
3898
- const withConfig = {};
3899
- const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
3900
- for (const [key, relation] of Object.entries(resolvedRelations)) {
3901
- if (!shouldInclude(key)) continue;
3902
- const drizzleRelName = relation.relationName || key;
3903
- if (relation.kind === "via") continue;
3904
- if (relation.cardinality === "many" && isJunctionRelation(relation)) {
3905
- const targetFkName = this.getJunctionTargetRelationName(relation, collection);
3906
- if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
3907
- else withConfig[drizzleRelName] = true;
3908
- } else withConfig[drizzleRelName] = true;
3909
- }
3910
- return withConfig;
3911
- }
3912
- /**
3913
- * Get the Drizzle relation name on the junction table that points to the actual target row.
3914
- * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
3915
- */
3916
- getJunctionTargetRelationName(relation, _collection) {
3917
- if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
3918
- return null;
3919
- }
3920
- /**
3921
4263
  * The relations one level of an include tree names, resolved.
3922
4264
  *
3923
4265
  * A name that is not a relation is a **400 `UNKNOWN_RELATION`**. It used to
@@ -4145,36 +4487,49 @@ var FetchService = class FetchService {
4145
4487
  */
4146
4488
  cursorFor(collectionPath, row, orderBy) {
4147
4489
  if (orderBy?.some(([field]) => field === FetchService.SCORE_FIELD)) return void 0;
4490
+ if (FetchService.DISTANCE_FIELD in row) return void 0;
4148
4491
  try {
4149
4492
  const pks = getPrimaryKeys(getCollectionByPath(collectionPath, this.registry), this.registry);
4150
4493
  if (pks.length === 0) return void 0;
4151
4494
  const address = buildCompositeId$1(row, pks);
4152
4495
  if (!address || !address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "")) return void 0;
4153
- return encodeCursor(orderBy, row, row[pks[0].fieldName]);
4496
+ return encodeCursor(orderBy, row, pks.length === 1 ? row[pks[0].fieldName] : address);
4154
4497
  } catch {
4155
4498
  return;
4156
4499
  }
4157
4500
  }
4158
4501
  /**
4159
- * Post-fetch joinPath relations for a single flat row.
4160
- * joinPath relations cannot be expressed via Drizzle's `with` config,
4161
- * so they must be loaded separately after the primary query.
4162
- */
4163
- async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
4164
- const resolvedRelations = resolveCollectionRelations(collection);
4165
- const promises = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via").map(async ([key, relation]) => {
4166
- try {
4167
- const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
4168
- if (relation.cardinality === "one" && relatedRows.length > 0) {
4169
- const e = relatedRows[0];
4170
- row[key] = createRelationRefWithData(e.id, e.path, e);
4171
- } else if (relation.cardinality === "many") row[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
4172
- } catch (e) {
4173
- if (reachedDatabase(e)) throw e;
4174
- logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
4175
- }
4502
+ * Every relation of one row, as the refs the admin renders, attached in
4503
+ * place.
4504
+ *
4505
+ * Loaded by the batched loaders an `include` takes, so a target reached from
4506
+ * this row is read as the caller may see it: its `beforeQuery` scope and its
4507
+ * soft delete applied, its unreadable fields stripped. Populated through
4508
+ * drizzle's relational `with`, the single get knew neither, and a
4509
+ * tenant-scoped user fetching an owner was handed another tenant's docs —
4510
+ * and the trashed ones — under a relation `?include=` answered correctly.
4511
+ *
4512
+ * A to-many relation that reaches nothing is `[]`; a to-one is left off. A
4513
+ * relation that fails for a reason other than the database is logged and
4514
+ * left off, as on the include path, rather than failing the whole row.
4515
+ */
4516
+ async loadRelationRefs(row, address, collection, collectionPath) {
4517
+ if (!address) return;
4518
+ const toRef = (related) => createRelationRefWithData(related.id, related.path, {
4519
+ id: related.id,
4520
+ path: related.path,
4521
+ values: related.values
4176
4522
  });
4177
- await Promise.all(promises);
4523
+ for (const [key, relation] of Object.entries(resolveCollectionRelations(collection))) try {
4524
+ if (relation.cardinality === "one") {
4525
+ const related = (await this.relationService.batchFetchRelatedEntities(collectionPath, [address], key, relation)).get(address);
4526
+ if (related) row[key] = toRef(related);
4527
+ } else row[key] = ((await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, [address], key, relation)).get(address) ?? []).map(toRef);
4528
+ } catch (e) {
4529
+ if (e instanceof ApiError) throw e;
4530
+ if (reachedDatabase(e)) throw e;
4531
+ logger.warn(`[FetchService] Could not load relation '${key}' on ${collectionPath}`, { error: e });
4532
+ }
4178
4533
  }
4179
4534
  /**
4180
4535
  * Extract cursor pagination conditions from startAfter options.
@@ -4184,26 +4539,102 @@ var FetchService = class FetchService {
4184
4539
  * one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with
4185
4540
  * several it nests, each key's tie handing the decision to the next.
4186
4541
  */
4187
- buildCursorConditions(table, idField, idInfo, options, collectionPath) {
4542
+ buildCursorConditions(table, idInfoArray, options, collectionPath) {
4188
4543
  if (!options.startAfter) return [];
4189
4544
  const cursor = options.startAfter;
4190
4545
  const keys = normalizeDriverOrderBy(options.orderBy, options.order);
4546
+ const keyColumns = FetchService.keyColumns(table, idInfoArray, collectionPath);
4547
+ const cursorKey = FetchService.cursorKey(cursor, idInfoArray, collectionPath);
4191
4548
  if (keys) {
4192
4549
  if (keys.some(([field]) => field === FetchService.SCORE_FIELD)) throw ApiError.badRequest("Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. Relevance is computed per query rather than stored, so it cannot key a cursor. Use `limit`/`offset` for relevance-ordered pages, or order by a column.", "SCORE_CURSOR_UNSUPPORTED", { field: FetchService.SCORE_FIELD });
4193
- const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
4194
- const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
4195
- const resolved = this.resolveOrderKeys(table, keys, collection, void 0, collectionPath, startAfterId ?? void 0);
4196
- if (resolved.length > 0 && startAfterId !== void 0) {
4550
+ const collection = getCollectionByPath(collectionPath, this.registry);
4551
+ const resolved = this.resolveOrderKeys(table, keys, collection, void 0, collectionPath, cursorKey?.length === 1 ? cursorKey[0] : void 0);
4552
+ if (resolved.length > 0 && cursorKey) {
4197
4553
  const cursorValues = cursor.values;
4198
4554
  const values = resolved.map(({ field, cursorTarget }) => cursorTarget ? null : cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
4199
- if (values.every((value, i) => resolved[i].cursorTarget || value !== void 0)) return [this.buildKeysetComparison(resolved, values, idField, startAfterId)];
4555
+ if (values.every((value, i) => resolved[i].cursorTarget || value !== void 0)) {
4556
+ const seekValues = values.map((value, i) => this.preciseCursorValue(table, resolved[i].target, value, keyColumns, cursorKey));
4557
+ return [this.buildKeysetComparison(resolved, seekValues, keyColumns, cursorKey)];
4558
+ }
4200
4559
  }
4201
- } else {
4202
- const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
4203
- if (startAfterId !== void 0 && startAfterId !== null) return [lt(idField, parseIdValues(startAfterId, [idInfo])[idInfo.fieldName])];
4204
- }
4560
+ } else if (cursorKey) return [FetchService.keyAfter(keyColumns, cursorKey)];
4205
4561
  return [];
4206
4562
  }
4563
+ /** The table's primary key columns, in key order. */
4564
+ static keyColumns(table, idInfoArray, collectionPath) {
4565
+ return idInfoArray.map((info) => {
4566
+ const column = table[info.fieldName];
4567
+ if (!column) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
4568
+ return column;
4569
+ });
4570
+ }
4571
+ /**
4572
+ * The cursor row's key, one value per key column — or `undefined` for a
4573
+ * cursor that names no row.
4574
+ *
4575
+ * A cursor carries the row's address, which for a composite key is every
4576
+ * part joined (see {@link cursorFor}). One that carries fewer parts than
4577
+ * the key has columns — the first column's value alone, say — cannot say
4578
+ * which row it stopped at, and is refused rather than read as a guess.
4579
+ */
4580
+ static cursorKey(cursor, idInfoArray, collectionPath) {
4581
+ const address = cursor.id;
4582
+ if (idInfoArray.length === 1) {
4583
+ const value = address ?? cursor[idInfoArray[0].fieldName];
4584
+ return value === void 0 || value === null ? void 0 : [value];
4585
+ }
4586
+ if (address === void 0 || address === null) {
4587
+ const parts = idInfoArray.map((info) => cursor[info.fieldName]);
4588
+ return parts.some((part) => part === void 0 || part === null) ? void 0 : parts;
4589
+ }
4590
+ let parsed;
4591
+ try {
4592
+ parsed = parseIdValues$1(String(address), idInfoArray);
4593
+ } catch {
4594
+ throw ApiError.badRequest(`Invalid cursor: its row id does not name every key column of '${collectionPath}' (${idInfoArray.map((info) => info.fieldName).join(", ")}). Pass back the \`nextCursor\` from the previous page unchanged — it is opaque and must not be built by hand.`, "INVALID_CURSOR");
4595
+ }
4596
+ return idInfoArray.map((info) => parsed[info.fieldName]);
4597
+ }
4598
+ /**
4599
+ * "Sorts after the cursor row" on the key alone: the key descending, as the
4600
+ * `ORDER BY` ends.
4601
+ *
4602
+ * Every column runs the same direction, so for a composite key the
4603
+ * row-value comparison is exactly the lexicographic order the `ORDER BY`
4604
+ * produces.
4605
+ */
4606
+ static keyAfter(keyColumns, cursorKey) {
4607
+ if (keyColumns.length === 1) return lt(keyColumns[0], cursorKey[0]);
4608
+ const values = keyColumns.map((column, i) => sql.param(cursorKey[i], column));
4609
+ return sql`(${sql.join(keyColumns, sql`, `)}) < (${sql.join(values, sql`, `)})`;
4610
+ }
4611
+ /**
4612
+ * The cursor row's value for a timestamp key, to the microsecond.
4613
+ *
4614
+ * A cursor is built from the row as served, and a timestamp is served to
4615
+ * the millisecond while Postgres stores it to the microsecond. Seeking past
4616
+ * the served value compares the column against something up to 999µs short
4617
+ * of the cursor row's own, so every row in that sliver lands on the wrong
4618
+ * side of it: paging `createdAt desc` over rows written in one transaction
4619
+ * stopped after the first page, and ascending repeated the cursor row
4620
+ * forever.
4621
+ *
4622
+ * So the exact value is read back from the cursor row by its key — but only
4623
+ * while that row still holds the millisecond the cursor carries. A row
4624
+ * deleted or re-stamped since the page was served falls back to the stored
4625
+ * value, which is where the listing was when the cursor was issued.
4626
+ *
4627
+ * Any other key, and a NULL, is returned as it came.
4628
+ */
4629
+ preciseCursorValue(table, target, value, keyColumns, cursorKey) {
4630
+ if (value === null || value === void 0) return value;
4631
+ if (!is(target, PgTimestamp) && !is(target, PgTimestampString)) return value;
4632
+ if (target.precision !== void 0 && target.precision <= 3) return value;
4633
+ const stored = sql`CAST(${value instanceof Date ? value.toISOString() : String(value)} AS ${sql.raw(target.getSQLType())})`;
4634
+ const alias = sql.identifier("__cursor_row");
4635
+ const column = sql`${alias}.${sql.identifier(target.name)}`;
4636
+ return sql`COALESCE((SELECT ${column} FROM ${table} AS ${alias} WHERE ${sql.join(keyColumns.map((key, i) => sql`${alias}.${sql.identifier(key.name)} = ${sql.param(cursorKey[i], key)}`), sql` AND `)} AND ${column} >= ${stored} AND ${column} < ${stored} + INTERVAL '1 millisecond' LIMIT 1), ${stored})`;
4637
+ }
4207
4638
  /**
4208
4639
  * "Sorts strictly after the cursor row", over `keys` and then the id.
4209
4640
  *
@@ -4217,13 +4648,13 @@ var FetchService = class FetchService {
4217
4648
  * NULL and therefore match nothing. Ordering by a nullable column and paging
4218
4649
  * used to drop every row whose sort value was NULL from page two onward.
4219
4650
  */
4220
- buildKeysetComparison(keys, values, idField, cursorId, index = 0) {
4221
- if (index >= keys.length) return lt(idField, cursorId);
4651
+ buildKeysetComparison(keys, values, keyColumns, cursorKey, index = 0) {
4652
+ if (index >= keys.length) return FetchService.keyAfter(keyColumns, cursorKey);
4222
4653
  const { direction, cursorTarget } = keys[index];
4223
4654
  const nullsLast = FetchService.nullsLast(keys[index]);
4224
4655
  const target = keys[index].target;
4225
4656
  const value = values[index];
4226
- const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
4657
+ const rest = this.buildKeysetComparison(keys, values, keyColumns, cursorKey, index + 1);
4227
4658
  const after = (a, b) => direction === "asc" ? gt(a, b) : lt(a, b);
4228
4659
  const afterSql = (a, b) => direction === "asc" ? sql`${a} > ${b}` : sql`${a} < ${b}`;
4229
4660
  if (cursorTarget) return nullsLast ? or(and(isNull(cursorTarget), isNull(target), rest), and(isNotNull(cursorTarget), or(afterSql(target, cursorTarget), isNull(target), and(sql`${target} = ${cursorTarget}`, rest)))) : or(and(isNull(cursorTarget), or(isNotNull(target), and(isNull(target), rest))), and(isNotNull(cursorTarget), or(afterSql(target, cursorTarget), and(sql`${target} = ${cursorTarget}`, rest))));
@@ -4237,7 +4668,7 @@ var FetchService = class FetchService {
4237
4668
  buildRelationScope(hop) {
4238
4669
  const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
4239
4670
  const parentIdInfo = parentPks[0];
4240
- const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
4671
+ const parsedParentId = parseIdValues$1(hop.parentId, parentPks)[parentIdInfo.fieldName];
4241
4672
  const parent = () => {
4242
4673
  const table = getTableForCollection(hop.parentCollection, this.registry);
4243
4674
  const idColumn = table[parentIdInfo.fieldName];
@@ -4277,58 +4708,38 @@ var FetchService = class FetchService {
4277
4708
  const collection = getCollectionByPath(collectionPath, this.registry);
4278
4709
  const table = getTableForCollection(collection, this.registry);
4279
4710
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
4280
- const idInfo = idInfoArray[0];
4281
- const idField = table[idInfo.fieldName];
4282
- if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
4283
4711
  if (!idCanAddressTable(id, table, idInfoArray)) return void 0;
4284
- const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
4712
+ const addressed = rowIdentityCondition(table, idInfoArray, id, collectionPath);
4713
+ const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", {});
4714
+ const identity = narrowing ? and(addressed, narrowing) : addressed;
4285
4715
  const tableName = getTableName(table);
4286
4716
  const qb = this.getQueryBuilder(tableName);
4287
- if (qb) try {
4288
- const withConfig = this.buildWithConfig(collection);
4289
- const hidden = hiddenColumnsOption(getTableColumns(table), collection);
4290
- const row = await qb.findFirst({
4291
- where: andSoftDelete(eq(idField, parsedId), collection, table, withDeleted),
4292
- with: withConfig,
4293
- ...hidden ? { columns: hidden } : {}
4294
- });
4295
- if (!row) return void 0;
4296
- const flatRow = toFlatRow(row, collection, this.registry);
4297
- await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
4298
- return flatRow;
4299
- } catch (e) {
4300
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
4301
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
4302
- 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.");
4717
+ if (qb) {
4718
+ let row = null;
4719
+ try {
4720
+ const hidden = hiddenColumnsOption(getTableColumns(table), collection);
4721
+ row = await qb.findFirst({
4722
+ where: andSoftDelete(identity, collection, table, withDeleted),
4723
+ ...hidden ? { columns: hidden } : {}
4724
+ });
4725
+ } catch (e) {
4726
+ if (reachedDatabase(e)) throw e;
4727
+ logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
4728
+ }
4729
+ if (row === void 0) return void 0;
4730
+ if (row !== null) {
4731
+ const flatRow = toFlatRow(row, collection, this.registry);
4732
+ await this.loadRelationRefs(flatRow, buildCompositeId$1(row, idInfoArray), collection, collectionPath);
4733
+ return stripUnreadable(flatRow, collection);
4303
4734
  }
4304
- if (reachedDatabase(e)) throw e;
4305
- logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
4306
4735
  }
4307
4736
  const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
4308
- const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(eq(idField, parsedId), collection, table, withDeleted)).limit(1);
4737
+ const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
4309
4738
  if (result.length === 0) return void 0;
4310
4739
  const raw = result[0];
4311
- const values = await parseDataFromServer(raw, collection, this.db, this.registry);
4312
- const resolvedRelations = resolveCollectionRelations(collection);
4313
- const propertyKeys = new Set(Object.keys(collection.properties));
4314
- const relationPromises = Object.entries(resolvedRelations).filter(([key]) => propertyKeys.has(key)).map(async ([key, relation]) => {
4315
- if (relation.cardinality === "many") {
4316
- const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, {});
4317
- values[key] = relatedRows.map((e) => createRelationRef(e.id, e.path));
4318
- } else if (relation.cardinality === "one") {
4319
- if (values[key] == null) try {
4320
- const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: 1 });
4321
- if (relatedRows.length > 0) {
4322
- const e = relatedRows[0];
4323
- values[key] = createRelationRef(e.id, e.path);
4324
- }
4325
- } catch (e) {
4326
- if (reachedDatabase(e)) throw e;
4327
- logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
4328
- }
4329
- }
4330
- });
4331
- await Promise.all(relationPromises);
4740
+ const values = await parseDataFromServer(raw, collection);
4741
+ await this.loadRelationRefs(values, buildCompositeId$1(raw, idInfoArray), collection, collectionPath);
4742
+ stripUnreadable(values, collection);
4332
4743
  return {
4333
4744
  ...values,
4334
4745
  id: id.toString()
@@ -4359,6 +4770,7 @@ var FetchService = class FetchService {
4359
4770
  if (results.length === 0) return [];
4360
4771
  const values = await Promise.all(results.map(async (rawRow) => await parseDataFromServer(rawRow, collection)));
4361
4772
  if (include) await this.loadIncludes(values, collection, collectionPath, include);
4773
+ for (const row of values) stripUnreadable(row, collection);
4362
4774
  return values;
4363
4775
  }
4364
4776
  /**
@@ -4366,11 +4778,10 @@ var FetchService = class FetchService {
4366
4778
  */
4367
4779
  async fetchCollection(collectionPath, options = {}) {
4368
4780
  const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
4369
- if (hop) return this.fetchRowsWithConditions(hop.targetCollection.slug, {
4781
+ return this.fetchRowsWithConditions(hop ? hop.targetCollection.slug : collectionPath, {
4370
4782
  ...options,
4371
4783
  relatedTo: hop
4372
4784
  });
4373
- return this.fetchRowsWithConditions(collectionPath, options);
4374
4785
  }
4375
4786
  /**
4376
4787
  * Search rows by text
@@ -4406,6 +4817,10 @@ var FetchService = class FetchService {
4406
4817
  const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, effectivePath, this.filterContext(effectivePath, table));
4407
4818
  if (logicalCondition) allConditions.push(logicalCondition);
4408
4819
  }
4820
+ allConditions.push(...await this.narrowRead(effectivePath, collection, table, "count", FetchService.describeRead({
4821
+ ...options,
4822
+ relatedTo: hop
4823
+ })));
4409
4824
  if (options.vectorSearch) {
4410
4825
  const vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
4411
4826
  if (vectorMeta.filter) allConditions.push(vectorMeta.filter);
@@ -4484,13 +4899,16 @@ var FetchService = class FetchService {
4484
4899
  const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
4485
4900
  if (logicalCondition) conditions.push(logicalCondition);
4486
4901
  }
4902
+ conditions.push(...await this.narrowRead(collectionPath, collection, table, "aggregate", FetchService.describeRead(options)));
4487
4903
  if (conditions.length > 0) {
4488
4904
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(conditions);
4489
4905
  if (finalCondition) query = query.where(finalCondition);
4490
4906
  }
4491
4907
  if (groupColumns.length > 0) {
4492
4908
  query = query.groupBy(...groupColumns.map((g) => g.column));
4909
+ query = query.orderBy(...this.aggregateOrdering(options.orderBy, groupColumns, options.aggregates, selection));
4493
4910
  if (options.limit) query = query.limit(options.limit);
4911
+ if (options.offset) query = query.offset(options.offset);
4494
4912
  }
4495
4913
  const rows = await query;
4496
4914
  const numericAliases = numericAggregateAliases(options.aggregates, collection?.properties ?? {});
@@ -4505,19 +4923,69 @@ var FetchService = class FetchService {
4505
4923
  });
4506
4924
  }
4507
4925
  /**
4508
- * Check if a field value is unique
4926
+ * The `ORDER BY` of a grouped aggregate: the caller's keys, then every
4927
+ * group key they did not name.
4928
+ *
4929
+ * A grouped aggregate is paged like a listing — `limit` bounds it, so more
4930
+ * groups than one page holds are reached with `offset` — and a page is only
4931
+ * a page over a total order. With no `ORDER BY` at all, Postgres returns the
4932
+ * groups in whatever order its plan produces them, so "the next fifty" was
4933
+ * not a thing that existed. The group keys identify a group, so ending on
4934
+ * them makes the order total and puts every page boundary in the same place
4935
+ * on every request.
4936
+ *
4937
+ * A key may name a `groupBy` field or an aggregate by its alias
4938
+ * (`count`, `sum_total`), sorted on the aggregate's own expression — a
4939
+ * drizzle selection is not aliased in the SQL, so there is no output name
4940
+ * to refer to. Anything else is a 400: Postgres would refuse it too, but as
4941
+ * a `GROUP BY` error from inside the statement.
4942
+ */
4943
+ aggregateOrdering(orderBy, groupColumns, aggregates, selection) {
4944
+ const keys = [];
4945
+ const named = /* @__PURE__ */ new Set();
4946
+ for (const [field, direction, nulls] of orderBy ?? []) {
4947
+ const group = groupColumns.find((g) => g.field === field);
4948
+ const aggregate = aggregates.find((a) => a.alias === field);
4949
+ const target = group?.column ?? (aggregate ? selection[aggregate.alias] : void 0);
4950
+ if (!target) {
4951
+ const sortable = [...groupColumns.map((g) => g.field), ...aggregates.map((a) => a.alias)];
4952
+ throw ApiError.badRequest(`An aggregate cannot be sorted by '${field}'. It sorts by what it returns: ${sortable.map((name) => `'${name}'`).join(", ")}.`, "INVALID_ORDER_BY");
4953
+ }
4954
+ keys.push({
4955
+ field,
4956
+ direction,
4957
+ nulls,
4958
+ target
4959
+ });
4960
+ named.add(field);
4961
+ }
4962
+ for (const group of groupColumns) if (!named.has(group.field)) keys.push({
4963
+ field: group.field,
4964
+ direction: "asc",
4965
+ target: group.column
4966
+ });
4967
+ return this.buildOrderExpressions(keys, []);
4968
+ }
4969
+ /**
4970
+ * Check if a field value is unique.
4971
+ *
4972
+ * Deliberately **not** narrowed by `beforeQuery`, and it is the only read
4973
+ * here that is not. The question is whether the value exists anywhere in
4974
+ * the table, not whether it exists among the rows this caller can see:
4975
+ * narrowed, it would answer "unique" for a value a hidden row already
4976
+ * holds, and the insert would then be rejected by the unique constraint
4977
+ * with a message about a row the caller has no way to find. Uniqueness is a
4978
+ * property of the table.
4509
4979
  */
4510
4980
  async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
4511
4981
  if (value === void 0 || value === null) return true;
4512
4982
  const collection = getCollectionByPath(collectionPath, this.registry);
4513
4983
  const table = getTableForCollection(collection, this.registry);
4514
4984
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
4515
- const idInfo = idInfoArray[0];
4516
- const idField = table[idInfo.fieldName];
4517
4985
  const field = table[fieldName];
4518
4986
  if (!field) return true;
4519
- const parsedExcludeId = excludeEntityId ? parseIdValues(excludeEntityId, idInfoArray)[idInfo.fieldName] : void 0;
4520
- const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(field, value, idField, parsedExcludeId);
4987
+ const excludeRow = excludeEntityId && idCanAddressTable(excludeEntityId, table, idInfoArray) ? rowIdentityCondition(table, idInfoArray, excludeEntityId, collectionPath) : void 0;
4988
+ const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(field, value, excludeRow);
4521
4989
  const result = await this.db.select({ count: count() }).from(table).where(and(...conditions));
4522
4990
  return Number(result[0]?.count || 0) === 0;
4523
4991
  }
@@ -4577,13 +5045,13 @@ var FetchService = class FetchService {
4577
5045
  const collection = getCollectionByPath(collectionPath, this.registry);
4578
5046
  const table = getTableForCollection(collection, this.registry);
4579
5047
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
4580
- const idInfo = idInfoArray[0];
4581
- const idField = table[idInfo.fieldName];
4582
5048
  if (!idCanAddressTable(id, table, idInfoArray)) return null;
4583
- const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
5049
+ const addressed = rowIdentityCondition(table, idInfoArray, id, collectionPath);
4584
5050
  const withDeleted = options?.withDeleted;
4585
5051
  const projection = this.columnProjection(table, collection, options?.fields, idInfoArray);
4586
- const result = await this.db.select(projection).from(table).where(andSoftDelete(eq(idField, parsedId), collection, table, withDeleted)).limit(1);
5052
+ const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", FetchService.describeRead({ fields: options?.fields }));
5053
+ const identity = narrowing ? and(addressed, narrowing) : addressed;
5054
+ const result = await this.db.select(projection).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
4587
5055
  if (result.length === 0) return null;
4588
5056
  const row = toRestRow(result[0], collection, this.registry);
4589
5057
  const normalizedInclude = normalizeInclude(include);
@@ -4597,8 +5065,7 @@ var FetchService = class FetchService {
4597
5065
  const collection = getCollectionByPath(collectionPath, this.registry);
4598
5066
  const table = getTableForCollection(collection, this.registry);
4599
5067
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
4600
- const idInfo = idInfoArray[0];
4601
- const idField = table[idInfo.fieldName];
5068
+ if (options.vectorSearch && options.startAfter) throw ApiError.badRequest("Cursor pagination (`startAfter`) cannot be combined with a vector search. Its rows are ordered by a distance computed per query, which cannot key a cursor. Use `limit`/`offset` for the next page of nearest rows.", "VECTOR_CURSOR_UNSUPPORTED");
4602
5069
  let vectorMeta;
4603
5070
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
4604
5071
  const visible = this.columnProjection(table, collection, options.fields, idInfoArray, options.distinct === true);
@@ -4634,6 +5101,7 @@ var FetchService = class FetchService {
4634
5101
  const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
4635
5102
  if (logicalCondition) allConditions.push(logicalCondition);
4636
5103
  }
5104
+ allConditions.push(...await this.narrowRead(collectionPath, collection, table, "list", FetchService.describeRead(options)));
4637
5105
  if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
4638
5106
  if (allConditions.length > 0) {
4639
5107
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
@@ -4645,10 +5113,11 @@ var FetchService = class FetchService {
4645
5113
  const missing = sortKeys.map(([field]) => field).filter((field) => !selected.has(field));
4646
5114
  if (missing.length > 0) throw ApiError.badRequest(`\`distinct\` cannot sort by ${missing.map((f) => `'${f}'`).join(", ")}: a DISTINCT read can only be ordered by columns it returns, or the rows it collapses have no defined order. Add ${missing.map((f) => `'${f}'`).join(", ")} to \`fields\`, or drop them from \`orderBy\`.`, "DISTINCT_ORDER_BY_NOT_SELECTED", { fields: missing });
4647
5115
  }
4648
- const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, sortKeys, collection, options.searchString), idField, !wantsDistinct);
5116
+ const keyColumns = FetchService.keyColumns(table, idInfoArray, collectionPath);
5117
+ const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), ...keyColumns.map((column) => desc(column))] : this.buildOrderExpressions(this.resolveOrderKeys(table, sortKeys, collection, options.searchString), wantsDistinct ? [] : keyColumns);
4649
5118
  if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
4650
5119
  if (options.startAfter) {
4651
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
5120
+ const cursorConditions = this.buildCursorConditions(table, idInfoArray, options, collectionPath);
4652
5121
  if (cursorConditions.length > 0) {
4653
5122
  allConditions.push(...cursorConditions);
4654
5123
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
@@ -4738,11 +5207,13 @@ function serializePivot(pivot, payload, label) {
4738
5207
  var RelationWriteService = class {
4739
5208
  db;
4740
5209
  registry;
5210
+ callContext;
4741
5211
  reads;
4742
- constructor(db, registry) {
5212
+ constructor(db, registry, callContext) {
4743
5213
  this.db = db;
4744
5214
  this.registry = registry;
4745
- this.reads = new RelationService(db, registry);
5215
+ this.callContext = callContext;
5216
+ this.reads = new RelationService(db, registry, callContext);
4746
5217
  }
4747
5218
  /**
4748
5219
  * Remove the junction row linking a parent to `targetId`, leaving the target
@@ -4785,16 +5256,33 @@ var RelationWriteService = class {
4785
5256
  relation: hop.relationKey
4786
5257
  });
4787
5258
  }
5259
+ /**
5260
+ * The target rows of `relation` this caller can see, for a junction diff —
5261
+ * or `undefined` when that is all of them. See
5262
+ * {@link RelationService.visibleTargetCondition}.
5263
+ */
5264
+ async visibleTargets(parentCollection, relation, parentId) {
5265
+ const condition = await this.reads.visibleTargetCondition(parentCollection, relation, parentId);
5266
+ if (!condition) return void 0;
5267
+ const targetCollection = relation.target();
5268
+ const table = getTableForCollection(targetCollection, this.registry);
5269
+ const [idInfo] = requirePrimaryKeys(targetCollection, this.registry);
5270
+ return {
5271
+ table,
5272
+ idColumn: table[idInfo.fieldName],
5273
+ condition
5274
+ };
5275
+ }
4788
5276
  /** A collection's id, parsed to the type its primary key column holds. */
4789
5277
  parsedId(collection, id) {
4790
5278
  const pks = requirePrimaryKeys(collection, this.registry);
4791
- return parseIdValues(id, pks)[pks[0].fieldName];
5279
+ return parseIdValues$1(id, pks)[pks[0].fieldName];
4792
5280
  }
4793
5281
  /** The same, for the membership list a to-many write names. */
4794
5282
  parsedIds(collection, ids) {
4795
5283
  if (ids.length === 0) return [];
4796
5284
  const pks = requirePrimaryKeys(collection, this.registry);
4797
- return ids.map((id) => parseIdValues(id, pks)[pks[0].fieldName]);
5285
+ return ids.map((id) => parseIdValues$1(id, pks)[pks[0].fieldName]);
4798
5286
  }
4799
5287
  /**
4800
5288
  * A membership list as links: the target ids parsed, and each element's
@@ -4812,7 +5300,7 @@ var RelationWriteService = class {
4812
5300
  if (elements.length === 0) return [];
4813
5301
  const pks = requirePrimaryKeys(collection, this.registry);
4814
5302
  return elements.map((element) => ({
4815
- id: parseIdValues(element.id, pks)[pks[0].fieldName],
5303
+ id: parseIdValues$1(element.id, pks)[pks[0].fieldName],
4816
5304
  pivot: element.pivot ? serializePivot(element.pivot, payload, label) : void 0
4817
5305
  }));
4818
5306
  }
@@ -4828,8 +5316,8 @@ var RelationWriteService = class {
4828
5316
  const targetEntityIds = elements.map((element) => element.id);
4829
5317
  const targetCollection = relation.target();
4830
5318
  const label = `${collection.slug}.${key}`;
4831
- if (relation.kind === "via") await applyJunctionMembership(tx, bindJoinPathJunction(this.registry, relation.joinPath, getTableName$1(collection), getTableName$1(targetCollection), label), this.parsedId(collection, id), this.parsedLinks(targetCollection, elements, {}, label));
4832
- else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.parsedLinks(targetCollection, elements, relation.through.properties, label));
5319
+ if (relation.kind === "via") await applyJunctionMembership(tx, bindJoinPathJunction(this.registry, relation.joinPath, getTableName$1(collection), getTableName$1(targetCollection), label), this.parsedId(collection, id), this.parsedLinks(targetCollection, elements, {}, label), await this.visibleTargets(collection, relation, id));
5320
+ else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.parsedLinks(targetCollection, elements, relation.through.properties, label), await this.visibleTargets(collection, relation, id));
4833
5321
  else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
4834
5322
  const targetTable = getTableForCollection(targetCollection, this.registry);
4835
5323
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
@@ -4840,11 +5328,12 @@ var RelationWriteService = class {
4840
5328
  if (!fkCol || !targetIdCol) throw relationMisconfigured(label, `the target table '${getTableName$1(targetCollection)}' has no ${fkCol ? `'${targetIdInfo.fieldName}' key column` : `'${relation.foreignKeyOnTarget}' foreign-key column`}`);
4841
5329
  const parentKeyValue = (await this.reads.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
4842
5330
  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.`);
5331
+ const visible = await this.reads.visibleTargetCondition(collection, relation, id);
4843
5332
  if (targetEntityIds.length > 0) {
4844
- const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
4845
- await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), notInArray(targetIdCol, parsedTargetIds)));
5333
+ const parsedTargetIds = targetEntityIds.map((id) => parseIdValues$1(id, targetPks)[targetIdInfo.fieldName]);
5334
+ await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), notInArray(targetIdCol, parsedTargetIds), visible));
4846
5335
  await tx.update(targetTable).set({ [fkField]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
4847
- } else await tx.update(targetTable).set({ [fkField]: null }).where(eq(fkCol, parentKeyValue));
5336
+ } else await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), visible));
4848
5337
  } else throw relationMisconfigured(label, "it is a to-many relation with no way to write links — a `manyToMany` needs `through`, a `hasMany` needs `foreignKeyOnTarget`");
4849
5338
  }
4850
5339
  }
@@ -4880,15 +5369,16 @@ var RelationWriteService = class {
4880
5369
  if (!foreignKeyColumn) throw relationMisconfigured(label, `'${relation.foreignKeyOnTarget}' is not a column on the target table '${getTableName$1(targetCollection)}'`);
4881
5370
  const sourceKeyValue = (await this.reads.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
4882
5371
  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.`);
4883
- if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
5372
+ const visible = await this.reads.visibleTargetCondition(sourceCollection, relation, sourceEntityId);
5373
+ if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(foreignKeyColumn, sourceKeyValue), visible));
4884
5374
  else {
4885
- const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
5375
+ const parsedNewTargetId = parseIdValues$1(newValue, targetPks)[targetIdInfo.fieldName];
4886
5376
  const targetIdField = targetTable[targetIdInfo.fieldName];
4887
- await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
5377
+ await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(foreignKeyColumn, sourceKeyValue), visible));
4888
5378
  await tx.update(targetTable).set({ [fkField]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
4889
5379
  }
4890
5380
  } catch (e) {
4891
- if (e instanceof ApiError || e?.name === "ApiError") throw e;
5381
+ if (e instanceof ApiError || e?.name === "ApiError" || reachedDatabase(e)) throw e;
4892
5382
  logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
4893
5383
  }
4894
5384
  }
@@ -4903,7 +5393,7 @@ var RelationWriteService = class {
4903
5393
  try {
4904
5394
  const binding = bindJoinPathJunction(this.registry, relation.joinPath, sourceTableName, targetTableName, `${sourceCollection.slug}.${relation.relationName}`);
4905
5395
  const elements = Array.isArray(newValue) ? relationLinkElements(newValue, relation.relationName, sourceCollection.slug) : newValue === null || newValue === void 0 ? [] : relationLinkElements([newValue], relation.relationName, sourceCollection.slug);
4906
- await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.parsedLinks(targetCollection, elements, {}, `${sourceCollection.slug}.${relation.relationName}`));
5396
+ await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.parsedLinks(targetCollection, elements, {}, `${sourceCollection.slug}.${relation.relationName}`), await this.visibleTargets(sourceCollection, relation, sourceEntityId));
4907
5397
  } catch (error) {
4908
5398
  logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
4909
5399
  throw error;
@@ -4916,7 +5406,7 @@ var RelationWriteService = class {
4916
5406
  const label = `${sourceCollection.slug}.${relation.relationName}`;
4917
5407
  try {
4918
5408
  const elements = Array.isArray(newValue) ? relationLinkElements(newValue, relation.relationName, sourceCollection.slug) : [];
4919
- await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo, label), this.parsedId(sourceCollection, sourceEntityId), this.parsedLinks(targetCollection, elements, junctionInfo.properties, label));
5409
+ await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo, label), this.parsedId(sourceCollection, sourceEntityId), this.parsedLinks(targetCollection, elements, junctionInfo.properties, label), await this.visibleTargets(sourceCollection, relation, sourceEntityId));
4920
5410
  } catch (error) {
4921
5411
  logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
4922
5412
  throw error;
@@ -4939,7 +5429,7 @@ var RelationWriteService = class {
4939
5429
  const parentTable = getTableForCollection(parentCollection, this.registry);
4940
5430
  const parentPks = requirePrimaryKeys(parentCollection, this.registry);
4941
5431
  const parentIdInfo = parentPks[0];
4942
- const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5432
+ const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
4943
5433
  const parentIdCol = parentTable[parentIdInfo.fieldName];
4944
5434
  const parentSourceCol = parentTable[parentSourceColName];
4945
5435
  const targetFKCol = targetTable[targetFKColName];
@@ -4953,7 +5443,7 @@ var RelationWriteService = class {
4953
5443
  if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
4954
5444
  continue;
4955
5445
  }
4956
- const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
5446
+ const parsedTargetId = parseIdValues$1(newTargetId, targetPks)[targetIdInfo.fieldName];
4957
5447
  if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
4958
5448
  else throw ApiError.badRequest(`Cannot write relation '${label}': row '${parentId}' has no value in '${parentSourceColName}', which is the column its joinPath joins on, so there is nothing for the related row to point at.`, "RELATION_SOURCE_KEY_EMPTY");
4959
5449
  await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
@@ -5022,6 +5512,10 @@ var RelationWriteService = class {
5022
5512
  * silently empties itself the first time it is incremented from an unset column
5023
5513
  * is worse than one that never worked.
5024
5514
  *
5515
+ * What an operation produces answers to the property's declared bounds, and
5516
+ * that check is compiled here too, as conditions for the same UPDATE — see
5517
+ * {@link compileFieldOpBounds}.
5518
+ *
5025
5519
  * @module
5026
5520
  */
5027
5521
  /** What the column is, as Postgres sees it: `text[]`, `jsonb`, `integer`, … */
@@ -5100,26 +5594,184 @@ function compileFieldOps(table, fieldOps, context) {
5100
5594
  for (const [field, op] of Object.entries(fieldOps)) compiled[field] = compileFieldOp(table, field, op, context);
5101
5595
  return compiled;
5102
5596
  }
5597
+ /**
5598
+ * The declared bounds each operation's *result* must satisfy, as conditions
5599
+ * for the UPDATE that applies it.
5600
+ *
5601
+ * `{ stock: -5 }` on `stock: { validation: { min: 0 } }` is refused before the
5602
+ * statement is built, because the value is in the request. What
5603
+ * `{ stock: { $inc: -1000 } }` produces depends on the stored row, which the
5604
+ * request never sees, so it can only be judged where the row is: in the WHERE
5605
+ * of the UPDATE that does the arithmetic, over the row that statement holds
5606
+ * locked. Reading the row first and judging the sum would reopen the race the
5607
+ * operation exists to close — two decrements of a stock of 1 would each read
5608
+ * `1`, each find room for one more, and both go through. A condition on the
5609
+ * UPDATE cannot be raced that way: under READ COMMITTED an UPDATE blocked on a
5610
+ * concurrent writer re-evaluates its WHERE against the row that writer
5611
+ * committed, so the second decrement sees `0 - 1` and matches nothing.
5612
+ *
5613
+ * The bound is the whole declared range, applied to the result the way it
5614
+ * applies to a value: a number's `min`, `max`, `moreThan`, `lessThan`,
5615
+ * `positive` and `negative`, an array's `min` and `max` items. `integer` is not
5616
+ * here: a whole increment of a whole number stays whole, and the request
5617
+ * already refuses a fractional one. `$merge` has no bounds to hold.
5618
+ */
5619
+ function compileFieldOpBounds(table, fieldOps, properties, context) {
5620
+ const bounds = [];
5621
+ for (const [field, op] of Object.entries(fieldOps)) {
5622
+ const property = properties[field];
5623
+ if (!property?.validation) continue;
5624
+ if (property.type === "number" && op.operator === "$inc" && typeof op.operand === "number") {
5625
+ const result = compileFieldOp(table, field, op, context);
5626
+ bounds.push(...numberBounds(field, property.validation, result, op.operand));
5627
+ } else if (property.type === "array" && (op.operator === "$push" || op.operator === "$pull")) {
5628
+ const column = table[field];
5629
+ const result = compileFieldOp(table, field, op, context);
5630
+ const size = column && isNativeArray(sqlTypeOf(column)) ? sql`cardinality(${result})` : sql`jsonb_array_length((${result})::jsonb)`;
5631
+ bounds.push(...arrayBounds(field, property.validation, size, op.operator));
5632
+ }
5633
+ }
5634
+ return bounds;
5635
+ }
5636
+ function numberBounds(field, rules, result, operand) {
5637
+ const by = `$inc by ${operand}`;
5638
+ const bounds = [];
5639
+ if (rules.min !== void 0) bounds.push({
5640
+ field,
5641
+ code: "min",
5642
+ holds: sql`(${result}) >= ${rules.min}::numeric`,
5643
+ message: `'${field}' must be at least ${rules.min}, and ${by} would take it below that.`
5644
+ });
5645
+ if (rules.max !== void 0) bounds.push({
5646
+ field,
5647
+ code: "max",
5648
+ holds: sql`(${result}) <= ${rules.max}::numeric`,
5649
+ message: `'${field}' must be at most ${rules.max}, and ${by} would take it above that.`
5650
+ });
5651
+ if (rules.moreThan !== void 0) bounds.push({
5652
+ field,
5653
+ code: "more_than",
5654
+ holds: sql`(${result}) > ${rules.moreThan}::numeric`,
5655
+ message: `'${field}' must be greater than ${rules.moreThan}, and ${by} would take it to ${rules.moreThan} or below.`
5656
+ });
5657
+ if (rules.lessThan !== void 0) bounds.push({
5658
+ field,
5659
+ code: "less_than",
5660
+ holds: sql`(${result}) < ${rules.lessThan}::numeric`,
5661
+ message: `'${field}' must be less than ${rules.lessThan}, and ${by} would take it to ${rules.lessThan} or above.`
5662
+ });
5663
+ if (rules.positive) bounds.push({
5664
+ field,
5665
+ code: "positive",
5666
+ holds: sql`(${result}) > 0`,
5667
+ message: `'${field}' must be positive, and ${by} would take it to 0 or below.`
5668
+ });
5669
+ if (rules.negative) bounds.push({
5670
+ field,
5671
+ code: "negative",
5672
+ holds: sql`(${result}) < 0`,
5673
+ message: `'${field}' must be negative, and ${by} would take it to 0 or above.`
5674
+ });
5675
+ return bounds;
5676
+ }
5677
+ function arrayBounds(field, rules, size, operator) {
5678
+ const items = (n) => `${n} item${n === 1 ? "" : "s"}`;
5679
+ const bounds = [];
5680
+ if (rules.min !== void 0) bounds.push({
5681
+ field,
5682
+ code: "min_items",
5683
+ holds: sql`COALESCE(${size} >= ${rules.min}::numeric, TRUE)`,
5684
+ message: `'${field}' must have at least ${items(rules.min)}, and ${operator} would leave it with fewer.`
5685
+ });
5686
+ if (rules.max !== void 0) bounds.push({
5687
+ field,
5688
+ code: "max_items",
5689
+ holds: sql`COALESCE(${size} <= ${rules.max}::numeric, TRUE)`,
5690
+ message: `'${field}' must have at most ${items(rules.max)}, and ${operator} would leave it with more.`
5691
+ });
5692
+ return bounds;
5693
+ }
5694
+ /**
5695
+ * Which bounds the row, as it now stands, would break — for an UPDATE that
5696
+ * carried bounds and matched nothing.
5697
+ *
5698
+ * A guarded UPDATE matching zero rows means one of three things: the row is not
5699
+ * there for this caller (404), a row-level security policy refused the write
5700
+ * (403), or the operation's result broke a bound (400). The first two are
5701
+ * `explainZeroRowWrite`'s; this answers the third, by evaluating each bound
5702
+ * against the row over the same handle and the same key conditions. An empty
5703
+ * answer — the row is not visible, or every bound holds — leaves the decision to
5704
+ * `explainZeroRowWrite`.
5705
+ *
5706
+ * It is a second statement, so the row can change in between. A row deleted in
5707
+ * between is a 404 here as it would have been anyway. A row changed so that the
5708
+ * operation now fits reads as "every bound holds" and falls through to the
5709
+ * row-level-security answer; the write was refused either way, and the
5710
+ * refusal is never turned into a write.
5711
+ *
5712
+ * Only booleans come back — whether each bound holds — never the stored value.
5713
+ */
5714
+ async function brokenFieldOpBounds(handle, table, conditions, bounds) {
5715
+ if (bounds.length === 0) return [];
5716
+ const selection = {};
5717
+ bounds.forEach((bound, index) => {
5718
+ selection[`bound${index}`] = sql`(${bound.holds})`;
5719
+ });
5720
+ const row = (await handle.select(selection).from(table).where(and(...conditions)).limit(1))[0];
5721
+ if (!row) return [];
5722
+ return bounds.filter((_bound, index) => row[`bound${index}`] === false);
5723
+ }
5724
+ /** The 400 a write gets when an operation's result breaks a bound. */
5725
+ function fieldOpBoundsError(collectionSlug, broken) {
5726
+ const violations = broken.map(({ field, code, message }) => ({
5727
+ field,
5728
+ code,
5729
+ message
5730
+ }));
5731
+ const messages = violations.map((violation) => violation.message);
5732
+ return ApiError.badRequest(messages.join(" "), "VALIDATION_CONSTRAINT", {
5733
+ collection: collectionSlug,
5734
+ violations,
5735
+ messages
5736
+ });
5737
+ }
5103
5738
  //#endregion
5104
5739
  //#region src/services/PersistService.ts
5105
5740
  /**
5741
+ * The top-level properties whose value is stamped once, when the row is
5742
+ * created: `on_create` dates and `user_on_create` identities.
5743
+ *
5744
+ * What they hold is a fact about the row's creation, so a write to a row that
5745
+ * already exists has nothing to say about them — an update, and the
5746
+ * conflict-update of an upsert that met a stored row. Top level only: a stamp
5747
+ * nested inside a `map` is part of that map's value, not a column a write can
5748
+ * leave out.
5749
+ */
5750
+ function createStampKeys(properties) {
5751
+ const keys = [];
5752
+ for (const [key, property] of Object.entries(properties ?? {})) if (property?.type === "date" && property.autoValue === "on_create" || property?.type === "string" && property.autoValue === "user_on_create") keys.push(key);
5753
+ return keys;
5754
+ }
5755
+ /**
5106
5756
  * Service for handling all row write operations.
5107
5757
  * Handles saving, deleting, and updating rows.
5108
5758
  */
5109
5759
  var PersistService = class {
5110
5760
  db;
5111
5761
  registry;
5762
+ callContext;
5112
5763
  /** Reads: whether a row is under a parent, the key a link joins on. */
5113
5764
  relationService;
5114
5765
  /** Writes: junction membership, foreign-key stamping, links. */
5115
5766
  relationWrites;
5116
5767
  fetchService;
5117
- constructor(db, registry) {
5768
+ constructor(db, registry, callContext) {
5118
5769
  this.db = db;
5119
5770
  this.registry = registry;
5120
- this.relationService = new RelationService(db, registry);
5121
- this.relationWrites = new RelationWriteService(db, registry);
5122
- this.fetchService = new FetchService(db, registry);
5771
+ this.callContext = callContext;
5772
+ this.relationService = new RelationService(db, registry, callContext);
5773
+ this.relationWrites = new RelationWriteService(db, registry, callContext);
5774
+ this.fetchService = new FetchService(db, registry, callContext);
5123
5775
  }
5124
5776
  /**
5125
5777
  * Set the columns of one many-to-many link, without touching the membership.
@@ -5162,14 +5814,20 @@ var PersistService = class {
5162
5814
  const collection = getCollectionByPath(collectionPath, this.registry);
5163
5815
  const table = getTableForCollection(collection, this.registry);
5164
5816
  const idInfoArray = getPrimaryKeys(collection, this.registry);
5165
- const parsedIdObj = parseIdValues(id, idInfoArray);
5817
+ const parsedIdObj = parseIdValues$1(id, idInfoArray);
5166
5818
  const conditions = [];
5167
5819
  for (const info of idInfoArray) {
5168
5820
  const field = table[info.fieldName];
5169
5821
  if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
5170
5822
  conditions.push(eq(field, parsedIdObj[info.fieldName]));
5171
5823
  }
5172
- if (((await this.db.delete(table).where(and(...conditions))).rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
5824
+ let result;
5825
+ try {
5826
+ result = await this.db.delete(table).where(and(...conditions));
5827
+ } catch (error) {
5828
+ throw this.toUserFriendlyError(error, collection.slug, collection, "delete");
5829
+ }
5830
+ if ((result.rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
5173
5831
  }
5174
5832
  /**
5175
5833
  * Delete all rows from a collection
@@ -5213,6 +5871,10 @@ var PersistService = class {
5213
5871
  * the row already exists — which is what a re-runnable import needs. The
5214
5872
  * conflict is matched on the primary key unless `options.onConflict` names
5215
5873
  * other columns; see {@link SaveProps.onConflict} for why that matters.
5874
+ * When the INSERT meets a stored row, the conflict-update sets what the
5875
+ * caller wrote and nothing the create alone decides — see
5876
+ * {@link PersistSaveOptions.insertOnlyKeys} — and only on a row the caller's
5877
+ * `beforeQuery` scope reaches.
5216
5878
  *
5217
5879
  * `values` may carry field operations (`{ views: { $inc: 1 } }`). They are
5218
5880
  * split out here rather than at the REST boundary because every request
@@ -5230,7 +5892,7 @@ var PersistService = class {
5230
5892
  effectiveCollectionPath = hop.targetCollection.slug;
5231
5893
  const parentIdForWrite = () => {
5232
5894
  const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
5233
- return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
5895
+ return parseIdValues$1(hop.parentId, parentPks)[parentPks[0].fieldName];
5234
5896
  };
5235
5897
  if (isManyToMany(hop.relation)) junctionTableInfo = {
5236
5898
  parentCollection: hop.parentCollection,
@@ -5291,10 +5953,11 @@ var PersistService = class {
5291
5953
  let currentId;
5292
5954
  if (id && !options?.upsert) {
5293
5955
  currentId = id;
5294
- const idValues = parseIdValues(id, idInfoArray);
5956
+ const idValues = parseIdValues$1(id, idInfoArray);
5295
5957
  if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
5296
5958
  const scalarKeys = Object.keys(entityData);
5297
5959
  const compiledOps = fieldOpKeys.length > 0 ? compileFieldOps(table, fieldOps, { collectionPath: effectiveCollectionPath }) : void 0;
5960
+ const bounds = fieldOpKeys.length > 0 ? compileFieldOpBounds(table, fieldOps, collection.properties ?? {}, { collectionPath: effectiveCollectionPath }) : [];
5298
5961
  if (scalarKeys.length > 0 || compiledOps) {
5299
5962
  const updateQuery = tx.update(table).set({
5300
5963
  ...entityData,
@@ -5305,11 +5968,15 @@ var PersistService = class {
5305
5968
  const field = table[info.fieldName];
5306
5969
  conditions.push(eq(field, idValues[info.fieldName]));
5307
5970
  }
5308
- if (((await updateQuery.where(and(...conditions))).rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(tx, table, conditions, effectiveCollectionPath, currentId, "update");
5971
+ if (((await updateQuery.where(and(...conditions, ...bounds.map((bound) => bound.holds)))).rowCount ?? 0) === 0) {
5972
+ const broken = await brokenFieldOpBounds(tx, table, conditions, bounds);
5973
+ if (broken.length > 0) throw fieldOpBoundsError(collection.slug, broken);
5974
+ throw await this.explainZeroRowWrite(tx, table, conditions, effectiveCollectionPath, currentId, "update");
5975
+ }
5309
5976
  }
5310
5977
  } else {
5311
5978
  const dataForInsert = { ...entityData };
5312
- if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
5979
+ if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues$1(id, idInfoArray));
5313
5980
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
5314
5981
  const insertQuery = tx.insert(table).values(dataForInsert);
5315
5982
  const targetFields = options?.onConflict && options.onConflict.length > 0 ? [...options.onConflict] : idInfoArray.map((info) => info.fieldName);
@@ -5324,11 +5991,24 @@ var PersistService = class {
5324
5991
  const set = { ...dataForInsert };
5325
5992
  for (const info of idInfoArray) delete set[info.fieldName];
5326
5993
  for (const field of targetFields) delete set[field];
5327
- for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
5328
- result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
5994
+ for (const key of createStampKeys(collection.properties)) delete set[key];
5995
+ for (const key of options.insertOnlyKeys ?? []) delete set[key];
5996
+ const scope = await this.writeScope(collection, effectiveCollectionPath, table);
5997
+ const updating = Object.keys(set).length > 0;
5998
+ result = updating ? await insertQuery.onConflictDoUpdate({
5329
5999
  target,
5330
- set
6000
+ set,
6001
+ setWhere: scope
5331
6002
  }).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
6003
+ if (result.length === 0) {
6004
+ const keyConditions = target.map((column, i) => eq(column, dataForInsert[targetFields[i]]));
6005
+ if (scope) keyConditions.push(scope);
6006
+ const keyLabel = targetFields.map((field) => String(dataForInsert[field])).join(", ");
6007
+ const missing = `No row "${keyLabel}" in "${effectiveCollectionPath}" to update.`;
6008
+ if (updating) throw await explainZeroRowWrite(tx, table, keyConditions, `Not allowed to update "${keyLabel}" in "${effectiveCollectionPath}": a row-level security policy rejected the write.`, missing);
6009
+ result = await tx.select(returningKeys).from(table).where(and(...keyConditions)).limit(1);
6010
+ if (result.length === 0) throw ApiError.notFound(missing);
6011
+ }
5332
6012
  } else result = await insertQuery.returning(returningKeys);
5333
6013
  const resultRow = result[0];
5334
6014
  if (!resultRow) if (id) currentId = id;
@@ -5349,6 +6029,26 @@ var PersistService = class {
5349
6029
  return finalEntity;
5350
6030
  }
5351
6031
  /**
6032
+ * The caller's `beforeQuery` scope over this table, as one condition: what
6033
+ * a stored row must satisfy for a write that meets it to change it. The
6034
+ * same scope the update gate and the single get apply; `undefined` when the
6035
+ * collection declares no hook.
6036
+ */
6037
+ writeScope(collection, collectionPath, table) {
6038
+ const [key] = getPrimaryKeys(collection, this.registry);
6039
+ return beforeQueryCondition({
6040
+ registry: this.registry,
6041
+ callContext: this.callContext
6042
+ }, collection, collectionPath, table, {
6043
+ operation: "get",
6044
+ query: {}
6045
+ }, {
6046
+ collection,
6047
+ registry: this.registry,
6048
+ sourceIdColumn: key ? table[key.fieldName] : void 0
6049
+ });
6050
+ }
6051
+ /**
5352
6052
  * Get the RelationService instance for external use
5353
6053
  */
5354
6054
  getRelationService() {
@@ -5377,7 +6077,7 @@ var PersistService = class {
5377
6077
  * so `author_id` had to be translated to `authorId` by whoever read the
5378
6078
  * message — which nothing does. See `pgFieldViolations`.
5379
6079
  */
5380
- toUserFriendlyError(error, collectionSlug, collection) {
6080
+ toUserFriendlyError(error, collectionSlug, collection, operation = "save") {
5381
6081
  if (error instanceof ApiError || error?.name === "ApiError") return error;
5382
6082
  const pgError = extractPgError(error);
5383
6083
  if (pgError) {
@@ -5387,7 +6087,7 @@ var PersistService = class {
5387
6087
  collection: collectionSlug,
5388
6088
  violations
5389
6089
  } : void 0;
5390
- if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`, details) : ApiError.badRequest(message, `PG_${code}`, details);
6090
+ if (/^2[23]/.test(code)) return code === "23505" || code === "23001" || code === "23503" && operation === "delete" ? ApiError.conflict(message, `PG_${code}`, details) : ApiError.badRequest(message, `PG_${code}`, details);
5391
6091
  if (isRowLevelSecurityDenial(error)) return ApiError.forbidden(message, "WRITE_DENIED");
5392
6092
  return new Error(message, { cause: error });
5393
6093
  }
@@ -5413,13 +6113,15 @@ var PersistService = class {
5413
6113
  var DataService = class {
5414
6114
  db;
5415
6115
  registry;
6116
+ callContext;
5416
6117
  fetchService;
5417
6118
  persistService;
5418
- constructor(db, registry) {
6119
+ constructor(db, registry, callContext) {
5419
6120
  this.db = db;
5420
6121
  this.registry = registry;
5421
- this.fetchService = new FetchService(db, registry);
5422
- this.persistService = new PersistService(db, registry);
6122
+ this.callContext = callContext;
6123
+ this.fetchService = new FetchService(db, registry, callContext);
6124
+ this.persistService = new PersistService(db, registry, callContext);
5423
6125
  }
5424
6126
  /**
5425
6127
  * Fetch a single row by ID
@@ -5570,6 +6272,10 @@ var WriteTransactionScope = class {
5570
6272
  open = true;
5571
6273
  inflight = /* @__PURE__ */ new Set();
5572
6274
  commitHooks = [];
6275
+ /** The first tracked statement that failed, whether or not its caller caught it. */
6276
+ failure;
6277
+ settledHooks = [];
6278
+ settledContext;
5573
6279
  /** The transaction handle, for statements in this package that are already written against drizzle. */
5574
6280
  tx;
5575
6281
  /** Attach the transaction, once it has begun. */
@@ -5600,7 +6306,10 @@ var WriteTransactionScope = class {
5600
6306
  const done = () => {
5601
6307
  this.inflight.delete(settled);
5602
6308
  };
5603
- settled.then(done, done);
6309
+ settled.then(done, (error) => {
6310
+ this.failure ??= error;
6311
+ done();
6312
+ });
5604
6313
  return settled;
5605
6314
  }
5606
6315
  afterCommit(fn) {
@@ -5617,6 +6326,65 @@ var WriteTransactionScope = class {
5617
6326
  this.open = false;
5618
6327
  }
5619
6328
  /**
6329
+ * Refuse to commit a transaction a failed statement has aborted.
6330
+ *
6331
+ * Once any statement fails, Postgres aborts the whole transaction, and
6332
+ * catching the error in JavaScript does not undo that. The COMMIT that
6333
+ * follows is answered with the tag `ROLLBACK` and no error, so the driver
6334
+ * resolves it: the write reported success — 200, webhooks, realtime,
6335
+ * the idempotency record — while nothing was stored. A callback that
6336
+ * wrapped a lookup or a job enqueue in `try/catch` was enough.
6337
+ *
6338
+ * So one statement is asked of the transaction before its commit. On an
6339
+ * aborted one it fails with `25P02`, and the write is refused and rolled
6340
+ * back instead. A failed `context.data` write is not caught by this: it
6341
+ * runs in a savepoint, is undone on its own, and leaves the transaction
6342
+ * usable — which is what makes catching one safe.
6343
+ */
6344
+ async assertCommittable() {
6345
+ if (!this.tx) return;
6346
+ try {
6347
+ await this.tx.execute(sql`SELECT 1`);
6348
+ } catch (error) {
6349
+ if (extractPgError(error)?.code !== "25P02") throw error;
6350
+ logger.error("[transaction] A statement on this write's transaction failed and its error was caught; the write was rolled back", { error: this.failure ?? error });
6351
+ const refusal = new ApiError(500, "TRANSACTION_ABORTED", "A statement on this write's transaction failed and its error was caught. That aborts the transaction in Postgres, so the write was rolled back and nothing was stored. A failed `context.data` write is undone on its own and may be caught; any other failed statement (a read, a job enqueue) has to be let through.");
6352
+ refusal.cause = this.failure ?? error;
6353
+ throw refusal;
6354
+ }
6355
+ }
6356
+ /**
6357
+ * What a hook deferred with {@link afterSettled} is handed as `context`:
6358
+ * the same caller, on a driver where every call is a transaction of its
6359
+ * own. The write's transaction is gone by the time the hook runs, so a
6360
+ * context bound to it would be bound to a connection back in the pool.
6361
+ */
6362
+ setSettledContext(context) {
6363
+ this.settledContext = context;
6364
+ }
6365
+ /**
6366
+ * Run `fn` once this write's transaction is over, whichever way it went,
6367
+ * outside it — for what a failure hook hands off (a job, a queue message,
6368
+ * a webhook), which must not ride the transaction the failure rolls back.
6369
+ * `false` when this scope has no context to hand it; the caller runs it
6370
+ * itself then.
6371
+ */
6372
+ afterSettled(fn) {
6373
+ if (!this.settledContext) return false;
6374
+ this.settledHooks.push(fn);
6375
+ return true;
6376
+ }
6377
+ /** The transaction committed or rolled back: run what was waiting for either. */
6378
+ async settled() {
6379
+ const context = this.settledContext;
6380
+ if (!context) return;
6381
+ for (const hook of this.settledHooks.splice(0)) try {
6382
+ await storage.run(void 0, () => hook(context));
6383
+ } catch (error) {
6384
+ logger.error("[transaction] A hook deferred to the end of the write threw", { error });
6385
+ }
6386
+ }
6387
+ /**
5620
6388
  * The transaction is over, whichever way it went. Nothing may reach it now:
5621
6389
  * its client is back in the pool, and a callback's detached promise still
5622
6390
  * carries this scope in its async context.
@@ -5734,7 +6502,15 @@ function classifyCollectionChanges(before, after, facts) {
5734
6502
  });
5735
6503
  continue;
5736
6504
  }
5737
- classifyProperties(previous.get(slug), collection, changes, facts);
6505
+ const was = previous.get(slug);
6506
+ if (qualifiedTable(was) !== qualifiedTable(collection)) changes.push({
6507
+ kind: "rename-table",
6508
+ verdict: "needs-migration",
6509
+ collection: slug,
6510
+ detail: `"${slug}" moves from table "${qualifiedTable(was)}" to "${qualifiedTable(collection)}", which would create the new table empty and leave every row in the old one.`,
6511
+ remedy: "The ensure path never renames or moves a table. Do it in a migration you have read (ALTER TABLE … RENAME TO, or SET SCHEMA), then change the collection to match."
6512
+ });
6513
+ classifyProperties(was, collection, changes, facts);
5738
6514
  }
5739
6515
  for (const [slug, collection] of previous) {
5740
6516
  if (next.has(slug)) continue;
@@ -6043,6 +6819,13 @@ function effectiveSqlRole(requestedRole) {
6043
6819
  /** How {@link effectiveSqlRole} names "whatever role the connection holds". */
6044
6820
  var CONNECTION_OWNER = "<connection owner>";
6045
6821
  /**
6822
+ * What puts a session back after caller SQL: the session user and role, then
6823
+ * every setting. Two statements, because the extended protocol refuses a
6824
+ * multi-command string. Pinned settings (`search_path` from the connection's
6825
+ * startup options) come back as they were pinned.
6826
+ */
6827
+ var RESET_SESSION_STATEMENTS = ["SET SESSION AUTHORIZATION DEFAULT", "RESET ALL"];
6828
+ /**
6046
6829
  * A statement named a database role the connection cannot assume.
6047
6830
  *
6048
6831
  * Its own type because the tempting recovery — run it anyway, as the owner — is
@@ -6062,6 +6845,66 @@ var RoleSwitchUnavailableError = class extends Error {
6062
6845
  }
6063
6846
  };
6064
6847
  /**
6848
+ * Fold what a `beforeSave` returned into the values being saved.
6849
+ *
6850
+ * The hook returns the values that will be saved, so a key it returns replaces
6851
+ * the value outright: an array it filtered stays filtered, a map it narrowed
6852
+ * stays narrow. A key it leaves out keeps the value it was given, so a hook
6853
+ * that returns only the field it computed still saves the rest.
6854
+ *
6855
+ * Not a deep merge. `mergeDeep` merges arrays of objects element by element and
6856
+ * keeps every element past the end of the shorter one, so a hook that dropped
6857
+ * one line item of three had the last one merged back in and the order was
6858
+ * stored with a line duplicated.
6859
+ */
6860
+ function applyBeforeSaveResult(values, result) {
6861
+ return {
6862
+ ...values,
6863
+ ...result
6864
+ };
6865
+ }
6866
+ /**
6867
+ * The keys of a create's final values that its INSERT alone may write: those
6868
+ * the caller did not send, no `beforeSave` hook wrote, and that are not an
6869
+ * `on_update` stamp — a declared default, a tenant stamp, a create-time stamp.
6870
+ * See `PersistSaveOptions.insertOnlyKeys`.
6871
+ */
6872
+ function insertOnlyKeysOf(finalValues, callerValues, beforeHooks, afterHooks, properties) {
6873
+ const sent = new Set(Object.keys(callerValues ?? {}));
6874
+ const updateStamps = new Set(Object.entries(properties ?? {}).filter(([, property]) => property?.type === "date" && property.autoValue === "on_update" || property?.type === "string" && property.autoValue === "user_on_update").map(([key]) => key));
6875
+ return Object.keys(finalValues).filter((key) => !sent.has(key) && !updateStamps.has(key) && beforeHooks[key] === afterHooks[key]);
6876
+ }
6877
+ /**
6878
+ * Refuse an update that stamps a `softDelete` collection's field.
6879
+ *
6880
+ * Stamping it IS the delete — every read hides the row from then on — so it is
6881
+ * `delete`'s to do, where the permission is `delete`, `beforeDelete` can veto,
6882
+ * `afterDelete` runs and history says "delete". Written by an update, a caller
6883
+ * holding only write access trashed rows, past all of that. Clearing it stays
6884
+ * an update: that is how a row is restored, and what a form sends back for a
6885
+ * live row.
6886
+ */
6887
+ function assertUpdateDoesNotSoftDelete(collection, values, path) {
6888
+ const softDelete = resolveSoftDelete(collection);
6889
+ if (!softDelete) return;
6890
+ const value = values?.[softDelete.field];
6891
+ if (value === void 0 || value === null) return;
6892
+ const slug = collection?.slug ?? path;
6893
+ const message = `'${softDelete.field}' on '${slug}' records a delete, so an update may only clear it (null restores the row). Delete the row to set it.`;
6894
+ throw ApiError.badRequest(message, "FIELD_NOT_WRITABLE", {
6895
+ collection: slug,
6896
+ violations: [{
6897
+ field: softDelete.field,
6898
+ code: "soft_delete",
6899
+ message
6900
+ }]
6901
+ });
6902
+ }
6903
+ /** A key that names a row: not absent, and not the empty string a form sends. */
6904
+ function isPresentKey(id) {
6905
+ return id !== void 0 && id !== null && id !== "";
6906
+ }
6907
+ /**
6065
6908
  * Refuse a write that would leave a *required* `created_by` / `updated_by`
6066
6909
  * column null because nobody is acting.
6067
6910
  *
@@ -6173,7 +7016,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6173
7016
  this.db = db;
6174
7017
  this.registry = registry;
6175
7018
  this.poolManager = poolManager;
6176
- this.dataService = new DataService(db, registry);
7019
+ this.dataService = new DataService(db, registry, () => this.buildCallContext());
6177
7020
  this.realtimeService = realtimeService;
6178
7021
  this.historyService = historyService;
6179
7022
  this.user = user;
@@ -6398,17 +7241,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6398
7241
  return out;
6399
7242
  }));
6400
7243
  }
6401
- async fetchCollection({ path, collection, filter, limit, offset, startAfter, orderBy, searchString, order, vectorSearch }) {
7244
+ async fetchCollection({ path, collection, startAfter, ...query }) {
6402
7245
  const rows = await this.dataService.fetchCollection(path, {
6403
- filter,
6404
- orderBy,
6405
- order,
6406
- limit,
6407
- offset,
7246
+ ...query,
6408
7247
  startAfter,
6409
- databaseId: collection?.databaseId,
6410
- searchString,
6411
- vectorSearch
7248
+ databaseId: collection?.databaseId
6412
7249
  });
6413
7250
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
6414
7251
  if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
@@ -6592,16 +7429,17 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6592
7429
  const { collection: slug, userField, tenantField } = tenant.from.membership;
6593
7430
  const cap = PostgresBackendDriver.TENANT_MEMBERSHIP_CAP;
6594
7431
  try {
6595
- const rows = await this.dataService.fetchCollection(slug, {
7432
+ const rows = await withFieldViewer(void 0, () => this.dataService.fetchCollection(slug, {
6596
7433
  filter: { [userField]: ["==", uid] },
6597
7434
  limit: cap + 1,
6598
7435
  databaseId: collection?.databaseId
6599
- });
7436
+ }));
6600
7437
  return {
6601
7438
  tenants: Array.from(new Set(rows.slice(0, cap).map((row) => row[tenantField]).filter((value) => value !== null && value !== void 0).map((value) => typeof value === "object" ? value.id ?? value : value))),
6602
7439
  complete: rows.length <= cap
6603
7440
  };
6604
7441
  } catch (err) {
7442
+ if (reachedDatabase(err)) throw err;
6605
7443
  logger.debug(`[save] Could not read '${slug}' to resolve the caller's tenants`, { detail: err instanceof Error ? err.message : String(err) });
6606
7444
  return {
6607
7445
  tenants: [],
@@ -6609,21 +7447,86 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6609
7447
  };
6610
7448
  }
6611
7449
  }
7450
+ /**
7451
+ * The stored row an upsert's key names, if this caller can address it —
7452
+ * with its address and the values to update it with.
7453
+ *
7454
+ * Read as the caller reads: their policies and `beforeQuery` scope, and
7455
+ * soft-deleted rows hidden, so a row found here is one an update may
7456
+ * target. On the trusted plane for its fields only, like
7457
+ * {@link resolveCallerTenants}: the key columns are the server's to see.
7458
+ *
7459
+ * `undefined` when the key is incomplete (the write is a plain insert,
7460
+ * which is the persistence layer's rule too), when a key value is not a
7461
+ * plain value, or when no such row is visible. None of those can write a
7462
+ * stored row wrongly: the statement's own conflict branch sets only what
7463
+ * the caller sent, and only on a row in their scope.
7464
+ *
7465
+ * The values drop the key and the conflict target, which the stored row
7466
+ * already has: a natural-key upsert carrying a key the caller invented
7467
+ * must not move the row onto it.
7468
+ */
7469
+ async findUpsertTarget(path, collection, values, id, onConflict) {
7470
+ const primaryKeys = getPrimaryKeys(collection, this.registry);
7471
+ const key = { ...values };
7472
+ if (isPresentKey(id)) Object.assign(key, parseIdValues$1(id, primaryKeys));
7473
+ const targetFields = onConflict && onConflict.length > 0 ? [...onConflict] : primaryKeys.map((info) => info.fieldName);
7474
+ if (targetFields.length === 0) return void 0;
7475
+ const filter = {};
7476
+ for (const field of targetFields) {
7477
+ const value = key[field];
7478
+ if (value === void 0 || value === null || value === "") return void 0;
7479
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return void 0;
7480
+ filter[field] = ["==", value];
7481
+ }
7482
+ const [row] = await withFieldViewer(void 0, () => this.dataService.fetchCollectionForRest(path, {
7483
+ filter,
7484
+ limit: 1,
7485
+ databaseId: collection.databaseId
7486
+ }));
7487
+ if (!row || targetFields.some((field) => String(row[field]) !== String(key[field]))) return void 0;
7488
+ const rest = { ...values };
7489
+ for (const field of [...primaryKeys.map((info) => info.fieldName), ...targetFields]) delete rest[field];
7490
+ return {
7491
+ id: deriveRowAddress(row, collection, this.registry),
7492
+ values: rest
7493
+ };
7494
+ }
6612
7495
  async save({ path, id, values, collection, status, upsert, onConflict }) {
6613
7496
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
7497
+ if (status === "existing") assertUpdateDoesNotSoftDelete(resolvedCollection, values, path);
7498
+ const upserting = upsert === true && status !== "existing";
7499
+ if (upserting && resolvedCollection) {
7500
+ const stored = await this.findUpsertTarget(path, resolvedCollection, values, id, onConflict);
7501
+ if (stored) return this.save({
7502
+ path,
7503
+ id: stored.id,
7504
+ values: stored.values,
7505
+ collection,
7506
+ status: "existing"
7507
+ });
7508
+ }
6614
7509
  let updatedValues = values;
6615
7510
  const contextForCallback = this.buildCallContext();
6616
7511
  if ((status === "new" || status === "copy") && resolvedCollection?.properties) updatedValues = applyDefaultValuesOnCreate(updatedValues, resolvedCollection.properties);
6617
7512
  let previousValuesForHistory;
6618
- if (status === "existing" && id) try {
6619
- const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
6620
- if (existing) {
6621
- const { id: _existingId, ...existingValues } = existing;
6622
- previousValuesForHistory = existingValues;
7513
+ if (status === "existing" && id) {
7514
+ const gateOnScope = hasBeforeQuery(this.registry, resolvedCollection);
7515
+ try {
7516
+ const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
7517
+ if (!existing && gateOnScope) throw ApiError.notFound(`No row "${id}" in "${path}" to update.`);
7518
+ if (existing) {
7519
+ const { id: _existingId, ...existingValues } = existing;
7520
+ previousValuesForHistory = existingValues;
7521
+ }
7522
+ } catch (err) {
7523
+ if (err instanceof ApiError) throw err;
7524
+ if (reachedDatabase(err)) throw err;
7525
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
6623
7526
  }
6624
- } catch (err) {
6625
- logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
6626
7527
  }
7528
+ /** The row as the hooks receive it, to tell what they wrote. */
7529
+ const beforeHooks = updatedValues;
6627
7530
  try {
6628
7531
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
6629
7532
  const callbackCollection = requireCallbackCollection(resolvedCollection, path);
@@ -6637,7 +7540,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6637
7540
  status,
6638
7541
  context: contextForCallback
6639
7542
  });
6640
- if (result) updatedValues = mergeDeep(updatedValues, result);
7543
+ if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
6641
7544
  }
6642
7545
  if (callbacks?.beforeSave) {
6643
7546
  const result = await callbacks.beforeSave({
@@ -6649,7 +7552,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6649
7552
  status,
6650
7553
  context: contextForCallback
6651
7554
  });
6652
- if (result) updatedValues = mergeDeep(updatedValues, result);
7555
+ if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
6653
7556
  }
6654
7557
  if (propertyCallbacks?.beforeSave) {
6655
7558
  const result = await propertyCallbacks.beforeSave({
@@ -6661,12 +7564,13 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6661
7564
  status,
6662
7565
  context: contextForCallback
6663
7566
  });
6664
- if (result) updatedValues = mergeDeep(updatedValues, result);
7567
+ if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
6665
7568
  }
6666
7569
  }
6667
7570
  } catch (callbackError) {
6668
7571
  throw toCallbackError(callbackError, "beforeSave", path);
6669
7572
  }
7573
+ const afterHooks = updatedValues;
6670
7574
  if (resolvedCollection?.properties) {
6671
7575
  updatedValues = updateDateAutoValues({
6672
7576
  inputValues: updatedValues,
@@ -6681,12 +7585,29 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6681
7585
  status: status ?? "new",
6682
7586
  uid: this.user?.uid
6683
7587
  });
7588
+ if (status === "existing") {
7589
+ const kept = { ...updatedValues };
7590
+ for (const key of createStampKeys(resolvedCollection.properties)) delete kept[key];
7591
+ updatedValues = kept;
7592
+ }
6684
7593
  }
6685
7594
  updatedValues = await this.applyTenantScope(resolvedCollection, path, updatedValues, status ?? "new", previousValuesForHistory);
7595
+ let writeId = id;
7596
+ if ((status === "new" || status === "copy") && !upsert && isPresentKey(id) && resolvedCollection) {
7597
+ updatedValues = {
7598
+ ...updatedValues,
7599
+ ...parseIdValues$1(id, getPrimaryKeys(resolvedCollection, this.registry))
7600
+ };
7601
+ writeId = void 0;
7602
+ }
7603
+ const insertOnlyKeys = upserting ? insertOnlyKeysOf(updatedValues, values, beforeHooks, afterHooks, resolvedCollection?.properties) : void 0;
7604
+ const softDeleteField = resolveSoftDelete(resolvedCollection)?.field;
7605
+ if (insertOnlyKeys && softDeleteField && updatedValues[softDeleteField] !== void 0 && updatedValues[softDeleteField] !== null) insertOnlyKeys.push(softDeleteField);
6686
7606
  try {
6687
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, {
6688
- upsert,
6689
- onConflict
7607
+ let savedRow = await this.dataService.save(path, updatedValues, writeId, resolvedCollection?.databaseId, {
7608
+ upsert: upserting,
7609
+ onConflict,
7610
+ insertOnlyKeys
6690
7611
  });
6691
7612
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
6692
7613
  const callbackCollection = requireCallbackCollection(resolvedCollection, path);
@@ -6746,9 +7667,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6746
7667
  throw toCallbackError(callbackError, "afterSave", path);
6747
7668
  }
6748
7669
  if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
6749
- tableName: path,
7670
+ tableName: resolvedCollection.slug,
6750
7671
  id: savedId,
6751
- action: status === "new" ? "create" : "update",
7672
+ action: status === "existing" ? "update" : "create",
6752
7673
  values: savedValues,
6753
7674
  previousValues: previousValuesForHistory,
6754
7675
  updatedBy: this.user?.uid
@@ -6764,27 +7685,31 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6764
7685
  } catch (error) {
6765
7686
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
6766
7687
  const callbackCollection = requireCallbackCollection(resolvedCollection, path);
6767
- const errorProps = {
6768
- path,
6769
- id,
6770
- values: updatedValues,
6771
- previousValues: previousValuesForHistory,
6772
- status,
6773
- error,
6774
- context: contextForCallback
7688
+ const runHooks = async (context) => {
7689
+ const errorProps = {
7690
+ collection: callbackCollection,
7691
+ path,
7692
+ id,
7693
+ values: updatedValues,
7694
+ previousValues: previousValuesForHistory,
7695
+ status,
7696
+ error,
7697
+ context
7698
+ };
7699
+ for (const [tier, hook] of [
7700
+ ["global", globalCallbacks?.afterSaveError],
7701
+ ["collection", callbacks?.afterSaveError],
7702
+ ["property", propertyCallbacks?.afterSaveError]
7703
+ ]) {
7704
+ if (!hook) continue;
7705
+ try {
7706
+ await hook(errorProps);
7707
+ } catch (hookError) {
7708
+ logger.error(`[save] The ${tier} afterSaveError hook for "${path}" threw; answering with the save's own failure`, { error: hookError });
7709
+ }
7710
+ }
6775
7711
  };
6776
- if (globalCallbacks?.afterSaveError) await globalCallbacks.afterSaveError({
6777
- collection: callbackCollection,
6778
- ...errorProps
6779
- });
6780
- if (callbacks?.afterSaveError) await callbacks.afterSaveError({
6781
- collection: callbackCollection,
6782
- ...errorProps
6783
- });
6784
- if (propertyCallbacks?.afterSaveError) await propertyCallbacks.afterSaveError({
6785
- collection: callbackCollection,
6786
- ...errorProps
6787
- });
7712
+ if (!currentWriteScope()?.afterSettled(runHooks)) await runHooks(contextForCallback);
6788
7713
  }
6789
7714
  throw error;
6790
7715
  }
@@ -6803,7 +7728,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6803
7728
  */
6804
7729
  bindToTransaction(tx) {
6805
7730
  const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
6806
- txDriver.dataService = new DataService(tx, this.registry);
7731
+ txDriver.dataService = new DataService(tx, this.registry, () => txDriver.buildCallContext());
6807
7732
  txDriver.client = this.client;
6808
7733
  txDriver._deferNotifications = this._deferNotifications;
6809
7734
  txDriver._pendingNotifications = this._pendingNotifications;
@@ -6825,7 +7750,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6825
7750
  */
6826
7751
  callContextWithin(tx, user) {
6827
7752
  const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, user, this.poolManager, this.historyService);
6828
- txDriver.dataService = new DataService(tx, this.registry);
7753
+ txDriver.dataService = new DataService(tx, this.registry, () => txDriver.buildCallContext());
6829
7754
  txDriver.client = this.client;
6830
7755
  return txDriver.buildCallContext();
6831
7756
  }
@@ -6882,7 +7807,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6882
7807
  if (!await txDriver.fetchOne({
6883
7808
  path,
6884
7809
  id: String(id),
6885
- collection
7810
+ collection,
7811
+ withDeleted: restoresSoftDeletedRow(collection ?? this.registry?.getCollectionByPath(path), values) ? true : void 0
6886
7812
  })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
6887
7813
  statusCode: 404,
6888
7814
  code: "NOT_FOUND"
@@ -6992,7 +7918,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6992
7918
  if (!await txDriver.fetchOne({
6993
7919
  path: operation.path,
6994
7920
  id,
6995
- collection: operation.collection
7921
+ collection: operation.collection,
7922
+ withDeleted: restoresSoftDeletedRow(operation.collection ?? this.registry?.getCollectionByPath(operation.path), values) ? true : void 0
6996
7923
  })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
6997
7924
  statusCode: 404,
6998
7925
  code: "NOT_FOUND"
@@ -7077,7 +8004,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7077
8004
  } catch (callbackError) {
7078
8005
  throw toCallbackError(callbackError, "beforeDelete", targetPath);
7079
8006
  }
7080
- const softDelete = hard ? void 0 : resolveSoftDelete(resolvedCollection);
8007
+ const hop = isNestedPath(targetPath) ? resolveNestedPath(targetPath, this.registry) : void 0;
8008
+ const unlinking = hop !== void 0 && isJunctionBackedRelation(hop.relation);
8009
+ const softDelete = hard || unlinking ? void 0 : resolveSoftDelete(resolvedCollection);
7081
8010
  if (softDelete) await this.dataService.save(targetPath, { [softDelete.field]: /* @__PURE__ */ new Date() }, row.id, resolvedCollection?.databaseId);
7082
8011
  else await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
7083
8012
  try {
@@ -7108,8 +8037,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7108
8037
  } catch (callbackError) {
7109
8038
  throw toCallbackError(callbackError, "afterDelete", targetPath);
7110
8039
  }
7111
- if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
7112
- tableName: targetPath,
8040
+ if (this.historyService && resolvedCollection?.history && !unlinking) await this.historyService.recordHistory({
8041
+ tableName: resolvedCollection.slug,
7113
8042
  id: row.id.toString(),
7114
8043
  action: "delete",
7115
8044
  values: stored,
@@ -7164,8 +8093,48 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7164
8093
  return sql.join(chunks, sql.raw(""));
7165
8094
  }
7166
8095
  async executeSql(sqlText, options) {
8096
+ if (options?.isolateSession) return this.onIsolatedSession(this.getTargetDb(options.database), (db) => this.executeSqlOn(db, sqlText, options));
7167
8097
  if (!options?.database && !options?.role) return this.dataService.executeSql(sqlText, options?.params);
7168
- const targetDb = this.getTargetDb(options?.database);
8098
+ return this.executeSqlOn(this.getTargetDb(options?.database), sqlText, options);
8099
+ }
8100
+ /**
8101
+ * Run `fn` on a session of its own, and put that session back the way it
8102
+ * was handed out before anything else can use it.
8103
+ *
8104
+ * For SQL a person wrote — the Studio editor. It may `SET ROLE`,
8105
+ * `SET SESSION AUTHORIZATION` or `set_config(…, false)`, which outlive the
8106
+ * statement (and a transaction's commit) and stay on the connection. On a
8107
+ * pooled one they were inherited by whatever checked it out next: an auth
8108
+ * lookup, the job store or history running as `rebase_user`, on one
8109
+ * connection in N, until a restart. `SET SESSION AUTHORIZATION DEFAULT`
8110
+ * clears the role as well, which `RESET ALL` does not.
8111
+ *
8112
+ * A connection that cannot be reset — the statement left a transaction
8113
+ * open or aborted — is destroyed rather than returned. A handle that is
8114
+ * already one session (PGlite in process, a single client) is reset in
8115
+ * place.
8116
+ */
8117
+ async onIsolatedSession(targetDb, fn) {
8118
+ const pool = "$client" in targetDb ? targetDb.$client : void 0;
8119
+ if (!(pool instanceof Pool)) try {
8120
+ return await fn(targetDb);
8121
+ } finally {
8122
+ for (const statement of RESET_SESSION_STATEMENTS) await targetDb.execute(sql.raw(statement)).catch((error) => logger.error("[PostgresBackendDriver] Could not reset the session after caller SQL", { error }));
8123
+ }
8124
+ const connection = await pool.connect();
8125
+ let reusable = true;
8126
+ try {
8127
+ return await fn(drizzle(connection));
8128
+ } finally {
8129
+ try {
8130
+ for (const statement of RESET_SESSION_STATEMENTS) await connection.query(statement);
8131
+ } catch {
8132
+ reusable = false;
8133
+ }
8134
+ connection.release(reusable ? void 0 : true);
8135
+ }
8136
+ }
8137
+ async executeSqlOn(targetDb, sqlText, options) {
7169
8138
  try {
7170
8139
  let needsRoleSwitch = false;
7171
8140
  if (options?.role) try {
@@ -7345,14 +8314,15 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7345
8314
  AND ccu2.table_name != ${safeName};
7346
8315
  `)).rows,
7347
8316
  policies: (await this.db.execute(sql`
7348
- SELECT
7349
- polname as policy_name,
7350
- polcmd as cmd,
7351
- polroles::regrole[]::text[] as roles,
7352
- pg_get_expr(polqual, polrelid) as qual,
7353
- pg_get_expr(polwithcheck, polrelid) as with_check
7354
- FROM pg_policy
7355
- WHERE polrelid = (SELECT oid FROM pg_class WHERE relname = ${safeName} AND relnamespace = 'public'::regnamespace);
8317
+ SELECT
8318
+ policyname AS policy_name,
8319
+ permissive,
8320
+ cmd,
8321
+ roles::text[] AS roles,
8322
+ qual,
8323
+ with_check
8324
+ FROM pg_policies
8325
+ WHERE schemaname = 'public' AND tablename = ${safeName};
7356
8326
  `)).rows
7357
8327
  };
7358
8328
  }
@@ -7407,11 +8377,22 @@ var AuthenticatedPostgresBackendDriver = class {
7407
8377
  async withTransaction(operation, options) {
7408
8378
  const pendingNotifications = [];
7409
8379
  const writeScope = options?.accessMode === "read only" ? void 0 : new WriteTransactionScope();
8380
+ const client = this.delegate.client;
8381
+ writeScope?.setSettledContext({
8382
+ user: this.user,
8383
+ driver: this,
8384
+ data: this.data,
8385
+ get client() {
8386
+ return requireCallbackClient(client);
8387
+ },
8388
+ storageSource: client?.storage
8389
+ });
7410
8390
  const result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await runInWriteScope(writeScope, () => this.delegate.db.transaction(async (tx) => {
7411
8391
  if (!this.user?.uid) logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
7412
8392
  if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
7413
8393
  await applyAuthContext(tx, authContextOf(this.user), this.delegate.rlsUserRole);
7414
- const txEntityService = new DataService(tx, this.delegate.registry);
8394
+ let hookContext;
8395
+ const txEntityService = new DataService(tx, this.delegate.registry, () => hookContext ??= this.delegate.callContextWithin(tx, this.user));
7415
8396
  const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
7416
8397
  txDelegate.dataService = txEntityService;
7417
8398
  txDelegate._deferNotifications = true;
@@ -7426,9 +8407,14 @@ var AuthenticatedPostgresBackendDriver = class {
7426
8407
  throw error;
7427
8408
  }
7428
8409
  await writeScope?.settle();
8410
+ await writeScope?.assertCommittable();
7429
8411
  return out;
7430
- }, options)).finally(() => writeScope?.close()));
8412
+ }, options)).finally(() => writeScope?.close())).catch(async (error) => {
8413
+ await writeScope?.settled();
8414
+ throw error;
8415
+ });
7431
8416
  writeScope?.committed();
8417
+ await writeScope?.settled();
7432
8418
  for (const notification of pendingNotifications) try {
7433
8419
  await this.delegate.realtimeService.notifyUpdate(notification.path, notification.id, notification.row, notification.databaseId);
7434
8420
  } catch (e) {
@@ -7797,6 +8783,41 @@ function buildJunctionLinkMap(registry) {
7797
8783
  return map;
7798
8784
  }
7799
8785
  //#endregion
8786
+ //#region src/services/read-field-access.ts
8787
+ /**
8788
+ * Refuse a read that names a field its caller may not read, for the doors that
8789
+ * take a driver-shaped request rather than a query string: the socket's
8790
+ * `FETCH_COLLECTION`, `COUNT` and `CHECK_UNIQUE_FIELD`, and `subscribe_collection`.
8791
+ * Callers hand it the request itself, not a re-listed copy of some of its keys.
8792
+ *
8793
+ * The row strip keeps a withheld value off the wire; this is the other half, and
8794
+ * without it the value is still readable one predicate at a time — a `COUNT`
8795
+ * filtered on `passwordHash like '$2b$10$A%'` answers 1 or 0. `GET /api/data`
8796
+ * refuses the same request with `FIELD_NOT_READABLE`, and this is that rule, not
8797
+ * a copy of it: only the request's shape is translated here.
8798
+ *
8799
+ * The shape arrives as whatever JSON the client sent, so it is read defensively.
8800
+ * A malformed `orderBy` or `fields` is not this check's to reject; the entries
8801
+ * that name a field are checked and the rest are left for the driver to refuse.
8802
+ */
8803
+ function assertReadRequestReadable(request, collection, viewer) {
8804
+ const vectorProperty = request.vectorSearch && typeof request.vectorSearch === "object" ? request.vectorSearch.property : void 0;
8805
+ assertQueryFieldsReadable({
8806
+ where: request.filter && typeof request.filter === "object" ? request.filter : void 0,
8807
+ logical: request.logical,
8808
+ orderBy: sortedFields(request.orderBy),
8809
+ fields: Array.isArray(request.fields) ? request.fields : void 0,
8810
+ include: request.include ?? void 0,
8811
+ vectorSearch: typeof vectorProperty === "string" ? { property: vectorProperty } : void 0
8812
+ }, collection, viewer);
8813
+ }
8814
+ /** The fields an `orderBy` sorts on, in either of its two spellings. */
8815
+ function sortedFields(orderBy) {
8816
+ if (typeof orderBy === "string") return [{ field: orderBy }];
8817
+ if (!Array.isArray(orderBy)) return void 0;
8818
+ return orderBy.flatMap((entry) => Array.isArray(entry) && typeof entry[0] === "string" ? [{ field: entry[0] }] : []);
8819
+ }
8820
+ //#endregion
7800
8821
  //#region src/services/cdc/trigger-cdc.ts
7801
8822
  /**
7802
8823
  * Trigger-based Change Data Capture (CDC).
@@ -7829,6 +8850,11 @@ var CDC_TRIGGER_NAME = "rebase_cdc_trigger";
7829
8850
  * statement* if the limit is exceeded. We stay comfortably under it and, for
7830
8851
  * wide rows, fall back to an identity-only payload so CDC can never break a
7831
8852
  * write. 7900 leaves headroom for the JSON envelope keys.
8853
+ *
8854
+ * The identity is every primary-key column, read from the catalogue for the
8855
+ * table that fired — `id` only where the table declares no primary key. It
8856
+ * used to be `id` alone, so a wide row of a table keyed on anything else
8857
+ * arrived with no key at all and reached no single-row subscriber.
7832
8858
  */
7833
8859
  var MAX_NOTIFY_BYTES = 7900;
7834
8860
  var quoteIdent = (name) => `"${name.replace(/"/g, "\"\"")}"`;
@@ -7852,6 +8878,7 @@ LANGUAGE plpgsql AS $rebase_cdc$
7852
8878
  DECLARE
7853
8879
  rec jsonb;
7854
8880
  payload text;
8881
+ ident jsonb;
7855
8882
  BEGIN
7856
8883
  IF (TG_OP = 'DELETE') THEN
7857
8884
  rec := to_jsonb(OLD);
@@ -7870,11 +8897,18 @@ BEGIN
7870
8897
  -- 8000-byte cap, emit an identity-only payload the consumer can still route
7871
8898
  -- (and refetch the authoritative row from).
7872
8899
  IF (octet_length(payload) > ${MAX_NOTIFY_BYTES}) THEN
8900
+ SELECT jsonb_object_agg(a.attname, rec->a.attname) INTO ident
8901
+ FROM pg_index i
8902
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
8903
+ WHERE i.indrelid = TG_RELID AND i.indisprimary;
8904
+ IF ident IS NULL THEN
8905
+ ident := CASE WHEN rec ? 'id' THEN jsonb_build_object('id', rec->'id') ELSE '{}'::jsonb END;
8906
+ END IF;
7873
8907
  payload := json_build_object(
7874
8908
  'schema', TG_TABLE_SCHEMA,
7875
8909
  'table', TG_TABLE_NAME,
7876
8910
  'op', TG_OP,
7877
- 'row', CASE WHEN rec ? 'id' THEN jsonb_build_object('id', rec->'id') ELSE '{}'::jsonb END,
8911
+ 'row', ident,
7878
8912
  'truncated', true
7879
8913
  )::text;
7880
8914
  END IF;
@@ -8023,6 +9057,15 @@ var PgNotifyListener = class {
8023
9057
  if (initial) throw err;
8024
9058
  logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
8025
9059
  this.scheduleReconnect();
9060
+ return;
9061
+ }
9062
+ if (!initial && this.options.onReconnect) {
9063
+ logger.warn(`⚠️ ${logLabel} LISTEN client reconnected; anything published while it was down was missed.`);
9064
+ try {
9065
+ this.options.onReconnect();
9066
+ } catch (err) {
9067
+ logger.error(`❌ ${logLabel} Error resyncing after a reconnect`, { error: err });
9068
+ }
8026
9069
  }
8027
9070
  }
8028
9071
  scheduleReconnect() {
@@ -8079,11 +9122,17 @@ function parseCdcPayload(payload) {
8079
9122
  */
8080
9123
  var CdcListener = class {
8081
9124
  listener;
8082
- constructor(connectionString, onEvent) {
9125
+ /**
9126
+ * @param onReconnect Called when the connection is listening again after a
9127
+ * drop. Every change committed in the gap was notified to nobody, so
9128
+ * this is where subscribers are told to look again.
9129
+ */
9130
+ constructor(connectionString, onEvent, onReconnect) {
8083
9131
  this.listener = new PgNotifyListener({
8084
9132
  connectionString,
8085
9133
  channel: CDC_CHANNEL,
8086
9134
  logLabel: "[CDC]",
9135
+ onReconnect,
8087
9136
  onPayload: (payload) => {
8088
9137
  const event = parseCdcPayload(payload);
8089
9138
  if (!event) {
@@ -8921,6 +9970,23 @@ function callbackContextWithin(driver, tx, auth) {
8921
9970
  });
8922
9971
  }
8923
9972
  /**
9973
+ * How long a client gets to answer the close frame at shutdown before its
9974
+ * socket is dropped. `ws` would otherwise wait its own 30s close timeout, which
9975
+ * is longer than the whole shutdown budget.
9976
+ */
9977
+ var WEBSOCKET_CLOSE_GRACE_MS = 1e3;
9978
+ /**
9979
+ * Close each socket with 1001, and terminate the ones still open after
9980
+ * `graceMs`. Nothing here waits: the HTTP server's close() is what waits for
9981
+ * the sockets to end.
9982
+ */
9983
+ function closeWebSockets(sockets, graceMs) {
9984
+ for (const ws of sockets) {
9985
+ if (ws.readyState === WebSocket.OPEN) ws.close(1001, "server shutting down");
9986
+ if (ws.readyState !== WebSocket.CLOSED) unref(setTimeout(() => ws.terminate(), graceMs));
9987
+ }
9988
+ }
9989
+ /**
8924
9990
  * PostgreSQL-specific realtime service.
8925
9991
  * Handles WebSocket connections and subscriptions for real-time row updates.
8926
9992
  *
@@ -9037,8 +10103,20 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9037
10103
  * and always flow through. Keyed → expiry timestamp (ms).
9038
10104
  */
9039
10105
  recentAppEmits = /* @__PURE__ */ new Map();
9040
- /** How long an app-emit key suppresses its own CDC echo. Covers NOTIFY round-trip latency. */
9041
- static CDC_DEDUP_WINDOW_MS = 5e3;
10106
+ /**
10107
+ * How long an app-emit key suppresses its own CDC echo: the refetch
10108
+ * debounce, and no longer.
10109
+ *
10110
+ * The mark cannot tell an echo from another writer's change to the same
10111
+ * row, and a save that touches no row of the table (a to-many relation
10112
+ * only, an empty payload) is announced with no echo ever coming to consume
10113
+ * it. Held for seconds, it swallowed the next change psql, a cron or
10114
+ * another instance made to that row. Inside the debounce nothing is lost:
10115
+ * the refetch the app emit scheduled has not started yet — its timer fires
10116
+ * no earlier than this — and it reads after any commit whose NOTIFY has
10117
+ * already arrived. An echo later than that costs one more refetch.
10118
+ */
10119
+ static CDC_DEDUP_WINDOW_MS = RealtimeService.REFETCH_DEBOUNCE_MS;
9042
10120
  constructor(db, registry) {
9043
10121
  super();
9044
10122
  this.db = db;
@@ -9263,7 +10341,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9263
10341
  const subscriptionId = request.subscriptionId;
9264
10342
  let canDeliver;
9265
10343
  try {
9266
- if (!this.registry.getCollectionByPath(request.path)) {
10344
+ const collection = this.registry.getCollectionByPath(request.path);
10345
+ if (!collection) {
9267
10346
  const registered = this.registry.getCollections().map((c) => c.slug).join(", ");
9268
10347
  const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
9269
10348
  logger.error(`[RealtimeService] ${msg}`);
@@ -9294,6 +10373,17 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9294
10373
  this.sendError(clientId, e.message, subscriptionId, e.code);
9295
10374
  return;
9296
10375
  }
10376
+ try {
10377
+ assertReadRequestReadable({
10378
+ ...request,
10379
+ orderBy
10380
+ }, collection, { roles: authContext?.roles ?? ["anon"] });
10381
+ } catch (e) {
10382
+ if (!(e instanceof ApiError)) throw e;
10383
+ logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
10384
+ this.sendError(clientId, e.message, subscriptionId, e.code);
10385
+ return;
10386
+ }
9297
10387
  const subscription = {
9298
10388
  clientId,
9299
10389
  type: "collection",
@@ -9372,6 +10462,76 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9372
10462
  if (canDeliver && !canDeliver.mayReportFailure()) return;
9373
10463
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
9374
10464
  }
10465
+ /**
10466
+ * Re-judge everything a socket holds open as the identity it has now.
10467
+ *
10468
+ * A socket re-authenticates on every session refresh, and when a role is
10469
+ * taken away, a tenant claim removed or a different account signs in, the
10470
+ * next token says so. Requests read as the new identity at once; the
10471
+ * subscriptions kept refetching as the one they were opened with, so a
10472
+ * demoted user went on receiving editor-only or another tenant's rows for
10473
+ * as long as the view stayed mounted.
10474
+ *
10475
+ * Each of this client's subscriptions is replaced by one carrying the new
10476
+ * identity — a replacement, so a refetch already running as the old one
10477
+ * cannot deliver — and refetched, so the view narrows now rather than at
10478
+ * the next write. A subscription whose filter, sort or projection names a
10479
+ * field the new roles may not read is ended with the refusal a new
10480
+ * subscription would get. Channel memberships are put to the authorizer
10481
+ * again, when one is installed, and left when it refuses.
10482
+ */
10483
+ async rescopeClient(clientId, authContext) {
10484
+ for (const [subscriptionId, subscription] of [...this._subscriptions.entries()]) {
10485
+ if (subscription.clientId !== clientId) continue;
10486
+ if (subscription.type === "collection" && subscription.collectionRequest) {
10487
+ const collection = this.registry.getCollectionByPath(subscription.path);
10488
+ const request = subscription.collectionRequest;
10489
+ try {
10490
+ if (collection) assertReadRequestReadable({
10491
+ filter: request.filter,
10492
+ logical: request.logical,
10493
+ orderBy: request.orderBy,
10494
+ fields: request.fields
10495
+ }, collection, { roles: authContext.roles ?? ["anon"] });
10496
+ } catch (e) {
10497
+ if (!(e instanceof ApiError)) throw e;
10498
+ await this.handleUnsubscribe(clientId, subscriptionId);
10499
+ this.sendError(clientId, e.message, subscriptionId, e.code);
10500
+ continue;
10501
+ }
10502
+ }
10503
+ const rescoped = {
10504
+ ...subscription,
10505
+ authContext,
10506
+ started: 0,
10507
+ delivered: 0
10508
+ };
10509
+ this._subscriptions.set(subscriptionId, rescoped);
10510
+ if (rescoped.type === "single" && rescoped.id !== void 0) this.debouncedSingleRefetch(subscriptionId, rescoped.path, String(rescoped.id), rescoped);
10511
+ else if (rescoped.type === "collection" && rescoped.collectionRequest) this.debouncedCollectionRefetch(subscriptionId, rescoped.path, rescoped);
10512
+ }
10513
+ const authorizer = this.channelAuthorizer;
10514
+ if (!authorizer) return;
10515
+ for (const [channel, members] of [...this.channels.entries()]) {
10516
+ if (!members.has(clientId)) continue;
10517
+ let allowed;
10518
+ try {
10519
+ allowed = await authorizer({
10520
+ channel,
10521
+ action: "join",
10522
+ clientId,
10523
+ user: authContext
10524
+ });
10525
+ } catch (error) {
10526
+ logger.error(`❌ [Channels] Authorizer threw re-checking "${channel}" after a sign-in — leaving it`, { error });
10527
+ allowed = false;
10528
+ }
10529
+ if (!allowed) {
10530
+ this.leaveChannel(clientId, channel);
10531
+ this.denyChannelAction(clientId, channel, "join", "refused by the channel authorizer for the identity this socket signed in as");
10532
+ }
10533
+ }
10534
+ }
9375
10535
  async handleUnsubscribe(_clientId, subscriptionId) {
9376
10536
  this._subscriptions.delete(subscriptionId);
9377
10537
  this.subscriptionCallbacks.delete(subscriptionId);
@@ -9402,6 +10562,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9402
10562
  */
9403
10563
  async notifyUpdate(path, id, row, databaseId, broadcast = true, origin = "app") {
9404
10564
  this.debugLog("🔔 [RealtimeService] notifyUpdate called for path:", path, "id:", id, "isDelete:", row === null, "origin:", origin);
10565
+ const aliases = this.aliasPaths(path);
9405
10566
  if (this.cdcActive) {
9406
10567
  const key = this.dedupKey(path, id, databaseId);
9407
10568
  if (origin === "cdc") {
@@ -9409,7 +10570,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9409
10570
  this.debugLog("🔁 [RealtimeService] Suppressing CDC echo of local app mutation:", key);
9410
10571
  return;
9411
10572
  }
9412
- } else this.markAppEmit(key);
10573
+ } else {
10574
+ this.markAppEmit(key);
10575
+ if (isNestedPath(path)) {
10576
+ const root = aliases.find((alias) => !isNestedPath(alias));
10577
+ if (root) this.markAppEmit(this.dedupKey(root, id, databaseId));
10578
+ }
10579
+ }
9413
10580
  }
9414
10581
  const pathsToNotify = [path];
9415
10582
  if (path.includes("/") && path.split("/").length > 1) {
@@ -9418,6 +10585,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9418
10585
  this.debugLog(`🔗 [RealtimeService] Nested path detected. Will notify paths: ${pathsToNotify.join(", ")}`);
9419
10586
  }
9420
10587
  for (const notifyPath of pathsToNotify) await this.notifyPathUpdate(notifyPath, path, id, row, databaseId);
10588
+ for (const alias of aliases) await this.notifyPathUpdate(alias, alias, id, row, databaseId);
9421
10589
  if (broadcast && this.broadcasting && !this.cdcActive) try {
9422
10590
  await this.broadcastChange(path, id, databaseId);
9423
10591
  } catch (err) {
@@ -9566,7 +10734,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9566
10734
  isAnonymous: activeAuth.isAnonymous === true,
9567
10735
  claims: activeAuth.claims
9568
10736
  }, this.rlsUserRole);
9569
- const fetchedEntities = await new DataService(tx, this.registry).fetchCollectionForRest(notifyPath, {
10737
+ let hookContext;
10738
+ const fetchedEntities = await new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)).fetchCollectionForRest(notifyPath, {
9570
10739
  filter: collectionRequest.filter,
9571
10740
  logical: collectionRequest.logical,
9572
10741
  orderBy: collectionRequest.orderBy,
@@ -9615,7 +10784,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9615
10784
  }));
9616
10785
  }
9617
10786
  return fetchedEntities;
9618
- }));
10787
+ }, { accessMode: "read only" }));
9619
10788
  }
9620
10789
  return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchCollectionForRest(notifyPath, {
9621
10790
  filter: collectionRequest.filter,
@@ -9665,8 +10834,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9665
10834
  isAnonymous: activeAuth.isAnonymous === true,
9666
10835
  claims: activeAuth.claims
9667
10836
  }, this.rlsUserRole);
9668
- return countOnce(new DataService(tx, this.registry));
9669
- });
10837
+ let hookContext;
10838
+ return countOnce(new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)));
10839
+ }, { accessMode: "read only" });
9670
10840
  } else total = await countOnce(this.dataService);
9671
10841
  } catch (error) {
9672
10842
  logger.warn(`[RealtimeService] Could not count '${notifyPath}' for a subscription frame`, { error });
@@ -9744,7 +10914,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9744
10914
  isAnonymous: activeAuth.isAnonymous === true,
9745
10915
  claims: activeAuth.claims
9746
10916
  }, this.rlsUserRole);
9747
- let processedEntity = await new DataService(tx, this.registry).fetchOneForRest(notifyPath, id, void 0, collection?.databaseId) ?? void 0;
10917
+ let hookContext;
10918
+ let processedEntity = await new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)).fetchOneForRest(notifyPath, id, void 0, collection?.databaseId) ?? void 0;
9748
10919
  if (processedEntity) {
9749
10920
  const registryCollection = this.registry.getCollectionByPath(notifyPath);
9750
10921
  const resolvedCollection = collection ? {
@@ -9778,7 +10949,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9778
10949
  }
9779
10950
  }
9780
10951
  return processedEntity;
9781
- }));
10952
+ }, { accessMode: "read only" }));
9782
10953
  }
9783
10954
  return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchOneForRest(notifyPath, id) ?? void 0);
9784
10955
  }
@@ -9857,6 +11028,39 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9857
11028
  * Extract parent paths from a nested path like "posts/70/tags"
9858
11029
  * Returns ["posts", "posts/70"] for the example above
9859
11030
  */
11031
+ /**
11032
+ * The other paths a subscriber can address the row written at `path` by.
11033
+ *
11034
+ * `authors/1/posts` and `posts` are two addresses for the same rows, and
11035
+ * subscriptions were matched by the written path's exact string. So a post
11036
+ * saved through `authors/1/posts` never reached a subscriber of `posts` or
11037
+ * of `posts/43`, and one saved through `posts` never reached a subscriber
11038
+ * of `authors/1/posts`, whose list it may just have joined or left.
11039
+ *
11040
+ * The aliases are the target collection's root path, and every nested path
11041
+ * a live subscription holds that lands on the same collection. Each still
11042
+ * refetches under its own scope and its own parent, so naming a path here
11043
+ * decides only who is asked to look again — never what they are shown.
11044
+ */
11045
+ aliasPaths(path) {
11046
+ const slug = this.collectionSlugAt(path);
11047
+ if (!slug) return [];
11048
+ const aliases = /* @__PURE__ */ new Set();
11049
+ if (slug !== path) aliases.add(slug);
11050
+ for (const subscription of this._subscriptions.values()) {
11051
+ if (subscription.path === path || aliases.has(subscription.path) || !isNestedPath(subscription.path)) continue;
11052
+ if (this.collectionSlugAt(subscription.path) === slug) aliases.add(subscription.path);
11053
+ }
11054
+ return [...aliases];
11055
+ }
11056
+ /** The slug of the collection a path lands on, or `undefined` for one that names none. */
11057
+ collectionSlugAt(path) {
11058
+ try {
11059
+ return this.registry.getCollectionByPath(path)?.slug;
11060
+ } catch {
11061
+ return;
11062
+ }
11063
+ }
9860
11064
  getParentPaths(path) {
9861
11065
  const segments = path.split("/").filter((s) => s.length > 0);
9862
11066
  const parentPaths = [];
@@ -10429,8 +11633,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10429
11633
  * 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
10430
11634
  * 2. All subscription state and callbacks are cleared.
10431
11635
  * 3. The dedicated LISTEN client (outside the pool) is disconnected.
10432
- * 4. All WebSocket clients are removed (but not forcefully closed — the
10433
- * HTTP server close will handle that).
11636
+ * 4. All WebSocket clients are closed with 1001 ("going away"); one that
11637
+ * does not answer the close frame is dropped after a short grace.
10434
11638
  */
10435
11639
  async destroy() {
10436
11640
  for (const [key, timer] of this.refetchTimers) {
@@ -10465,7 +11669,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10465
11669
  await this.stopCdc();
10466
11670
  await this.bus.stop().catch((error) => logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
10467
11671
  this.bus = new MemoryChannelBus();
11672
+ const sockets = [...this.clients.values()];
10468
11673
  this.clients.clear();
11674
+ closeWebSockets(sockets, WEBSOCKET_CLOSE_GRACE_MS);
10469
11675
  this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
10470
11676
  }
10471
11677
  /** Whether database-level change capture is currently the active source. */
@@ -10496,7 +11702,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10496
11702
  }
10497
11703
  this.cdcTableMap = this.buildCdcTableMap();
10498
11704
  this.junctionLinkMap = buildJunctionLinkMap(this.registry);
10499
- this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
11705
+ this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event), () => this.resyncSubscriptions());
10500
11706
  try {
10501
11707
  await this.cdcListener.start();
10502
11708
  } catch (err) {
@@ -10509,6 +11715,32 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10509
11715
  this.cdcActive = true;
10510
11716
  logger.debug(`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events (${this.cdcTableMap.size} mapped table key(s)).`);
10511
11717
  }
11718
+ /**
11719
+ * Refetch every live subscription, as a change to its path would.
11720
+ *
11721
+ * Called when a LISTEN connection (CDC, or the cross-instance broadcast)
11722
+ * is listening again after a drop. Postgres does not queue NOTIFY for a
11723
+ * session that is not listening, so every change committed while it was
11724
+ * down — by another instance, psql, a cron — reached nobody here, and a
11725
+ * subscriber held its pre-gap rows until some later change to the same
11726
+ * collection happened to arrive. Each refetch runs under the
11727
+ * subscription's own scope, so this decides only who looks again.
11728
+ */
11729
+ resyncSubscriptions() {
11730
+ for (const [subscriptionId, subscription] of this._subscriptions.entries()) try {
11731
+ if (subscription.clientId === "driver") {
11732
+ const callback = this.subscriptionCallbacks.get(subscriptionId);
11733
+ if (!callback) continue;
11734
+ if (subscription.type === "single" && subscription.id !== void 0) this.debouncedSingleDriverRefetch(subscriptionId, subscription.path, String(subscription.id), subscription, callback);
11735
+ else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedDriverRefetch(subscriptionId, subscription.path, subscription, callback);
11736
+ } else if (this.clients.has(subscription.clientId)) {
11737
+ if (subscription.type === "single" && subscription.id !== void 0) this.debouncedSingleRefetch(subscriptionId, subscription.path, String(subscription.id), subscription);
11738
+ else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedCollectionRefetch(subscriptionId, subscription.path, subscription);
11739
+ }
11740
+ } catch (error) {
11741
+ logger.error(`❌ [RealtimeService] Could not resync subscription ${subscriptionId}`, { error });
11742
+ }
11743
+ }
10512
11744
  /** Stop the CDC listener and clear its state. */
10513
11745
  async stopCdc() {
10514
11746
  this.cdcActive = false;
@@ -10598,7 +11830,29 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10598
11830
  }
10599
11831
  /** Compute the canonical (possibly composite) id string from a captured row. */
10600
11832
  extractIdFromCdcRow(collection, row) {
10601
- return deriveRowAddress(row, collection, this.registry) || "*";
11833
+ return deriveRowAddress(this.cdcRowByKeyFields(collection, row), collection, this.registry) || "*";
11834
+ }
11835
+ /**
11836
+ * A captured row with its key under the key's *field* names.
11837
+ *
11838
+ * The trigger captures the tuple by column — `user_id` — and an address is
11839
+ * built from the key's property names — `userId`. Read by property name
11840
+ * alone, a key declared apart from its column was never on the captured
11841
+ * row, the address fell back to `*`, and no single-row subscriber heard
11842
+ * about any write made outside this server.
11843
+ */
11844
+ cdcRowByKeyFields(collection, row) {
11845
+ const table = this.registry.getTable(getTableName$1(collection));
11846
+ const columns = table ? getTableColumns(table) : void 0;
11847
+ const keyed = { ...row };
11848
+ for (const pk of getPrimaryKeys(collection, this.registry)) {
11849
+ if (pk.fieldName in row) continue;
11850
+ const property = collection.properties?.[pk.fieldName];
11851
+ const declared = property && "columnName" in property ? property.columnName : void 0;
11852
+ const columnName = columns?.[pk.fieldName]?.name ?? declared ?? toSnakeCase(pk.fieldName);
11853
+ if (columnName in row) keyed[pk.fieldName] = row[columnName];
11854
+ }
11855
+ return keyed;
10602
11856
  }
10603
11857
  dedupKey(path, id, databaseId) {
10604
11858
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -10670,8 +11924,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10670
11924
  }
10671
11925
  /**
10672
11926
  * Create and connect the dedicated LISTEN client with auto-reconnect.
11927
+ *
11928
+ * @param reconnect Set when the client is coming back from a drop: every
11929
+ * notification published in the gap is gone, so the subscriptions
11930
+ * are refetched once it is listening again.
10673
11931
  */
10674
- async connectListenClient() {
11932
+ async connectListenClient({ reconnect = false } = {}) {
10675
11933
  if (!this.listenConnectionString) return;
10676
11934
  let pending;
10677
11935
  try {
@@ -10717,6 +11975,10 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10717
11975
  this.listenClient = client;
10718
11976
  pending = void 0;
10719
11977
  this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
11978
+ if (reconnect) {
11979
+ logger.warn("⚠️ [RealtimeService] LISTEN client reconnected; refetching every subscription for what it missed.");
11980
+ this.resyncSubscriptions();
11981
+ }
10720
11982
  } catch (err) {
10721
11983
  if (pending) try {
10722
11984
  await pending.end();
@@ -10741,7 +12003,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10741
12003
  } catch {}
10742
12004
  this.listenClient = void 0;
10743
12005
  }
10744
- await this.connectListenClient();
12006
+ await this.connectListenClient({ reconnect: true });
10745
12007
  }, delay);
10746
12008
  }
10747
12009
  };
@@ -10776,6 +12038,17 @@ var WS_RATE_WINDOW_MS = 6e4;
10776
12038
  * a considered product limit (see `docs/channel-authorization.md`).
10777
12039
  */
10778
12040
  var WS_CHANNEL_RATE_LIMIT = 7200;
12041
+ /**
12042
+ * The largest frame a socket that has not authenticated may send, in bytes.
12043
+ *
12044
+ * All such a socket can usefully say is `AUTHENTICATE` with a token — a few
12045
+ * KiB with custom claims — and everything else is answered `UNAUTHORIZED`.
12046
+ * Without this, each of those answers came after the whole frame was buffered,
12047
+ * stringified and parsed, up to the body limit: a handful of connections that
12048
+ * never signed in could hold the process's memory. A larger frame closes the
12049
+ * socket with 1009 before it is read.
12050
+ */
12051
+ var MAX_UNAUTHENTICATED_FRAME_BYTES = 64 * 1024;
10779
12052
  /** Frames counted against the channel budget rather than the general one. */
10780
12053
  var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
10781
12054
  "join_channel",
@@ -10877,7 +12150,33 @@ function isAdminSession(session) {
10877
12150
  if (!session.user.roles) return false;
10878
12151
  return session.user.roles.some((r) => r === "admin");
10879
12152
  }
10880
- function createPostgresWebSocket(server, realtimeService, driver, authConfig, authAdapter) {
12153
+ /** A frame's size in bytes, however `ws` delivered it. */
12154
+ function frameBytes(data) {
12155
+ if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0);
12156
+ return data.byteLength;
12157
+ }
12158
+ /**
12159
+ * Where a connection comes from, as the data API's rate limiter reads an
12160
+ * address: the upgrade request's headers (believed only as far as proxies are
12161
+ * declared) and the socket's own address.
12162
+ */
12163
+ function connectionOrigin(request) {
12164
+ return {
12165
+ header: (name) => {
12166
+ const value = request?.headers[name];
12167
+ return Array.isArray(value) ? value.join(", ") : value;
12168
+ },
12169
+ socketAddress: request?.socket?.remoteAddress
12170
+ };
12171
+ }
12172
+ /**
12173
+ * `limits` are the data API's, for the same rows reached through this door:
12174
+ * frames larger than its body limit close the socket with 1009 before they are
12175
+ * read, and every data frame counts in the caller's data-API bucket — the one
12176
+ * their HTTP requests count in — rather than only in a budget per connection,
12177
+ * which a caller multiplied by opening more.
12178
+ */
12179
+ function createPostgresWebSocket(server, realtimeService, driver, authConfig, authAdapter, limits) {
10881
12180
  const clientSessions = /* @__PURE__ */ new Map();
10882
12181
  const isProduction = process.env.NODE_ENV === "production";
10883
12182
  /**
@@ -10900,15 +12199,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10900
12199
  if (extras.length === 0) logger.debug(message);
10901
12200
  else logger.debug(message, { details: extras.length === 1 ? extras[0] : extras });
10902
12201
  };
10903
- const wss = new WebSocketServer({ server });
12202
+ const maxPayload = limits?.maxPayload ?? RUNTIME_DEFAULT_MAX_BODY_SIZE;
12203
+ const wss = new WebSocketServer({
12204
+ server,
12205
+ maxPayload: maxPayload > 0 ? maxPayload : 0
12206
+ });
10904
12207
  wss.on("error", (err) => {
10905
12208
  if (err.code === "EADDRINUSE") return;
10906
12209
  logger.error("❌ [WebSocket Server] Error", { error: err });
10907
12210
  });
10908
12211
  const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);
10909
12212
  if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) logger.warn("🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or serviceKey is configured — no client can complete AUTH, so every realtime message will be refused with UNAUTHORIZED.");
10910
- wss.on("connection", (ws) => {
12213
+ wss.on("connection", (ws, request) => {
10911
12214
  const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
12215
+ const origin = connectionOrigin(request);
10912
12216
  wsDebug(`WebSocket client connected: ${clientId}`);
10913
12217
  clientSessions.set(clientId, {
10914
12218
  ws,
@@ -10923,9 +12227,16 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10923
12227
  wsDebug(`WebSocket client disconnected: ${clientId}`);
10924
12228
  clientSessions.delete(clientId);
10925
12229
  });
12230
+ ws.on("error", (error) => {
12231
+ wsDebug(`WebSocket client ${clientId} error: ${error.message}`);
12232
+ });
10926
12233
  ws.on("message", async (message) => {
10927
12234
  let requestId;
10928
12235
  try {
12236
+ if (!clientSessions.get(clientId)?.authenticated && frameBytes(message) > MAX_UNAUTHENTICATED_FRAME_BYTES) {
12237
+ ws.close(1009, "Authenticate before sending anything larger than a token.");
12238
+ return;
12239
+ }
10929
12240
  const { type, payload, requestId: reqId } = JSON.parse(message.toString());
10930
12241
  requestId = reqId;
10931
12242
  wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
@@ -10944,6 +12255,33 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10944
12255
  }
10945
12256
  }));
10946
12257
  };
12258
+ {
12259
+ const session = clientSessions.get(clientId);
12260
+ if (session) {
12261
+ const now = Date.now();
12262
+ if (CHANNEL_MESSAGE_TYPES.has(type)) {
12263
+ if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
12264
+ session.channelMessageCount = 0;
12265
+ session.channelWindowStart = now;
12266
+ }
12267
+ session.channelMessageCount++;
12268
+ if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
12269
+ sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
12270
+ return;
12271
+ }
12272
+ } else {
12273
+ if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
12274
+ session.messageCount = 0;
12275
+ session.messageWindowStart = now;
12276
+ }
12277
+ session.messageCount++;
12278
+ if (session.messageCount > WS_RATE_LIMIT) {
12279
+ sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
12280
+ return;
12281
+ }
12282
+ }
12283
+ }
12284
+ }
10947
12285
  if (type === "AUTHENTICATE") {
10948
12286
  const { token } = payload || {};
10949
12287
  if (!token) {
@@ -10957,7 +12295,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10957
12295
  uid: adapterUser.uid,
10958
12296
  roles: adapterUser.roles,
10959
12297
  isAdmin: adapterUser.isAdmin,
10960
- isAnonymous: adapterUser.isAnonymous === true
12298
+ isAnonymous: adapterUser.isAnonymous === true,
12299
+ ...adapterUser.claims ? { claims: adapterUser.claims } : {}
10961
12300
  };
10962
12301
  } catch {}
10963
12302
  else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
@@ -10981,6 +12320,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10981
12320
  if (session) {
10982
12321
  session.user = verifiedUser;
10983
12322
  session.authenticated = true;
12323
+ await realtimeService.rescopeClient(clientId, sessionAuthContext(session));
10984
12324
  }
10985
12325
  wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
10986
12326
  ws.send(JSON.stringify({
@@ -11004,39 +12344,22 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11004
12344
  return;
11005
12345
  }
11006
12346
  }
11007
- {
11008
- const session = clientSessions.get(clientId);
11009
- if (session) {
11010
- const now = Date.now();
11011
- if (CHANNEL_MESSAGE_TYPES.has(type)) {
11012
- if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
11013
- session.channelMessageCount = 0;
11014
- session.channelWindowStart = now;
11015
- }
11016
- session.channelMessageCount++;
11017
- if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
11018
- sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
11019
- return;
11020
- }
11021
- } else {
11022
- if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
11023
- session.messageCount = 0;
11024
- session.messageWindowStart = now;
11025
- }
11026
- session.messageCount++;
11027
- if (session.messageCount > WS_RATE_LIMIT) {
11028
- sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
11029
- return;
11030
- }
11031
- }
11032
- }
11033
- }
11034
12347
  if (ADMIN_ONLY_TYPES.has(type)) {
11035
12348
  if (!isAdminSession(clientSessions.get(clientId))) {
11036
12349
  sendError("ERROR", "FORBIDDEN", "Admin access required for this operation");
11037
12350
  return;
11038
12351
  }
11039
12352
  }
12353
+ if (limits?.dataRateLimit && PUBLIC_TYPES.has(type)) {
12354
+ const decision = await limits.dataRateLimit({
12355
+ ...origin,
12356
+ uid: clientSessions.get(clientId)?.user?.uid
12357
+ });
12358
+ if (decision && !decision.allowed) {
12359
+ sendError("ERROR", "RATE_LIMITED", "Too many requests, please try again later.");
12360
+ return;
12361
+ }
12362
+ }
11040
12363
  /**
11041
12364
  * Apply the REST layer's write checks to a socket payload.
11042
12365
  *
@@ -11044,13 +12367,98 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11044
12367
  * driver decides what a path means, and refusing here would
11045
12368
  * turn "unknown collection" into a validation error.
11046
12369
  */
11047
- const assertWriteRequest = (path, values) => {
12370
+ const assertWriteRequest = (path, values, status) => {
11048
12371
  if (!path || !values || typeof values !== "object") return;
11049
12372
  const collection = driver.registry?.getCollectionByPath(path);
11050
12373
  if (!collection) return;
11051
- assertWriteRequestValid(values, collection, { viewer: { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] } });
12374
+ assertWriteRequestValid(values, collection, {
12375
+ viewer: { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] },
12376
+ status
12377
+ });
11052
12378
  assertFieldOpsValid(values, collection);
11053
12379
  };
12380
+ /**
12381
+ * Apply the REST layer's read check to a socket request: no
12382
+ * filter, sort, projection or uniqueness probe over a field
12383
+ * this caller may not read. The strip keeps the value off the
12384
+ * wire; without this a `COUNT` still answers whether a hidden
12385
+ * value matches a pattern, one character at a time.
12386
+ *
12387
+ * Same viewer as `assertWriteRequest`, and silent in the same
12388
+ * case: a path that names no registered collection is the
12389
+ * driver's to answer.
12390
+ */
12391
+ const assertReadRequest = (path, request) => {
12392
+ if (!path) return;
12393
+ let collection;
12394
+ try {
12395
+ collection = driver.registry?.getCollectionByPath(path);
12396
+ } catch {
12397
+ return;
12398
+ }
12399
+ if (!collection) return;
12400
+ const session = clientSessions.get(clientId);
12401
+ assertReadRequestReadable(request, collection, { roles: session?.user?.roles ?? ["anon"] });
12402
+ };
12403
+ /**
12404
+ * Refuse a write through a nested path that sets a relation
12405
+ * this caller may not write — the rule REST's nested routes
12406
+ * apply, from the same function. The body is checked by
12407
+ * `assertWriteRequest`; this is what the address writes.
12408
+ *
12409
+ * Silent for a root path, and for a nested one the registry
12410
+ * cannot walk: the driver refuses that with its own message.
12411
+ */
12412
+ const assertNestedWrite = (path, kind) => {
12413
+ if (typeof path !== "string" || !isNestedPath(path) || !driver.registry) return;
12414
+ let hop;
12415
+ try {
12416
+ hop = resolveNestedPath(path, driver.registry);
12417
+ } catch {
12418
+ return;
12419
+ }
12420
+ if (!hop) return;
12421
+ const session = clientSessions.get(clientId);
12422
+ assertNestedWriteAllowed(hop, kind, path, { roles: session?.user?.roles ?? ["anon"] });
12423
+ };
12424
+ /** The auth collection at `path`, or `undefined` for any other. */
12425
+ const authCollectionAt = (path) => {
12426
+ if (typeof path !== "string") return void 0;
12427
+ let collection;
12428
+ try {
12429
+ collection = driver.registry?.getCollectionByPath(path);
12430
+ } catch {
12431
+ return;
12432
+ }
12433
+ const auth = collection?.auth;
12434
+ return auth === true || !!auth && typeof auth === "object" && auth.enabled === true ? collection : void 0;
12435
+ };
12436
+ /**
12437
+ * The auth collection's rows are the users, so a write to one
12438
+ * is user administration — the auth adapter's to check, as it
12439
+ * is on the REST routes and `/admin/users`. `undefined` for any
12440
+ * other collection, or an adapter with nothing to say.
12441
+ */
12442
+ const userAdminFor = (path) => authAdapter && authCollectionAt(path) ? authAdapter : void 0;
12443
+ /**
12444
+ * An upsert's options, under the rules `POST /api/data`
12445
+ * applies to `?on_conflict=`: refused on a nested path,
12446
+ * whose insert branch stamps the parent's key into the
12447
+ * DO UPDATE and so moves the row it matched under this
12448
+ * parent; and a target that is a declared unique key of
12449
+ * the registry's collection, or a 400 naming the ones
12450
+ * there are rather than Postgres's 42P10.
12451
+ */
12452
+ const upsertOptions = (request) => {
12453
+ if (request.upsert !== true) return {};
12454
+ if (typeof request.path === "string" && isNestedPath(request.path)) throw ApiError.badRequest("An upsert is not accepted on a nested path: a row it matched would be moved under this parent. Send the upsert to the collection directly.", "INVALID_CONFLICT_TARGET");
12455
+ const collection = driver.registry?.getCollectionByPath(request.path);
12456
+ const onConflict = collection ? resolveConflictTarget(request.onConflict, collection, { where: "`onConflict`" }) : void 0;
12457
+ return {
12458
+ upsert: true,
12459
+ ...onConflict && { onConflict }
12460
+ };
12461
+ };
11054
12462
  const getScopedDelegate = async () => {
11055
12463
  if (typeof driver.withAuth === "function") try {
11056
12464
  const who = sessionAuthContext(clientSessions.get(clientId));
@@ -11076,6 +12484,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11076
12484
  {
11077
12485
  wsDebug("📋 [WebSocket Server] Processing FETCH_COLLECTION request");
11078
12486
  const request = payload;
12487
+ assertReadRequest(request.path, request);
11079
12488
  const rows = await (await getScopedDelegate()).fetchCollection({
11080
12489
  ...request,
11081
12490
  limit: resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch })
@@ -11113,8 +12522,31 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11113
12522
  depth: null,
11114
12523
  colors: true
11115
12524
  }));
11116
- assertWriteRequest(request.path, request.values);
11117
- const row = await (await getScopedDelegate()).save(request);
12525
+ const usersCollection = request.status !== "existing" && request.upsert !== true && authAdapter?.prepareUserCreation ? authCollectionAt(request.path) : void 0;
12526
+ if (usersCollection) {
12527
+ const body = request.values ?? {};
12528
+ assertUserCreationBodyValid(authAdapter, usersCollection, body, { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] });
12529
+ assertNoFieldOpsOnCreate(body, "A create");
12530
+ } else assertWriteRequest(request.path, request.values, request.status);
12531
+ assertNestedWrite(request.path, request.id === void 0 || request.status !== "existing" ? "create" : "update");
12532
+ const delegate = await getScopedDelegate();
12533
+ const userAdmin = request.status === "existing" && request.id !== void 0 ? userAdminFor(request.path) : void 0;
12534
+ const [values] = userAdmin?.prepareUserUpdates ? await userAdmin.prepareUserUpdates([{
12535
+ uid: String(request.id),
12536
+ values: request.values
12537
+ }]) : [request.values];
12538
+ const write = (toWrite) => delegate.save({
12539
+ path: request.path,
12540
+ ...request.id !== void 0 && { id: request.id },
12541
+ values: toWrite,
12542
+ status: request.status,
12543
+ ...upsertOptions(request)
12544
+ });
12545
+ const created = usersCollection ? await createUserThroughAuthCollection(authAdapter, usersCollection, values ?? {}, write) : void 0;
12546
+ const row = created ? {
12547
+ ...created.row,
12548
+ ...created.delivery
12549
+ } : await write(values);
11118
12550
  wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, {
11119
12551
  depth: null,
11120
12552
  colors: true
@@ -11133,13 +12565,19 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11133
12565
  wsDebug("🗑️ [WebSocket Server] Processing DELETE_ENTITY request");
11134
12566
  const request = payload;
11135
12567
  wsDebug("🗑️ [WebSocket Server] Deleting row:", request.row);
11136
- await (await getScopedDelegate()).delete({
12568
+ assertNestedWrite(request.row?.path, "unlink");
12569
+ const delegate = await getScopedDelegate();
12570
+ const userAdmin = userAdminFor(request.row?.path);
12571
+ const uids = [String(request.row.id)];
12572
+ await userAdmin?.prepareUserDeletions?.(uids);
12573
+ await delegate.delete({
11137
12574
  row: {
11138
12575
  id: request.row.id,
11139
12576
  path: request.row.path
11140
12577
  },
11141
12578
  hard: request.hard
11142
12579
  });
12580
+ await userAdmin?.finalizeUserDeletions?.(uids);
11143
12581
  wsDebug("🗑️ [WebSocket Server] DELETE_ENTITY completed successfully");
11144
12582
  const response = {
11145
12583
  type: "DELETE_SUCCESS",
@@ -11154,6 +12592,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11154
12592
  {
11155
12593
  wsDebug("🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request");
11156
12594
  const { path, name, value, id, collection } = payload;
12595
+ if (typeof name === "string") assertReadRequest(path, { filter: { [name]: ["==", value] } });
11157
12596
  const isUnique = await (await getScopedDelegate()).checkUniqueField(path, name, value, id, collection);
11158
12597
  wsDebug("🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:", isUnique);
11159
12598
  const response = {
@@ -11168,6 +12607,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11168
12607
  case "COUNT":
11169
12608
  {
11170
12609
  const request = payload;
12610
+ assertReadRequest(request.path, request);
11171
12611
  const response = {
11172
12612
  type: "COUNT_SUCCESS",
11173
12613
  payload: { count: await (await getScopedDelegate()).count(request) },
@@ -11185,7 +12625,10 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11185
12625
  sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
11186
12626
  break;
11187
12627
  }
11188
- const result = await admin.executeSql(sql, options);
12628
+ const result = await admin.executeSql(sql, {
12629
+ ...options,
12630
+ isolateSession: true
12631
+ });
11189
12632
  if (process.env.NODE_ENV !== "production") wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
11190
12633
  const auditSession = clientSessions.get(clientId);
11191
12634
  logger.info("[SQL Audit] WebSocket SQL execution", {
@@ -11592,7 +13035,7 @@ function createBackupCron(config) {
11592
13035
  enabled: config.enabled ?? true,
11593
13036
  timeoutSeconds: 3600,
11594
13037
  async handler({ log }) {
11595
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-BNLwvxuy.js").then((n) => n.r);
13038
+ const { createDump, discardPartialDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-HQ9GC4tN.js").then((n) => n.r);
11596
13039
  const { destination } = config;
11597
13040
  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 }).`);
11598
13041
  log(`Starting backup of "${dbName}"…`);
@@ -11606,8 +13049,8 @@ function createBackupCron(config) {
11606
13049
  log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
11607
13050
  const check = await validateDump(dump.localFile);
11608
13051
  if (!check.ok) {
11609
- if (destination.kind !== "local" && fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
11610
- if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
13052
+ if (dump.globalsFile) discardPartialDump(dump.globalsFile);
13053
+ discardPartialDump(dump.localFile);
11611
13054
  throw new Error(`New backup failed validation — skipping upload and pruning to protect existing backups. ${check.reason}`);
11612
13055
  }
11613
13056
  let storedKey = dump.localFile;
@@ -12182,6 +13625,14 @@ var UserService = class {
12182
13625
  updatedAt: row.updated_at
12183
13626
  }));
12184
13627
  }
13628
+ /**
13629
+ * @see UserRepository.unlinkUserIdentity — scoped to the user as well as
13630
+ * the identity, so it can only ever detach an identity from the account
13631
+ * it names.
13632
+ */
13633
+ async unlinkUserIdentity(uid, provider, providerId) {
13634
+ await this.withServerContext(async (db) => db.delete(this.userIdentitiesTable).where(sql`${this.userIdentitiesTable.uid} = ${uid} AND ${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`));
13635
+ }
12185
13636
  async linkUserIdentity(uid, provider, providerId, profileData) {
12186
13637
  await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
12187
13638
  uid,
@@ -12196,7 +13647,13 @@ var UserService = class {
12196
13647
  const payload = this.mapPayload(data);
12197
13648
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
12198
13649
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
12199
- const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
13650
+ let row;
13651
+ try {
13652
+ [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
13653
+ } catch (error) {
13654
+ if (extractPgError(error)?.code === "23505") throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
13655
+ throw error;
13656
+ }
12200
13657
  return row ? this.mapRowToUser(row) : null;
12201
13658
  }
12202
13659
  async deleteUser(id) {
@@ -12249,7 +13706,7 @@ var UserService = class {
12249
13706
  };
12250
13707
  }
12251
13708
  /**
12252
- * Update user's password hash
13709
+ * Update user's password hash; `null` removes the password.
12253
13710
  */
12254
13711
  async updatePassword(id, passwordHash) {
12255
13712
  const idCol = getColumn(this.usersTable, "id");
@@ -12419,7 +13876,18 @@ var RefreshTokenService = class RefreshTokenService {
12419
13876
  if (session && this.has("sessionId")) values.sessionId = session.id;
12420
13877
  if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
12421
13878
  if (session?.aal && this.has("aal")) values.aal = session.aal;
12422
- await this.db.insert(this.refreshTokensTable).values(values);
13879
+ const parentHash = session?.rotatedFrom && this.has("rotatedAt") ? session.rotatedFrom : void 0;
13880
+ if (!parentHash) {
13881
+ await this.db.insert(this.refreshTokensTable).values(values);
13882
+ return true;
13883
+ }
13884
+ return await this.db.transaction(async (tx) => {
13885
+ const live = this.has("revoked") ? sql`${this.refreshTokensTable.tokenHash} = ${parentHash} AND ${this.col("revoked")} IS NOT TRUE` : eq(this.refreshTokensTable.tokenHash, parentHash);
13886
+ const [parent] = await tx.select({ id: this.refreshTokensTable.id }).from(this.refreshTokensTable).where(live).for("share");
13887
+ if (!parent) return false;
13888
+ await tx.insert(this.refreshTokensTable).values(values);
13889
+ return true;
13890
+ });
12423
13891
  }
12424
13892
  /**
12425
13893
  * Build a {@link RefreshTokenInfo} out of a row of {@link selection}.
@@ -12475,17 +13943,31 @@ var RefreshTokenService = class RefreshTokenService {
12475
13943
  }
12476
13944
  await this.db.update(this.refreshTokensTable).set({ rotatedAt: /* @__PURE__ */ new Date() }).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
12477
13945
  }
12478
- /** Final kill of one sign-in: logout, or revoking a device remotely. */
13946
+ /**
13947
+ * Final kill of one sign-in: logout, or revoking a device remotely.
13948
+ *
13949
+ * Lock first, write second, in one transaction. A rotation in flight
13950
+ * holds a share lock on its parent — a row of this session — until the
13951
+ * token it mints is committed (see `createToken`), so the lock here waits
13952
+ * for it, and the write, being a new statement, reads a snapshot that has
13953
+ * that token in it. A lone UPDATE would work from a snapshot taken before
13954
+ * the token existed and leave it live: the sign-out a refresh in flight
13955
+ * used to undo.
13956
+ */
12479
13957
  async revokeSession(sessionId) {
12480
13958
  if (!this.has("sessionId")) return;
12481
- if (this.has("revoked")) {
12482
- await this.db.update(this.refreshTokensTable).set({
12483
- revoked: true,
12484
- ...this.has("rotatedAt") ? { rotatedAt: /* @__PURE__ */ new Date() } : {}
12485
- }).where(eq(this.col("sessionId"), sessionId));
12486
- return;
12487
- }
12488
- await this.db.delete(this.refreshTokensTable).where(eq(this.col("sessionId"), sessionId));
13959
+ const inSession = eq(this.col("sessionId"), sessionId);
13960
+ await this.db.transaction(async (tx) => {
13961
+ await tx.select({ id: this.refreshTokensTable.id }).from(this.refreshTokensTable).where(inSession).for("update");
13962
+ if (this.has("revoked")) {
13963
+ await tx.update(this.refreshTokensTable).set({
13964
+ revoked: true,
13965
+ ...this.has("rotatedAt") ? { rotatedAt: /* @__PURE__ */ new Date() } : {}
13966
+ }).where(inSession);
13967
+ return;
13968
+ }
13969
+ await tx.delete(this.refreshTokensTable).where(inSession);
13970
+ });
12489
13971
  }
12490
13972
  /**
12491
13973
  * Housekeeping: rotation would otherwise leave a row per refresh forever.
@@ -12676,7 +14158,7 @@ var PostgresTokenRepository = class {
12676
14158
  this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
12677
14159
  }
12678
14160
  async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
12679
- await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
14161
+ return this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
12680
14162
  }
12681
14163
  async markRefreshTokenRotated(tokenHash) {
12682
14164
  await this.refreshTokenService.markRotated(tokenHash);
@@ -12765,6 +14247,9 @@ var PostgresAuthRepository = class {
12765
14247
  async linkUserIdentity(uid, provider, providerId, profileData) {
12766
14248
  return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
12767
14249
  }
14250
+ async unlinkUserIdentity(uid, provider, providerId) {
14251
+ await this.userService.unlinkUserIdentity(uid, provider, providerId);
14252
+ }
12768
14253
  async updateUser(id, data) {
12769
14254
  return this.userService.updateUser(id, data);
12770
14255
  }
@@ -12858,7 +14343,7 @@ var PostgresAuthRepository = class {
12858
14343
  }
12859
14344
  async deleteRole(_id) {}
12860
14345
  async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
12861
- await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
14346
+ return this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
12862
14347
  }
12863
14348
  async markRefreshTokenRotated(tokenHash) {
12864
14349
  await this.tokenRepository.markRefreshTokenRotated(tokenHash);
@@ -13113,7 +14598,9 @@ var MfaService = class {
13113
14598
  };
13114
14599
  }
13115
14600
  /**
13116
- * Count one failed guess against a challenge and report the new total.
14601
+ * Count one guess against a challenge and report the new total. The route
14602
+ * claims it before judging the code, so the total it returns is what the
14603
+ * cap is decided on.
13117
14604
  *
13118
14605
  * Incremented in the database rather than in the route so that guesses
13119
14606
  * arriving in parallel — the shape any real brute-force takes — cannot
@@ -14729,9 +16216,13 @@ function createPostgresBootstrapper(pgConfig) {
14729
16216
  const provisioningDb = (driverResult) => {
14730
16217
  return (driverResult?.internals)?.db ?? pgConfig.connection;
14731
16218
  };
14732
- const provisioningQueryable = (driverResult) => {
14733
- const db = (driverResult?.internals)?.db ?? pgConfig.connection;
16219
+ const requireProvisioningDb = (driverResult) => {
16220
+ const db = provisioningDb(driverResult);
14734
16221
  if (!db) throw new Error("Cannot provision the collection schema: this Postgres adapter was created without a `connection`, and no initialized driver was supplied to fall back on. Pass `connection` to `createPostgresAdapter` (see `createPostgresDatabaseConnection`).");
16222
+ return db;
16223
+ };
16224
+ const provisioningQueryable = (driverResult) => {
16225
+ const db = requireProvisioningDb(driverResult);
14735
16226
  return { async query(text) {
14736
16227
  return { rows: sqlRows(await db.execute(sql.raw(text))) };
14737
16228
  } };
@@ -14744,14 +16235,13 @@ function createPostgresBootstrapper(pgConfig) {
14744
16235
  * Idempotent and cheap to repeat: `CREATE SCHEMA IF NOT EXISTS` plus a
14745
16236
  * handful of `CREATE OR REPLACE FUNCTION`.
14746
16237
  */
14747
- const provisionRlsRuntime = async (queryable) => {
14748
- const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-BHA6jLtj.js").then((n) => n.i);
14749
- await queryable.query("SELECT pg_advisory_lock(hashtext('rebase_auth_functions_init'))");
14750
- try {
14751
- for (const statement of RLS_BOOTSTRAP_STATEMENTS) await queryable.query(statement);
14752
- } finally {
14753
- await queryable.query("SELECT pg_advisory_unlock(hashtext('rebase_auth_functions_init'))");
14754
- }
16238
+ const provisionRlsRuntime = async (driverResult) => {
16239
+ const db = requireProvisioningDb(driverResult);
16240
+ const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-B8EclDyM.js").then((n) => n.i);
16241
+ await db.transaction(async (tx) => {
16242
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
16243
+ for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
16244
+ });
14755
16245
  };
14756
16246
  return {
14757
16247
  type: "postgres",
@@ -15139,7 +16629,7 @@ function createPostgresBootstrapper(pgConfig) {
15139
16629
  * proved it can bootstrap with.
15140
16630
  */
15141
16631
  async ensureCollectionSchema(collections, driverResult, log) {
15142
- const { ensureCollectionTables } = await import("./ensure-collection-tables-Cr2ye5JC.js").then((n) => n.n);
16632
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-BEEjn5cn.js").then((n) => n.n);
15143
16633
  const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
15144
16634
  for (const failure of plan.failures) logger.warn(describeEnsureFailure(failure));
15145
16635
  return { applied: plan.actions.length - plan.failures.length };
@@ -15170,13 +16660,13 @@ function createPostgresBootstrapper(pgConfig) {
15170
16660
  * as the auth path and the migration preamble, from the same constant.
15171
16661
  */
15172
16662
  async ensureRlsRuntime(driverResult) {
15173
- await provisionRlsRuntime(provisioningQueryable(driverResult));
16663
+ await provisionRlsRuntime(driverResult);
15174
16664
  },
15175
16665
  async ensureCollectionPolicies(collections, driverResult, log) {
15176
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-B4IDFtQ-.js");
16666
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-RHUcEp4v.js");
15177
16667
  const queryable = provisioningQueryable(driverResult);
15178
16668
  const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
15179
- if (!rows[0]?.present) await provisionRlsRuntime(queryable);
16669
+ if (!rows[0]?.present) await provisionRlsRuntime(driverResult);
15180
16670
  const outcome = await ensureCollectionPolicies(queryable, collections, log);
15181
16671
  for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
15182
16672
  for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — RLS is on, so it denies until this is resolved: ${failure.error}`);
@@ -15184,7 +16674,7 @@ function createPostgresBootstrapper(pgConfig) {
15184
16674
  for (const u of outcome.unsecured.filter((u) => u.grantWithdrawn)) logger.error(`🔐 [rls] Could not enable row-level security on "${u.table}": ${u.error}. Its privileges have been revoked from ${REBASE_USER_ROLE}, so the table is unreachable rather than unprotected. Reads and writes to that collection will fail until RLS can be enabled.`);
15185
16675
  if (unrevoked.length > 0) throw new Error("Refusing to start: row-level security could not be enabled on " + unrevoked.map((u) => `"${u.table}" (${u.error})`).join(", ") + `, and the privileges granted to ${REBASE_USER_ROLE} could not be revoked either. The table would be served with no row filtering. Fix the database permissions (the connection role must own these tables, or be able to ALTER them) and boot again.`);
15186
16676
  try {
15187
- const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-BHA6jLtj.js").then((n) => n.i);
16677
+ const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-B8EclDyM.js").then((n) => n.i);
15188
16678
  await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
15189
16679
  info: (m) => logger.info(m),
15190
16680
  warn: (m) => logger.warn(m)
@@ -15262,9 +16752,9 @@ function createPostgresBootstrapper(pgConfig) {
15262
16752
  return driverResult.internals.driver.admin;
15263
16753
  },
15264
16754
  mountRoutes(app, basePath, driverResult) {},
15265
- async initializeWebsockets(server, realtimeService, driver, config, adapter) {
16755
+ async initializeWebsockets(server, realtimeService, driver, config, adapter, limits) {
15266
16756
  const { createPostgresWebSocket } = await Promise.resolve().then(() => websocket_exports);
15267
- createPostgresWebSocket(server, realtimeService, driver, config, adapter);
16757
+ createPostgresWebSocket(server, realtimeService, driver, config, adapter, limits);
15268
16758
  }
15269
16759
  };
15270
16760
  }
@@ -15289,8 +16779,8 @@ function createPostgresAdapter(pgConfig) {
15289
16779
  async initializeHistory(config, driverResult) {
15290
16780
  if (bootstrapper.initializeHistory) return bootstrapper.initializeHistory(config, driverResult);
15291
16781
  },
15292
- initializeWebsockets(server, realtimeService, driver, config, adapter) {
15293
- if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config, adapter);
16782
+ initializeWebsockets(server, realtimeService, driver, config, adapter, limits) {
16783
+ if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config, adapter, limits);
15294
16784
  },
15295
16785
  verifyConnection: bootstrapper.verifyConnection ? (driverResult) => bootstrapper.verifyConnection(driverResult) : void 0,
15296
16786
  ensureCollectionSchema: bootstrapper.ensureCollectionSchema ? (collections, driverResult, log) => bootstrapper.ensureCollectionSchema(collections, driverResult, log) : void 0,