@rebasepro/server-postgres 0.22.0 → 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 (81) 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-DqBakiMO.js → backup-cli-24v4OlSp.js} +17 -14
  7. package/dist/backup-cli-24v4OlSp.js.map +1 -0
  8. package/dist/{backup-service-CaMOS76G.js → backup-service-HQ9GC4tN.js} +2 -2
  9. package/dist/{backup-service-CaMOS76G.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-CU9IogdL.js → doctor-Cb2thZ8s.js} +8 -7
  22. package/dist/doctor-Cb2thZ8s.js.map +1 -0
  23. package/dist/{ensure-collection-policies-Dzd-2S81.js → ensure-collection-policies-RHUcEp4v.js} +5 -5
  24. package/dist/{ensure-collection-policies-Dzd-2S81.js.map → ensure-collection-policies-RHUcEp4v.js.map} +1 -1
  25. package/dist/{ensure-collection-tables-CpAgy51F.js → ensure-collection-tables-BEEjn5cn.js} +53 -22
  26. package/dist/ensure-collection-tables-BEEjn5cn.js.map +1 -0
  27. package/dist/{ensure-tables-Cr5B4UmH.js → ensure-tables-Dhn9KM3B.js} +2 -2
  28. package/dist/{ensure-tables-Cr5B4UmH.js.map → ensure-tables-Dhn9KM3B.js.map} +1 -1
  29. package/dist/{generate-drizzle-schema-logic-BcMl7VSy.js → generate-drizzle-schema-logic-BfzK7UQd.js} +2 -2
  30. package/dist/{generate-drizzle-schema-logic-BcMl7VSy.js.map → generate-drizzle-schema-logic-BfzK7UQd.js.map} +1 -1
  31. package/dist/{generate-drizzle-schema-B537GIvz.js → generate-drizzle-schema-yzY_BLhr.js} +2 -2
  32. package/dist/{generate-drizzle-schema-B537GIvz.js.map → generate-drizzle-schema-yzY_BLhr.js.map} +1 -1
  33. package/dist/{generate-postgres-ddl-logic-D7imhYV8.js → generate-postgres-ddl-logic-Bt2d2mRH.js} +2 -2
  34. package/dist/{generate-postgres-ddl-logic-D7imhYV8.js.map → generate-postgres-ddl-logic-Bt2d2mRH.js.map} +1 -1
  35. package/dist/index.es.js +1508 -355
  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-CboAIwLN.js → plan-schema-C0fxM8dY.js} +158 -31
  40. package/dist/plan-schema-C0fxM8dY.js.map +1 -0
  41. package/dist/{policy-drift-xJfy9xG7.js → policy-drift-DljYdrpW.js} +2 -2
  42. package/dist/{policy-drift-xJfy9xG7.js.map → policy-drift-DljYdrpW.js.map} +1 -1
  43. package/dist/{rls-bootstrap-sql-H3rCFi3F.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/services/FetchService.d.ts +97 -28
  57. package/dist/services/PersistService.d.ts +41 -5
  58. package/dist/services/RelationService.d.ts +11 -0
  59. package/dist/services/RelationWriteService.d.ts +6 -0
  60. package/dist/services/cdc/CdcListener.d.ts +6 -1
  61. package/dist/services/collection-helpers.d.ts +11 -0
  62. package/dist/services/dataService.d.ts +3 -20
  63. package/dist/services/field-op-sql.d.ts +71 -0
  64. package/dist/services/junction-writes.d.ts +19 -3
  65. package/dist/services/pg-notify-listener.d.ts +7 -0
  66. package/dist/services/read-field-access.d.ts +19 -0
  67. package/dist/services/realtimeService.d.ts +77 -3
  68. package/dist/services/row-pipeline.d.ts +5 -0
  69. package/dist/services/soft-delete.d.ts +0 -2
  70. package/dist/services/write-transaction-scope.d.ts +39 -0
  71. package/dist/utils/drizzle-conditions.d.ts +7 -2
  72. package/dist/websocket.d.ts +10 -3
  73. package/package.json +9 -9
  74. package/dist/backup-cli-DqBakiMO.js.map +0 -1
  75. package/dist/cli-errors-Dka89exj.js.map +0 -1
  76. package/dist/column-plan-helpers-CpILzHJS.js.map +0 -1
  77. package/dist/doctor-CU9IogdL.js.map +0 -1
  78. package/dist/ensure-collection-tables-CpAgy51F.js.map +0 -1
  79. package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
  80. package/dist/plan-schema-CboAIwLN.js.map +0 -1
  81. package/dist/rls-bootstrap-sql-H3rCFi3F.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-CboAIwLN.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-D7imhYV8.js";
12
- import { t as generateSchema } from "./generate-drizzle-schema-logic-BcMl7VSy.js";
13
- import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-CpAgy51F.js";
14
- import { t as main } from "./generate-drizzle-schema-B537GIvz.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-CaMOS76G.js";
16
- import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-Cr5B4UmH.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, splitSearchTerms, 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
@@ -1918,11 +1937,16 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1918
1937
  return sql`(${rank} + public.word_similarity(${spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`}, ${fuzzyColumn}))`;
1919
1938
  }
1920
1939
  /**
1921
- * 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.
1922
1946
  */
1923
- static buildUniqueFieldCondition(fieldColumn, value, idColumn, excludeId) {
1947
+ static buildUniqueFieldCondition(fieldColumn, value, excludeRow) {
1924
1948
  const conditions = [eq(fieldColumn, value)];
1925
- if (excludeId && idColumn) conditions.push(sql`${idColumn} != ${excludeId}`);
1949
+ if (excludeRow) conditions.push(not(excludeRow));
1926
1950
  return conditions;
1927
1951
  }
1928
1952
  /**
@@ -2199,6 +2223,11 @@ function toRestValues(row, collection) {
2199
2223
  * an `update` that echoes the row back overwrite the real value with the null it
2200
2224
  * was handed.
2201
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
+ *
2202
2231
  * `_matches` is filtered rather than deleted: it is the list of fields a text
2203
2232
  * search hit, and a field the caller cannot read must not appear in it even
2204
2233
  * though the array itself is theirs to see.
@@ -2208,13 +2237,22 @@ function stripUnreadable(row, collection) {
2208
2237
  if (!properties) return row;
2209
2238
  const viewer = currentFieldViewer();
2210
2239
  const hidden = /* @__PURE__ */ new Set();
2240
+ const hide = (name) => {
2241
+ hidden.add(name);
2242
+ delete row[name];
2243
+ };
2244
+ let relations;
2211
2245
  for (const [key, property] of Object.entries(properties)) {
2212
2246
  if (canReadField(property, viewer)) continue;
2213
- hidden.add(key);
2214
- delete row[key];
2215
- if (property.columnName) {
2216
- hidden.add(property.columnName);
2217
- 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
+ }
2218
2256
  }
2219
2257
  }
2220
2258
  if (hidden.size > 0 && Array.isArray(row._matches)) row._matches = row._matches.filter((match) => !hidden.has(typeof match === "string" ? match : String(match?.field)));
@@ -2419,8 +2457,10 @@ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
2419
2457
  * the form runs under RLS, so a user who may edit the parent but cannot see
2420
2458
  * some of the linked rows gets a shorter list — and writing it back deleted
2421
2459
  * the links they were never shown. The select that drives the diff runs in
2422
- * this same transaction under the same policies, so a link the caller cannot
2423
- * 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.
2424
2464
  * - **Junction payload columns.** A junction carrying its own columns
2425
2465
  * (`position`, `role`, `created_at`) lost them on every save, because every
2426
2466
  * row was re-inserted with only the two keys. Untouched links are left alone.
@@ -2443,8 +2483,8 @@ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
2443
2483
  * that is what keeps a plain `[1, 2, 3]` membership write from wiping the
2444
2484
  * `role`s off the links it did not mention.
2445
2485
  */
2446
- async function applyJunctionMembership(tx, binding, parentId, links) {
2447
- 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));
2448
2488
  const existingById = /* @__PURE__ */ new Map();
2449
2489
  for (const row of existingRows) {
2450
2490
  if (row.targetId === null || row.targetId === void 0) continue;
@@ -2503,6 +2543,8 @@ async function removeLinks(tx, binding, parentId, removed) {
2503
2543
  if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
2504
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");
2505
2545
  }
2546
+ //#endregion
2547
+ //#region src/services/soft-delete.ts
2506
2548
  /**
2507
2549
  * The soft-delete field this collection uses, or `undefined` if it has none.
2508
2550
  *
@@ -2512,9 +2554,8 @@ async function removeLinks(tx, binding, parentId, removed) {
2512
2554
  * worth avoiding — deletes would silently start removing rows.
2513
2555
  */
2514
2556
  function resolveSoftDelete(collection) {
2515
- const declared = collection?.softDelete;
2516
- if (!declared) return void 0;
2517
- const field = typeof declared === "object" && declared.field || "deletedAt";
2557
+ const field = softDeleteFieldOf(collection);
2558
+ if (!field) return void 0;
2518
2559
  return {
2519
2560
  field,
2520
2561
  columnName: ((collection?.properties)?.[field])?.columnName ?? toSnakeCase(field)
@@ -2640,6 +2681,10 @@ function resolveBeforeQuery(registry, collection) {
2640
2681
  if (own?.beforeQuery) hooks.push(own.beforeQuery);
2641
2682
  return hooks;
2642
2683
  }
2684
+ /** Whether anything at all would run for this collection. */
2685
+ function hasBeforeQuery(registry, collection) {
2686
+ return resolveBeforeQuery(registry, collection).length > 0;
2687
+ }
2643
2688
  /**
2644
2689
  * The conditions every `beforeQuery` hook for this collection asks to add.
2645
2690
  *
@@ -2789,6 +2834,21 @@ var RelationService = class {
2789
2834
  });
2790
2835
  }
2791
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);
2850
+ }
2851
+ /**
2792
2852
  * One target row, as the {@link RelatedRow} everything here returns.
2793
2853
  *
2794
2854
  * Eight sites built this by hand, which is how the address came to be the
@@ -2847,11 +2907,11 @@ var RelationService = class {
2847
2907
  return col;
2848
2908
  };
2849
2909
  if (parentPks.length === 1) {
2850
- const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
2910
+ const values = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentPks[0].fieldName]);
2851
2911
  return inArray(columnFor(parentPks[0].fieldName), values);
2852
2912
  }
2853
2913
  return or(...parentIds.map((id) => {
2854
- const values = parseIdValues(id, parentPks);
2914
+ const values = parseIdValues$1(id, parentPks);
2855
2915
  return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
2856
2916
  }));
2857
2917
  }
@@ -2902,7 +2962,7 @@ var RelationService = class {
2902
2962
  const parentPks = requirePrimaryKeys(parentCollection, this.registry);
2903
2963
  if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
2904
2964
  for (const id of parentIds) {
2905
- const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
2965
+ const value = parseIdValues$1(id, parentPks)[parentPks[0].fieldName];
2906
2966
  keyByParentId.set(String(id), value);
2907
2967
  parentIdByKey.set(String(value), id);
2908
2968
  }
@@ -2952,7 +3012,7 @@ var RelationService = class {
2952
3012
  const idField = targetTable[idInfo[0].fieldName];
2953
3013
  const parentPks = requirePrimaryKeys(parentCollection, this.registry);
2954
3014
  const parentIdInfo = parentPks[0];
2955
- const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
3015
+ const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
2956
3016
  const parentTableName = getTableName$1(parentCollection);
2957
3017
  const parentTable = this.registry.getTable(parentTableName);
2958
3018
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
@@ -2977,7 +3037,7 @@ var RelationService = class {
2977
3037
  currentTable = joinTable;
2978
3038
  }
2979
3039
  const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
2980
- query = query.where(narrowed(eq(parentIdField, parsedParentId), relatedNarrowing));
3040
+ query = query.where(narrowed(eq(parentIdField, parsedParentId), andSoftDelete(relatedNarrowing, targetCollection, targetTable)));
2981
3041
  if (options.limit) query = query.limit(options.limit);
2982
3042
  const results = await query;
2983
3043
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
@@ -3040,7 +3100,7 @@ var RelationService = class {
3040
3100
  const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
3041
3101
  const parentPks = requirePrimaryKeys(parentCollection, this.registry);
3042
3102
  const parentIdInfo = parentPks[0];
3043
- const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
3103
+ const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
3044
3104
  const parentTableName = getTableName$1(parentCollection);
3045
3105
  const parentTable = this.registry.getTable(parentTableName);
3046
3106
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
@@ -3067,7 +3127,7 @@ var RelationService = class {
3067
3127
  async isRelated(hop, targetId) {
3068
3128
  const targetTable = getTableForCollection(hop.targetCollection, this.registry);
3069
3129
  const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
3070
- const parsedTargetId = parseIdValues(targetId, targetPks);
3130
+ const parsedTargetId = parseIdValues$1(targetId, targetPks);
3071
3131
  const identity = targetPks.map((pk) => {
3072
3132
  const column = targetTable[pk.fieldName];
3073
3133
  if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
@@ -3097,7 +3157,7 @@ var RelationService = class {
3097
3157
  const parentTable = this.registry.getTable(parentTableName);
3098
3158
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
3099
3159
  const parentIdCol = parentTable[parentIdInfo.fieldName];
3100
- const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
3160
+ const parsedParentIds = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentIdInfo.fieldName]);
3101
3161
  const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
3102
3162
  if (relation.kind === "via") {
3103
3163
  let query = this.db.select().from(parentTable).$dynamic();
@@ -3201,7 +3261,7 @@ var RelationService = class {
3201
3261
  const parentTable = this.registry.getTable(parentTableName);
3202
3262
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
3203
3263
  const parentIdCol = parentTable[parentIdInfo.fieldName];
3204
- const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
3264
+ const parsedParentIds = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentIdInfo.fieldName]);
3205
3265
  const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
3206
3266
  if (relation.kind === "via") {
3207
3267
  let query = this.db.select().from(parentTable).$dynamic();
@@ -4042,6 +4102,11 @@ var FetchService = class FetchService {
4042
4102
  * rows in arbitrary order, and paging over that repeats and skips rows.
4043
4103
  */
4044
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";
4045
4110
  resolveOrderTarget(table, orderBy, collection, searchString) {
4046
4111
  if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
4047
4112
  const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
@@ -4125,15 +4190,17 @@ var FetchService = class FetchService {
4125
4190
  return key.direction === "asc";
4126
4191
  }
4127
4192
  /**
4128
- * The full `ORDER BY`: the caller's keys, then the id.
4193
+ * The full `ORDER BY`: the caller's keys, then the primary key.
4129
4194
  *
4130
- * The id is always last and always descending. It is not decoration — it is
4131
- * what makes the ordering *total*, and a cursor over a non-total order
4132
- * repeats and skips rows among the ties. Every keyset comparison built by
4133
- * {@link buildCursorConditions} ends on the same `id DESC`, and the two have
4134
- * to agree: they did not, and an ascending sort paged with `id >` against an
4135
- * `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
4136
- * 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.
4137
4204
  *
4138
4205
  * Where the NULLs go is written out rather than inherited. Postgres already
4139
4206
  * defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this
@@ -4144,13 +4211,13 @@ var FetchService = class FetchService {
4144
4211
  * relation reaches nothing from, which is precisely the "nobody waiting"
4145
4212
  * end of a queue.
4146
4213
  */
4147
- buildOrderExpressions(keys, idField, tieBreakOnId = true) {
4214
+ buildOrderExpressions(keys, keyColumns) {
4148
4215
  const expressions = keys.map((key) => {
4149
4216
  const nullsLast = FetchService.nullsLast(key);
4150
4217
  if (key.direction === "asc") return nullsLast ? sql`${key.target} ASC NULLS LAST` : sql`${key.target} ASC NULLS FIRST`;
4151
4218
  return nullsLast ? sql`${key.target} DESC NULLS LAST` : sql`${key.target} DESC NULLS FIRST`;
4152
4219
  });
4153
- if (tieBreakOnId) expressions.push(desc(idField));
4220
+ for (const column of keyColumns) expressions.push(desc(column));
4154
4221
  return expressions;
4155
4222
  }
4156
4223
  resolveOrderByField(table, orderBy, collection) {
@@ -4193,40 +4260,6 @@ var FetchService = class FetchService {
4193
4260
  });
4194
4261
  }
4195
4262
  /**
4196
- * Build the `with` config for Drizzle's relational query API.
4197
- * Converts collection relations to a Drizzle-compatible `with` object.
4198
- *
4199
- * When `include` is provided, only those relations are loaded.
4200
- * When `include` is absent, ALL relations are loaded (the admin path).
4201
- *
4202
- * Automatically detects many-to-many junction tables and nests
4203
- * the target relation so actual row data is returned.
4204
- */
4205
- buildWithConfig(collection, include) {
4206
- const resolvedRelations = resolveCollectionRelations(collection);
4207
- const withConfig = {};
4208
- const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
4209
- for (const [key, relation] of Object.entries(resolvedRelations)) {
4210
- if (!shouldInclude(key)) continue;
4211
- const drizzleRelName = relation.relationName || key;
4212
- if (relation.kind === "via") continue;
4213
- if (relation.cardinality === "many" && isJunctionRelation(relation)) {
4214
- const targetFkName = this.getJunctionTargetRelationName(relation, collection);
4215
- if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
4216
- else withConfig[drizzleRelName] = true;
4217
- } else withConfig[drizzleRelName] = true;
4218
- }
4219
- return withConfig;
4220
- }
4221
- /**
4222
- * Get the Drizzle relation name on the junction table that points to the actual target row.
4223
- * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
4224
- */
4225
- getJunctionTargetRelationName(relation, _collection) {
4226
- if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
4227
- return null;
4228
- }
4229
- /**
4230
4263
  * The relations one level of an include tree names, resolved.
4231
4264
  *
4232
4265
  * A name that is not a relation is a **400 `UNKNOWN_RELATION`**. It used to
@@ -4454,36 +4487,49 @@ var FetchService = class FetchService {
4454
4487
  */
4455
4488
  cursorFor(collectionPath, row, orderBy) {
4456
4489
  if (orderBy?.some(([field]) => field === FetchService.SCORE_FIELD)) return void 0;
4490
+ if (FetchService.DISTANCE_FIELD in row) return void 0;
4457
4491
  try {
4458
4492
  const pks = getPrimaryKeys(getCollectionByPath(collectionPath, this.registry), this.registry);
4459
4493
  if (pks.length === 0) return void 0;
4460
4494
  const address = buildCompositeId$1(row, pks);
4461
4495
  if (!address || !address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "")) return void 0;
4462
- return encodeCursor(orderBy, row, row[pks[0].fieldName]);
4496
+ return encodeCursor(orderBy, row, pks.length === 1 ? row[pks[0].fieldName] : address);
4463
4497
  } catch {
4464
4498
  return;
4465
4499
  }
4466
4500
  }
4467
4501
  /**
4468
- * Post-fetch joinPath relations for a single flat row.
4469
- * joinPath relations cannot be expressed via Drizzle's `with` config,
4470
- * so they must be loaded separately after the primary query.
4471
- */
4472
- async resolveJoinPathRelations(row, collection, collectionPath, parsedId, _databaseId) {
4473
- const resolvedRelations = resolveCollectionRelations(collection);
4474
- const promises = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via").map(async ([key, relation]) => {
4475
- try {
4476
- const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: relation.cardinality === "one" ? 1 : void 0 });
4477
- if (relation.cardinality === "one" && relatedRows.length > 0) {
4478
- const e = relatedRows[0];
4479
- row[key] = createRelationRefWithData(e.id, e.path, e);
4480
- } else if (relation.cardinality === "many") row[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
4481
- } catch (e) {
4482
- if (reachedDatabase(e)) throw e;
4483
- logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
4484
- }
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
4485
4522
  });
4486
- 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
+ }
4487
4533
  }
4488
4534
  /**
4489
4535
  * Extract cursor pagination conditions from startAfter options.
@@ -4493,26 +4539,102 @@ var FetchService = class FetchService {
4493
4539
  * one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with
4494
4540
  * several it nests, each key's tie handing the decision to the next.
4495
4541
  */
4496
- buildCursorConditions(table, idField, idInfo, options, collectionPath) {
4542
+ buildCursorConditions(table, idInfoArray, options, collectionPath) {
4497
4543
  if (!options.startAfter) return [];
4498
4544
  const cursor = options.startAfter;
4499
4545
  const keys = normalizeDriverOrderBy(options.orderBy, options.order);
4546
+ const keyColumns = FetchService.keyColumns(table, idInfoArray, collectionPath);
4547
+ const cursorKey = FetchService.cursorKey(cursor, idInfoArray, collectionPath);
4500
4548
  if (keys) {
4501
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 });
4502
- const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
4503
- const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
4504
- const resolved = this.resolveOrderKeys(table, keys, collection, void 0, collectionPath, startAfterId ?? void 0);
4505
- 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) {
4506
4553
  const cursorValues = cursor.values;
4507
4554
  const values = resolved.map(({ field, cursorTarget }) => cursorTarget ? null : cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
4508
- 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
+ }
4509
4559
  }
4510
- } else {
4511
- const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
4512
- if (startAfterId !== void 0 && startAfterId !== null) return [lt(idField, parseIdValues(startAfterId, [idInfo])[idInfo.fieldName])];
4513
- }
4560
+ } else if (cursorKey) return [FetchService.keyAfter(keyColumns, cursorKey)];
4514
4561
  return [];
4515
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
+ }
4516
4638
  /**
4517
4639
  * "Sorts strictly after the cursor row", over `keys` and then the id.
4518
4640
  *
@@ -4526,13 +4648,13 @@ var FetchService = class FetchService {
4526
4648
  * NULL and therefore match nothing. Ordering by a nullable column and paging
4527
4649
  * used to drop every row whose sort value was NULL from page two onward.
4528
4650
  */
4529
- buildKeysetComparison(keys, values, idField, cursorId, index = 0) {
4530
- 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);
4531
4653
  const { direction, cursorTarget } = keys[index];
4532
4654
  const nullsLast = FetchService.nullsLast(keys[index]);
4533
4655
  const target = keys[index].target;
4534
4656
  const value = values[index];
4535
- const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
4657
+ const rest = this.buildKeysetComparison(keys, values, keyColumns, cursorKey, index + 1);
4536
4658
  const after = (a, b) => direction === "asc" ? gt(a, b) : lt(a, b);
4537
4659
  const afterSql = (a, b) => direction === "asc" ? sql`${a} > ${b}` : sql`${a} < ${b}`;
4538
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))));
@@ -4546,7 +4668,7 @@ var FetchService = class FetchService {
4546
4668
  buildRelationScope(hop) {
4547
4669
  const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
4548
4670
  const parentIdInfo = parentPks[0];
4549
- const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
4671
+ const parsedParentId = parseIdValues$1(hop.parentId, parentPks)[parentIdInfo.fieldName];
4550
4672
  const parent = () => {
4551
4673
  const table = getTableForCollection(hop.parentCollection, this.registry);
4552
4674
  const idColumn = table[parentIdInfo.fieldName];
@@ -4586,60 +4708,38 @@ var FetchService = class FetchService {
4586
4708
  const collection = getCollectionByPath(collectionPath, this.registry);
4587
4709
  const table = getTableForCollection(collection, this.registry);
4588
4710
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
4589
- const idInfo = idInfoArray[0];
4590
- const idField = table[idInfo.fieldName];
4591
- if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
4592
4711
  if (!idCanAddressTable(id, table, idInfoArray)) return void 0;
4593
- const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
4712
+ const addressed = rowIdentityCondition(table, idInfoArray, id, collectionPath);
4594
4713
  const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", {});
4595
- const identity = narrowing ? and(eq(idField, parsedId), narrowing) : eq(idField, parsedId);
4714
+ const identity = narrowing ? and(addressed, narrowing) : addressed;
4596
4715
  const tableName = getTableName(table);
4597
4716
  const qb = this.getQueryBuilder(tableName);
4598
- if (qb) try {
4599
- const withConfig = this.buildWithConfig(collection);
4600
- const hidden = hiddenColumnsOption(getTableColumns(table), collection);
4601
- const row = await qb.findFirst({
4602
- where: andSoftDelete(identity, collection, table, withDeleted),
4603
- with: withConfig,
4604
- ...hidden ? { columns: hidden } : {}
4605
- });
4606
- if (!row) return void 0;
4607
- const flatRow = toFlatRow(row, collection, this.registry);
4608
- await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
4609
- return flatRow;
4610
- } catch (e) {
4611
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
4612
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
4613
- 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);
4614
4734
  }
4615
- if (reachedDatabase(e)) throw e;
4616
- logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
4617
4735
  }
4618
4736
  const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
4619
4737
  const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
4620
4738
  if (result.length === 0) return void 0;
4621
4739
  const raw = result[0];
4622
- const values = await parseDataFromServer(raw, collection, this.db, this.registry);
4623
- const resolvedRelations = resolveCollectionRelations(collection);
4624
- const propertyKeys = new Set(Object.keys(collection.properties));
4625
- const relationPromises = Object.entries(resolvedRelations).filter(([key]) => propertyKeys.has(key)).map(async ([key, relation]) => {
4626
- if (relation.cardinality === "many") {
4627
- const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, {});
4628
- values[key] = relatedRows.map((e) => createRelationRef(e.id, e.path));
4629
- } else if (relation.cardinality === "one") {
4630
- if (values[key] == null) try {
4631
- const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: 1 });
4632
- if (relatedRows.length > 0) {
4633
- const e = relatedRows[0];
4634
- values[key] = createRelationRef(e.id, e.path);
4635
- }
4636
- } catch (e) {
4637
- if (reachedDatabase(e)) throw e;
4638
- logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
4639
- }
4640
- }
4641
- });
4642
- 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);
4643
4743
  return {
4644
4744
  ...values,
4645
4745
  id: id.toString()
@@ -4670,6 +4770,7 @@ var FetchService = class FetchService {
4670
4770
  if (results.length === 0) return [];
4671
4771
  const values = await Promise.all(results.map(async (rawRow) => await parseDataFromServer(rawRow, collection)));
4672
4772
  if (include) await this.loadIncludes(values, collection, collectionPath, include);
4773
+ for (const row of values) stripUnreadable(row, collection);
4673
4774
  return values;
4674
4775
  }
4675
4776
  /**
@@ -4677,11 +4778,10 @@ var FetchService = class FetchService {
4677
4778
  */
4678
4779
  async fetchCollection(collectionPath, options = {}) {
4679
4780
  const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
4680
- if (hop) return this.fetchRowsWithConditions(hop.targetCollection.slug, {
4781
+ return this.fetchRowsWithConditions(hop ? hop.targetCollection.slug : collectionPath, {
4681
4782
  ...options,
4682
4783
  relatedTo: hop
4683
4784
  });
4684
- return this.fetchRowsWithConditions(collectionPath, options);
4685
4785
  }
4686
4786
  /**
4687
4787
  * Search rows by text
@@ -4806,7 +4906,9 @@ var FetchService = class FetchService {
4806
4906
  }
4807
4907
  if (groupColumns.length > 0) {
4808
4908
  query = query.groupBy(...groupColumns.map((g) => g.column));
4909
+ query = query.orderBy(...this.aggregateOrdering(options.orderBy, groupColumns, options.aggregates, selection));
4809
4910
  if (options.limit) query = query.limit(options.limit);
4911
+ if (options.offset) query = query.offset(options.offset);
4810
4912
  }
4811
4913
  const rows = await query;
4812
4914
  const numericAliases = numericAggregateAliases(options.aggregates, collection?.properties ?? {});
@@ -4821,6 +4923,50 @@ var FetchService = class FetchService {
4821
4923
  });
4822
4924
  }
4823
4925
  /**
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
+ /**
4824
4970
  * Check if a field value is unique.
4825
4971
  *
4826
4972
  * Deliberately **not** narrowed by `beforeQuery`, and it is the only read
@@ -4836,12 +4982,10 @@ var FetchService = class FetchService {
4836
4982
  const collection = getCollectionByPath(collectionPath, this.registry);
4837
4983
  const table = getTableForCollection(collection, this.registry);
4838
4984
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
4839
- const idInfo = idInfoArray[0];
4840
- const idField = table[idInfo.fieldName];
4841
4985
  const field = table[fieldName];
4842
4986
  if (!field) return true;
4843
- const parsedExcludeId = excludeEntityId ? parseIdValues(excludeEntityId, idInfoArray)[idInfo.fieldName] : void 0;
4844
- 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);
4845
4989
  const result = await this.db.select({ count: count() }).from(table).where(and(...conditions));
4846
4990
  return Number(result[0]?.count || 0) === 0;
4847
4991
  }
@@ -4901,14 +5045,12 @@ var FetchService = class FetchService {
4901
5045
  const collection = getCollectionByPath(collectionPath, this.registry);
4902
5046
  const table = getTableForCollection(collection, this.registry);
4903
5047
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
4904
- const idInfo = idInfoArray[0];
4905
- const idField = table[idInfo.fieldName];
4906
5048
  if (!idCanAddressTable(id, table, idInfoArray)) return null;
4907
- const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
5049
+ const addressed = rowIdentityCondition(table, idInfoArray, id, collectionPath);
4908
5050
  const withDeleted = options?.withDeleted;
4909
5051
  const projection = this.columnProjection(table, collection, options?.fields, idInfoArray);
4910
5052
  const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", FetchService.describeRead({ fields: options?.fields }));
4911
- const identity = narrowing ? and(eq(idField, parsedId), narrowing) : eq(idField, parsedId);
5053
+ const identity = narrowing ? and(addressed, narrowing) : addressed;
4912
5054
  const result = await this.db.select(projection).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
4913
5055
  if (result.length === 0) return null;
4914
5056
  const row = toRestRow(result[0], collection, this.registry);
@@ -4923,8 +5065,7 @@ var FetchService = class FetchService {
4923
5065
  const collection = getCollectionByPath(collectionPath, this.registry);
4924
5066
  const table = getTableForCollection(collection, this.registry);
4925
5067
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
4926
- const idInfo = idInfoArray[0];
4927
- 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");
4928
5069
  let vectorMeta;
4929
5070
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
4930
5071
  const visible = this.columnProjection(table, collection, options.fields, idInfoArray, options.distinct === true);
@@ -4972,10 +5113,11 @@ var FetchService = class FetchService {
4972
5113
  const missing = sortKeys.map(([field]) => field).filter((field) => !selected.has(field));
4973
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 });
4974
5115
  }
4975
- 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);
4976
5118
  if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
4977
5119
  if (options.startAfter) {
4978
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
5120
+ const cursorConditions = this.buildCursorConditions(table, idInfoArray, options, collectionPath);
4979
5121
  if (cursorConditions.length > 0) {
4980
5122
  allConditions.push(...cursorConditions);
4981
5123
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
@@ -5114,16 +5256,33 @@ var RelationWriteService = class {
5114
5256
  relation: hop.relationKey
5115
5257
  });
5116
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
+ }
5117
5276
  /** A collection's id, parsed to the type its primary key column holds. */
5118
5277
  parsedId(collection, id) {
5119
5278
  const pks = requirePrimaryKeys(collection, this.registry);
5120
- return parseIdValues(id, pks)[pks[0].fieldName];
5279
+ return parseIdValues$1(id, pks)[pks[0].fieldName];
5121
5280
  }
5122
5281
  /** The same, for the membership list a to-many write names. */
5123
5282
  parsedIds(collection, ids) {
5124
5283
  if (ids.length === 0) return [];
5125
5284
  const pks = requirePrimaryKeys(collection, this.registry);
5126
- return ids.map((id) => parseIdValues(id, pks)[pks[0].fieldName]);
5285
+ return ids.map((id) => parseIdValues$1(id, pks)[pks[0].fieldName]);
5127
5286
  }
5128
5287
  /**
5129
5288
  * A membership list as links: the target ids parsed, and each element's
@@ -5141,7 +5300,7 @@ var RelationWriteService = class {
5141
5300
  if (elements.length === 0) return [];
5142
5301
  const pks = requirePrimaryKeys(collection, this.registry);
5143
5302
  return elements.map((element) => ({
5144
- id: parseIdValues(element.id, pks)[pks[0].fieldName],
5303
+ id: parseIdValues$1(element.id, pks)[pks[0].fieldName],
5145
5304
  pivot: element.pivot ? serializePivot(element.pivot, payload, label) : void 0
5146
5305
  }));
5147
5306
  }
@@ -5157,8 +5316,8 @@ var RelationWriteService = class {
5157
5316
  const targetEntityIds = elements.map((element) => element.id);
5158
5317
  const targetCollection = relation.target();
5159
5318
  const label = `${collection.slug}.${key}`;
5160
- 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));
5161
- 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));
5162
5321
  else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
5163
5322
  const targetTable = getTableForCollection(targetCollection, this.registry);
5164
5323
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
@@ -5169,11 +5328,12 @@ var RelationWriteService = class {
5169
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`}`);
5170
5329
  const parentKeyValue = (await this.reads.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
5171
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);
5172
5332
  if (targetEntityIds.length > 0) {
5173
- const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
5174
- 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));
5175
5335
  await tx.update(targetTable).set({ [fkField]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
5176
- } 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));
5177
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`");
5178
5338
  }
5179
5339
  }
@@ -5209,15 +5369,16 @@ var RelationWriteService = class {
5209
5369
  if (!foreignKeyColumn) throw relationMisconfigured(label, `'${relation.foreignKeyOnTarget}' is not a column on the target table '${getTableName$1(targetCollection)}'`);
5210
5370
  const sourceKeyValue = (await this.reads.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
5211
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.`);
5212
- 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));
5213
5374
  else {
5214
- const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
5375
+ const parsedNewTargetId = parseIdValues$1(newValue, targetPks)[targetIdInfo.fieldName];
5215
5376
  const targetIdField = targetTable[targetIdInfo.fieldName];
5216
- 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));
5217
5378
  await tx.update(targetTable).set({ [fkField]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
5218
5379
  }
5219
5380
  } catch (e) {
5220
- if (e instanceof ApiError || e?.name === "ApiError") throw e;
5381
+ if (e instanceof ApiError || e?.name === "ApiError" || reachedDatabase(e)) throw e;
5221
5382
  logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
5222
5383
  }
5223
5384
  }
@@ -5232,7 +5393,7 @@ var RelationWriteService = class {
5232
5393
  try {
5233
5394
  const binding = bindJoinPathJunction(this.registry, relation.joinPath, sourceTableName, targetTableName, `${sourceCollection.slug}.${relation.relationName}`);
5234
5395
  const elements = Array.isArray(newValue) ? relationLinkElements(newValue, relation.relationName, sourceCollection.slug) : newValue === null || newValue === void 0 ? [] : relationLinkElements([newValue], relation.relationName, sourceCollection.slug);
5235
- 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));
5236
5397
  } catch (error) {
5237
5398
  logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
5238
5399
  throw error;
@@ -5245,7 +5406,7 @@ var RelationWriteService = class {
5245
5406
  const label = `${sourceCollection.slug}.${relation.relationName}`;
5246
5407
  try {
5247
5408
  const elements = Array.isArray(newValue) ? relationLinkElements(newValue, relation.relationName, sourceCollection.slug) : [];
5248
- 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));
5249
5410
  } catch (error) {
5250
5411
  logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
5251
5412
  throw error;
@@ -5268,7 +5429,7 @@ var RelationWriteService = class {
5268
5429
  const parentTable = getTableForCollection(parentCollection, this.registry);
5269
5430
  const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5270
5431
  const parentIdInfo = parentPks[0];
5271
- const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5432
+ const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
5272
5433
  const parentIdCol = parentTable[parentIdInfo.fieldName];
5273
5434
  const parentSourceCol = parentTable[parentSourceColName];
5274
5435
  const targetFKCol = targetTable[targetFKColName];
@@ -5282,7 +5443,7 @@ var RelationWriteService = class {
5282
5443
  if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
5283
5444
  continue;
5284
5445
  }
5285
- const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
5446
+ const parsedTargetId = parseIdValues$1(newTargetId, targetPks)[targetIdInfo.fieldName];
5286
5447
  if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
5287
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");
5288
5449
  await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
@@ -5351,6 +5512,10 @@ var RelationWriteService = class {
5351
5512
  * silently empties itself the first time it is incremented from an unset column
5352
5513
  * is worse than one that never worked.
5353
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
+ *
5354
5519
  * @module
5355
5520
  */
5356
5521
  /** What the column is, as Postgres sees it: `text[]`, `jsonb`, `integer`, … */
@@ -5429,9 +5594,165 @@ function compileFieldOps(table, fieldOps, context) {
5429
5594
  for (const [field, op] of Object.entries(fieldOps)) compiled[field] = compileFieldOp(table, field, op, context);
5430
5595
  return compiled;
5431
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
+ }
5432
5738
  //#endregion
5433
5739
  //#region src/services/PersistService.ts
5434
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
+ /**
5435
5756
  * Service for handling all row write operations.
5436
5757
  * Handles saving, deleting, and updating rows.
5437
5758
  */
@@ -5493,14 +5814,20 @@ var PersistService = class {
5493
5814
  const collection = getCollectionByPath(collectionPath, this.registry);
5494
5815
  const table = getTableForCollection(collection, this.registry);
5495
5816
  const idInfoArray = getPrimaryKeys(collection, this.registry);
5496
- const parsedIdObj = parseIdValues(id, idInfoArray);
5817
+ const parsedIdObj = parseIdValues$1(id, idInfoArray);
5497
5818
  const conditions = [];
5498
5819
  for (const info of idInfoArray) {
5499
5820
  const field = table[info.fieldName];
5500
5821
  if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
5501
5822
  conditions.push(eq(field, parsedIdObj[info.fieldName]));
5502
5823
  }
5503
- 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");
5504
5831
  }
5505
5832
  /**
5506
5833
  * Delete all rows from a collection
@@ -5544,6 +5871,10 @@ var PersistService = class {
5544
5871
  * the row already exists — which is what a re-runnable import needs. The
5545
5872
  * conflict is matched on the primary key unless `options.onConflict` names
5546
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.
5547
5878
  *
5548
5879
  * `values` may carry field operations (`{ views: { $inc: 1 } }`). They are
5549
5880
  * split out here rather than at the REST boundary because every request
@@ -5561,7 +5892,7 @@ var PersistService = class {
5561
5892
  effectiveCollectionPath = hop.targetCollection.slug;
5562
5893
  const parentIdForWrite = () => {
5563
5894
  const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
5564
- return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
5895
+ return parseIdValues$1(hop.parentId, parentPks)[parentPks[0].fieldName];
5565
5896
  };
5566
5897
  if (isManyToMany(hop.relation)) junctionTableInfo = {
5567
5898
  parentCollection: hop.parentCollection,
@@ -5622,10 +5953,11 @@ var PersistService = class {
5622
5953
  let currentId;
5623
5954
  if (id && !options?.upsert) {
5624
5955
  currentId = id;
5625
- const idValues = parseIdValues(id, idInfoArray);
5956
+ const idValues = parseIdValues$1(id, idInfoArray);
5626
5957
  if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
5627
5958
  const scalarKeys = Object.keys(entityData);
5628
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 }) : [];
5629
5961
  if (scalarKeys.length > 0 || compiledOps) {
5630
5962
  const updateQuery = tx.update(table).set({
5631
5963
  ...entityData,
@@ -5636,11 +5968,15 @@ var PersistService = class {
5636
5968
  const field = table[info.fieldName];
5637
5969
  conditions.push(eq(field, idValues[info.fieldName]));
5638
5970
  }
5639
- 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
+ }
5640
5976
  }
5641
5977
  } else {
5642
5978
  const dataForInsert = { ...entityData };
5643
- if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
5979
+ if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues$1(id, idInfoArray));
5644
5980
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
5645
5981
  const insertQuery = tx.insert(table).values(dataForInsert);
5646
5982
  const targetFields = options?.onConflict && options.onConflict.length > 0 ? [...options.onConflict] : idInfoArray.map((info) => info.fieldName);
@@ -5655,11 +5991,24 @@ var PersistService = class {
5655
5991
  const set = { ...dataForInsert };
5656
5992
  for (const info of idInfoArray) delete set[info.fieldName];
5657
5993
  for (const field of targetFields) delete set[field];
5658
- for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
5659
- 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({
5660
5999
  target,
5661
- set
6000
+ set,
6001
+ setWhere: scope
5662
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
+ }
5663
6012
  } else result = await insertQuery.returning(returningKeys);
5664
6013
  const resultRow = result[0];
5665
6014
  if (!resultRow) if (id) currentId = id;
@@ -5680,6 +6029,26 @@ var PersistService = class {
5680
6029
  return finalEntity;
5681
6030
  }
5682
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
+ /**
5683
6052
  * Get the RelationService instance for external use
5684
6053
  */
5685
6054
  getRelationService() {
@@ -5708,7 +6077,7 @@ var PersistService = class {
5708
6077
  * so `author_id` had to be translated to `authorId` by whoever read the
5709
6078
  * message — which nothing does. See `pgFieldViolations`.
5710
6079
  */
5711
- toUserFriendlyError(error, collectionSlug, collection) {
6080
+ toUserFriendlyError(error, collectionSlug, collection, operation = "save") {
5712
6081
  if (error instanceof ApiError || error?.name === "ApiError") return error;
5713
6082
  const pgError = extractPgError(error);
5714
6083
  if (pgError) {
@@ -5718,7 +6087,7 @@ var PersistService = class {
5718
6087
  collection: collectionSlug,
5719
6088
  violations
5720
6089
  } : void 0;
5721
- 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);
5722
6091
  if (isRowLevelSecurityDenial(error)) return ApiError.forbidden(message, "WRITE_DENIED");
5723
6092
  return new Error(message, { cause: error });
5724
6093
  }
@@ -5903,6 +6272,10 @@ var WriteTransactionScope = class {
5903
6272
  open = true;
5904
6273
  inflight = /* @__PURE__ */ new Set();
5905
6274
  commitHooks = [];
6275
+ /** The first tracked statement that failed, whether or not its caller caught it. */
6276
+ failure;
6277
+ settledHooks = [];
6278
+ settledContext;
5906
6279
  /** The transaction handle, for statements in this package that are already written against drizzle. */
5907
6280
  tx;
5908
6281
  /** Attach the transaction, once it has begun. */
@@ -5933,7 +6306,10 @@ var WriteTransactionScope = class {
5933
6306
  const done = () => {
5934
6307
  this.inflight.delete(settled);
5935
6308
  };
5936
- settled.then(done, done);
6309
+ settled.then(done, (error) => {
6310
+ this.failure ??= error;
6311
+ done();
6312
+ });
5937
6313
  return settled;
5938
6314
  }
5939
6315
  afterCommit(fn) {
@@ -5950,6 +6326,65 @@ var WriteTransactionScope = class {
5950
6326
  this.open = false;
5951
6327
  }
5952
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
+ /**
5953
6388
  * The transaction is over, whichever way it went. Nothing may reach it now:
5954
6389
  * its client is back in the pool, and a callback's detached promise still
5955
6390
  * carries this scope in its async context.
@@ -6067,7 +6502,15 @@ function classifyCollectionChanges(before, after, facts) {
6067
6502
  });
6068
6503
  continue;
6069
6504
  }
6070
- 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);
6071
6514
  }
6072
6515
  for (const [slug, collection] of previous) {
6073
6516
  if (next.has(slug)) continue;
@@ -6376,6 +6819,13 @@ function effectiveSqlRole(requestedRole) {
6376
6819
  /** How {@link effectiveSqlRole} names "whatever role the connection holds". */
6377
6820
  var CONNECTION_OWNER = "<connection owner>";
6378
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
+ /**
6379
6829
  * A statement named a database role the connection cannot assume.
6380
6830
  *
6381
6831
  * Its own type because the tempting recovery — run it anyway, as the owner — is
@@ -6395,6 +6845,66 @@ var RoleSwitchUnavailableError = class extends Error {
6395
6845
  }
6396
6846
  };
6397
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
+ /**
6398
6908
  * Refuse a write that would leave a *required* `created_by` / `updated_by`
6399
6909
  * column null because nobody is acting.
6400
6910
  *
@@ -6731,17 +7241,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6731
7241
  return out;
6732
7242
  }));
6733
7243
  }
6734
- async fetchCollection({ path, collection, filter, limit, offset, startAfter, orderBy, searchString, order, vectorSearch }) {
7244
+ async fetchCollection({ path, collection, startAfter, ...query }) {
6735
7245
  const rows = await this.dataService.fetchCollection(path, {
6736
- filter,
6737
- orderBy,
6738
- order,
6739
- limit,
6740
- offset,
7246
+ ...query,
6741
7247
  startAfter,
6742
- databaseId: collection?.databaseId,
6743
- searchString,
6744
- vectorSearch
7248
+ databaseId: collection?.databaseId
6745
7249
  });
6746
7250
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
6747
7251
  if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
@@ -6925,16 +7429,17 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6925
7429
  const { collection: slug, userField, tenantField } = tenant.from.membership;
6926
7430
  const cap = PostgresBackendDriver.TENANT_MEMBERSHIP_CAP;
6927
7431
  try {
6928
- const rows = await this.dataService.fetchCollection(slug, {
7432
+ const rows = await withFieldViewer(void 0, () => this.dataService.fetchCollection(slug, {
6929
7433
  filter: { [userField]: ["==", uid] },
6930
7434
  limit: cap + 1,
6931
7435
  databaseId: collection?.databaseId
6932
- });
7436
+ }));
6933
7437
  return {
6934
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))),
6935
7439
  complete: rows.length <= cap
6936
7440
  };
6937
7441
  } catch (err) {
7442
+ if (reachedDatabase(err)) throw err;
6938
7443
  logger.debug(`[save] Could not read '${slug}' to resolve the caller's tenants`, { detail: err instanceof Error ? err.message : String(err) });
6939
7444
  return {
6940
7445
  tenants: [],
@@ -6942,21 +7447,86 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6942
7447
  };
6943
7448
  }
6944
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
+ }
6945
7495
  async save({ path, id, values, collection, status, upsert, onConflict }) {
6946
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
+ }
6947
7509
  let updatedValues = values;
6948
7510
  const contextForCallback = this.buildCallContext();
6949
7511
  if ((status === "new" || status === "copy") && resolvedCollection?.properties) updatedValues = applyDefaultValuesOnCreate(updatedValues, resolvedCollection.properties);
6950
7512
  let previousValuesForHistory;
6951
- if (status === "existing" && id) try {
6952
- const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
6953
- if (existing) {
6954
- const { id: _existingId, ...existingValues } = existing;
6955
- 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) });
6956
7526
  }
6957
- } catch (err) {
6958
- logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
6959
7527
  }
7528
+ /** The row as the hooks receive it, to tell what they wrote. */
7529
+ const beforeHooks = updatedValues;
6960
7530
  try {
6961
7531
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
6962
7532
  const callbackCollection = requireCallbackCollection(resolvedCollection, path);
@@ -6970,7 +7540,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6970
7540
  status,
6971
7541
  context: contextForCallback
6972
7542
  });
6973
- if (result) updatedValues = mergeDeep(updatedValues, result);
7543
+ if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
6974
7544
  }
6975
7545
  if (callbacks?.beforeSave) {
6976
7546
  const result = await callbacks.beforeSave({
@@ -6982,7 +7552,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6982
7552
  status,
6983
7553
  context: contextForCallback
6984
7554
  });
6985
- if (result) updatedValues = mergeDeep(updatedValues, result);
7555
+ if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
6986
7556
  }
6987
7557
  if (propertyCallbacks?.beforeSave) {
6988
7558
  const result = await propertyCallbacks.beforeSave({
@@ -6994,12 +7564,13 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6994
7564
  status,
6995
7565
  context: contextForCallback
6996
7566
  });
6997
- if (result) updatedValues = mergeDeep(updatedValues, result);
7567
+ if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
6998
7568
  }
6999
7569
  }
7000
7570
  } catch (callbackError) {
7001
7571
  throw toCallbackError(callbackError, "beforeSave", path);
7002
7572
  }
7573
+ const afterHooks = updatedValues;
7003
7574
  if (resolvedCollection?.properties) {
7004
7575
  updatedValues = updateDateAutoValues({
7005
7576
  inputValues: updatedValues,
@@ -7014,12 +7585,29 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7014
7585
  status: status ?? "new",
7015
7586
  uid: this.user?.uid
7016
7587
  });
7588
+ if (status === "existing") {
7589
+ const kept = { ...updatedValues };
7590
+ for (const key of createStampKeys(resolvedCollection.properties)) delete kept[key];
7591
+ updatedValues = kept;
7592
+ }
7017
7593
  }
7018
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);
7019
7606
  try {
7020
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, {
7021
- upsert,
7022
- onConflict
7607
+ let savedRow = await this.dataService.save(path, updatedValues, writeId, resolvedCollection?.databaseId, {
7608
+ upsert: upserting,
7609
+ onConflict,
7610
+ insertOnlyKeys
7023
7611
  });
7024
7612
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
7025
7613
  const callbackCollection = requireCallbackCollection(resolvedCollection, path);
@@ -7079,9 +7667,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7079
7667
  throw toCallbackError(callbackError, "afterSave", path);
7080
7668
  }
7081
7669
  if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
7082
- tableName: path,
7670
+ tableName: resolvedCollection.slug,
7083
7671
  id: savedId,
7084
- action: status === "new" ? "create" : "update",
7672
+ action: status === "existing" ? "update" : "create",
7085
7673
  values: savedValues,
7086
7674
  previousValues: previousValuesForHistory,
7087
7675
  updatedBy: this.user?.uid
@@ -7097,27 +7685,31 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7097
7685
  } catch (error) {
7098
7686
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
7099
7687
  const callbackCollection = requireCallbackCollection(resolvedCollection, path);
7100
- const errorProps = {
7101
- path,
7102
- id,
7103
- values: updatedValues,
7104
- previousValues: previousValuesForHistory,
7105
- status,
7106
- error,
7107
- 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
+ }
7108
7711
  };
7109
- if (globalCallbacks?.afterSaveError) await globalCallbacks.afterSaveError({
7110
- collection: callbackCollection,
7111
- ...errorProps
7112
- });
7113
- if (callbacks?.afterSaveError) await callbacks.afterSaveError({
7114
- collection: callbackCollection,
7115
- ...errorProps
7116
- });
7117
- if (propertyCallbacks?.afterSaveError) await propertyCallbacks.afterSaveError({
7118
- collection: callbackCollection,
7119
- ...errorProps
7120
- });
7712
+ if (!currentWriteScope()?.afterSettled(runHooks)) await runHooks(contextForCallback);
7121
7713
  }
7122
7714
  throw error;
7123
7715
  }
@@ -7215,7 +7807,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7215
7807
  if (!await txDriver.fetchOne({
7216
7808
  path,
7217
7809
  id: String(id),
7218
- collection
7810
+ collection,
7811
+ withDeleted: restoresSoftDeletedRow(collection ?? this.registry?.getCollectionByPath(path), values) ? true : void 0
7219
7812
  })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
7220
7813
  statusCode: 404,
7221
7814
  code: "NOT_FOUND"
@@ -7325,7 +7918,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7325
7918
  if (!await txDriver.fetchOne({
7326
7919
  path: operation.path,
7327
7920
  id,
7328
- collection: operation.collection
7921
+ collection: operation.collection,
7922
+ withDeleted: restoresSoftDeletedRow(operation.collection ?? this.registry?.getCollectionByPath(operation.path), values) ? true : void 0
7329
7923
  })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
7330
7924
  statusCode: 404,
7331
7925
  code: "NOT_FOUND"
@@ -7410,7 +8004,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7410
8004
  } catch (callbackError) {
7411
8005
  throw toCallbackError(callbackError, "beforeDelete", targetPath);
7412
8006
  }
7413
- 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);
7414
8010
  if (softDelete) await this.dataService.save(targetPath, { [softDelete.field]: /* @__PURE__ */ new Date() }, row.id, resolvedCollection?.databaseId);
7415
8011
  else await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
7416
8012
  try {
@@ -7441,8 +8037,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7441
8037
  } catch (callbackError) {
7442
8038
  throw toCallbackError(callbackError, "afterDelete", targetPath);
7443
8039
  }
7444
- if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
7445
- tableName: targetPath,
8040
+ if (this.historyService && resolvedCollection?.history && !unlinking) await this.historyService.recordHistory({
8041
+ tableName: resolvedCollection.slug,
7446
8042
  id: row.id.toString(),
7447
8043
  action: "delete",
7448
8044
  values: stored,
@@ -7497,8 +8093,48 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7497
8093
  return sql.join(chunks, sql.raw(""));
7498
8094
  }
7499
8095
  async executeSql(sqlText, options) {
8096
+ if (options?.isolateSession) return this.onIsolatedSession(this.getTargetDb(options.database), (db) => this.executeSqlOn(db, sqlText, options));
7500
8097
  if (!options?.database && !options?.role) return this.dataService.executeSql(sqlText, options?.params);
7501
- 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) {
7502
8138
  try {
7503
8139
  let needsRoleSwitch = false;
7504
8140
  if (options?.role) try {
@@ -7678,14 +8314,15 @@ var PostgresBackendDriver = class PostgresBackendDriver {
7678
8314
  AND ccu2.table_name != ${safeName};
7679
8315
  `)).rows,
7680
8316
  policies: (await this.db.execute(sql`
7681
- SELECT
7682
- polname as policy_name,
7683
- polcmd as cmd,
7684
- polroles::regrole[]::text[] as roles,
7685
- pg_get_expr(polqual, polrelid) as qual,
7686
- pg_get_expr(polwithcheck, polrelid) as with_check
7687
- FROM pg_policy
7688
- 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};
7689
8326
  `)).rows
7690
8327
  };
7691
8328
  }
@@ -7740,6 +8377,16 @@ var AuthenticatedPostgresBackendDriver = class {
7740
8377
  async withTransaction(operation, options) {
7741
8378
  const pendingNotifications = [];
7742
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
+ });
7743
8390
  const result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await runInWriteScope(writeScope, () => this.delegate.db.transaction(async (tx) => {
7744
8391
  if (!this.user?.uid) logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
7745
8392
  if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
@@ -7760,9 +8407,14 @@ var AuthenticatedPostgresBackendDriver = class {
7760
8407
  throw error;
7761
8408
  }
7762
8409
  await writeScope?.settle();
8410
+ await writeScope?.assertCommittable();
7763
8411
  return out;
7764
- }, options)).finally(() => writeScope?.close()));
8412
+ }, options)).finally(() => writeScope?.close())).catch(async (error) => {
8413
+ await writeScope?.settled();
8414
+ throw error;
8415
+ });
7765
8416
  writeScope?.committed();
8417
+ await writeScope?.settled();
7766
8418
  for (const notification of pendingNotifications) try {
7767
8419
  await this.delegate.realtimeService.notifyUpdate(notification.path, notification.id, notification.row, notification.databaseId);
7768
8420
  } catch (e) {
@@ -8131,6 +8783,41 @@ function buildJunctionLinkMap(registry) {
8131
8783
  return map;
8132
8784
  }
8133
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
8134
8821
  //#region src/services/cdc/trigger-cdc.ts
8135
8822
  /**
8136
8823
  * Trigger-based Change Data Capture (CDC).
@@ -8163,6 +8850,11 @@ var CDC_TRIGGER_NAME = "rebase_cdc_trigger";
8163
8850
  * statement* if the limit is exceeded. We stay comfortably under it and, for
8164
8851
  * wide rows, fall back to an identity-only payload so CDC can never break a
8165
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.
8166
8858
  */
8167
8859
  var MAX_NOTIFY_BYTES = 7900;
8168
8860
  var quoteIdent = (name) => `"${name.replace(/"/g, "\"\"")}"`;
@@ -8186,6 +8878,7 @@ LANGUAGE plpgsql AS $rebase_cdc$
8186
8878
  DECLARE
8187
8879
  rec jsonb;
8188
8880
  payload text;
8881
+ ident jsonb;
8189
8882
  BEGIN
8190
8883
  IF (TG_OP = 'DELETE') THEN
8191
8884
  rec := to_jsonb(OLD);
@@ -8204,11 +8897,18 @@ BEGIN
8204
8897
  -- 8000-byte cap, emit an identity-only payload the consumer can still route
8205
8898
  -- (and refetch the authoritative row from).
8206
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;
8207
8907
  payload := json_build_object(
8208
8908
  'schema', TG_TABLE_SCHEMA,
8209
8909
  'table', TG_TABLE_NAME,
8210
8910
  'op', TG_OP,
8211
- 'row', CASE WHEN rec ? 'id' THEN jsonb_build_object('id', rec->'id') ELSE '{}'::jsonb END,
8911
+ 'row', ident,
8212
8912
  'truncated', true
8213
8913
  )::text;
8214
8914
  END IF;
@@ -8357,6 +9057,15 @@ var PgNotifyListener = class {
8357
9057
  if (initial) throw err;
8358
9058
  logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
8359
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
+ }
8360
9069
  }
8361
9070
  }
8362
9071
  scheduleReconnect() {
@@ -8413,11 +9122,17 @@ function parseCdcPayload(payload) {
8413
9122
  */
8414
9123
  var CdcListener = class {
8415
9124
  listener;
8416
- 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) {
8417
9131
  this.listener = new PgNotifyListener({
8418
9132
  connectionString,
8419
9133
  channel: CDC_CHANNEL,
8420
9134
  logLabel: "[CDC]",
9135
+ onReconnect,
8421
9136
  onPayload: (payload) => {
8422
9137
  const event = parseCdcPayload(payload);
8423
9138
  if (!event) {
@@ -9255,6 +9970,23 @@ function callbackContextWithin(driver, tx, auth) {
9255
9970
  });
9256
9971
  }
9257
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
+ /**
9258
9990
  * PostgreSQL-specific realtime service.
9259
9991
  * Handles WebSocket connections and subscriptions for real-time row updates.
9260
9992
  *
@@ -9371,8 +10103,20 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9371
10103
  * and always flow through. Keyed → expiry timestamp (ms).
9372
10104
  */
9373
10105
  recentAppEmits = /* @__PURE__ */ new Map();
9374
- /** How long an app-emit key suppresses its own CDC echo. Covers NOTIFY round-trip latency. */
9375
- 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;
9376
10120
  constructor(db, registry) {
9377
10121
  super();
9378
10122
  this.db = db;
@@ -9597,7 +10341,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9597
10341
  const subscriptionId = request.subscriptionId;
9598
10342
  let canDeliver;
9599
10343
  try {
9600
- if (!this.registry.getCollectionByPath(request.path)) {
10344
+ const collection = this.registry.getCollectionByPath(request.path);
10345
+ if (!collection) {
9601
10346
  const registered = this.registry.getCollections().map((c) => c.slug).join(", ");
9602
10347
  const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
9603
10348
  logger.error(`[RealtimeService] ${msg}`);
@@ -9628,6 +10373,17 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9628
10373
  this.sendError(clientId, e.message, subscriptionId, e.code);
9629
10374
  return;
9630
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
+ }
9631
10387
  const subscription = {
9632
10388
  clientId,
9633
10389
  type: "collection",
@@ -9706,6 +10462,76 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9706
10462
  if (canDeliver && !canDeliver.mayReportFailure()) return;
9707
10463
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
9708
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
+ }
9709
10535
  async handleUnsubscribe(_clientId, subscriptionId) {
9710
10536
  this._subscriptions.delete(subscriptionId);
9711
10537
  this.subscriptionCallbacks.delete(subscriptionId);
@@ -9736,6 +10562,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9736
10562
  */
9737
10563
  async notifyUpdate(path, id, row, databaseId, broadcast = true, origin = "app") {
9738
10564
  this.debugLog("🔔 [RealtimeService] notifyUpdate called for path:", path, "id:", id, "isDelete:", row === null, "origin:", origin);
10565
+ const aliases = this.aliasPaths(path);
9739
10566
  if (this.cdcActive) {
9740
10567
  const key = this.dedupKey(path, id, databaseId);
9741
10568
  if (origin === "cdc") {
@@ -9743,7 +10570,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9743
10570
  this.debugLog("🔁 [RealtimeService] Suppressing CDC echo of local app mutation:", key);
9744
10571
  return;
9745
10572
  }
9746
- } 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
+ }
9747
10580
  }
9748
10581
  const pathsToNotify = [path];
9749
10582
  if (path.includes("/") && path.split("/").length > 1) {
@@ -9752,6 +10585,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9752
10585
  this.debugLog(`🔗 [RealtimeService] Nested path detected. Will notify paths: ${pathsToNotify.join(", ")}`);
9753
10586
  }
9754
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);
9755
10589
  if (broadcast && this.broadcasting && !this.cdcActive) try {
9756
10590
  await this.broadcastChange(path, id, databaseId);
9757
10591
  } catch (err) {
@@ -9950,7 +10784,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9950
10784
  }));
9951
10785
  }
9952
10786
  return fetchedEntities;
9953
- }));
10787
+ }, { accessMode: "read only" }));
9954
10788
  }
9955
10789
  return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchCollectionForRest(notifyPath, {
9956
10790
  filter: collectionRequest.filter,
@@ -10002,7 +10836,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10002
10836
  }, this.rlsUserRole);
10003
10837
  let hookContext;
10004
10838
  return countOnce(new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)));
10005
- });
10839
+ }, { accessMode: "read only" });
10006
10840
  } else total = await countOnce(this.dataService);
10007
10841
  } catch (error) {
10008
10842
  logger.warn(`[RealtimeService] Could not count '${notifyPath}' for a subscription frame`, { error });
@@ -10115,7 +10949,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10115
10949
  }
10116
10950
  }
10117
10951
  return processedEntity;
10118
- }));
10952
+ }, { accessMode: "read only" }));
10119
10953
  }
10120
10954
  return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchOneForRest(notifyPath, id) ?? void 0);
10121
10955
  }
@@ -10194,6 +11028,39 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10194
11028
  * Extract parent paths from a nested path like "posts/70/tags"
10195
11029
  * Returns ["posts", "posts/70"] for the example above
10196
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
+ }
10197
11064
  getParentPaths(path) {
10198
11065
  const segments = path.split("/").filter((s) => s.length > 0);
10199
11066
  const parentPaths = [];
@@ -10766,8 +11633,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10766
11633
  * 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
10767
11634
  * 2. All subscription state and callbacks are cleared.
10768
11635
  * 3. The dedicated LISTEN client (outside the pool) is disconnected.
10769
- * 4. All WebSocket clients are removed (but not forcefully closed — the
10770
- * 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.
10771
11638
  */
10772
11639
  async destroy() {
10773
11640
  for (const [key, timer] of this.refetchTimers) {
@@ -10802,7 +11669,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10802
11669
  await this.stopCdc();
10803
11670
  await this.bus.stop().catch((error) => logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
10804
11671
  this.bus = new MemoryChannelBus();
11672
+ const sockets = [...this.clients.values()];
10805
11673
  this.clients.clear();
11674
+ closeWebSockets(sockets, WEBSOCKET_CLOSE_GRACE_MS);
10806
11675
  this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
10807
11676
  }
10808
11677
  /** Whether database-level change capture is currently the active source. */
@@ -10833,7 +11702,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10833
11702
  }
10834
11703
  this.cdcTableMap = this.buildCdcTableMap();
10835
11704
  this.junctionLinkMap = buildJunctionLinkMap(this.registry);
10836
- this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
11705
+ this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event), () => this.resyncSubscriptions());
10837
11706
  try {
10838
11707
  await this.cdcListener.start();
10839
11708
  } catch (err) {
@@ -10846,6 +11715,32 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10846
11715
  this.cdcActive = true;
10847
11716
  logger.debug(`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events (${this.cdcTableMap.size} mapped table key(s)).`);
10848
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
+ }
10849
11744
  /** Stop the CDC listener and clear its state. */
10850
11745
  async stopCdc() {
10851
11746
  this.cdcActive = false;
@@ -10935,7 +11830,29 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10935
11830
  }
10936
11831
  /** Compute the canonical (possibly composite) id string from a captured row. */
10937
11832
  extractIdFromCdcRow(collection, row) {
10938
- 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;
10939
11856
  }
10940
11857
  dedupKey(path, id, databaseId) {
10941
11858
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -11007,8 +11924,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11007
11924
  }
11008
11925
  /**
11009
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.
11010
11931
  */
11011
- async connectListenClient() {
11932
+ async connectListenClient({ reconnect = false } = {}) {
11012
11933
  if (!this.listenConnectionString) return;
11013
11934
  let pending;
11014
11935
  try {
@@ -11054,6 +11975,10 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11054
11975
  this.listenClient = client;
11055
11976
  pending = void 0;
11056
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
+ }
11057
11982
  } catch (err) {
11058
11983
  if (pending) try {
11059
11984
  await pending.end();
@@ -11078,7 +12003,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11078
12003
  } catch {}
11079
12004
  this.listenClient = void 0;
11080
12005
  }
11081
- await this.connectListenClient();
12006
+ await this.connectListenClient({ reconnect: true });
11082
12007
  }, delay);
11083
12008
  }
11084
12009
  };
@@ -11113,6 +12038,17 @@ var WS_RATE_WINDOW_MS = 6e4;
11113
12038
  * a considered product limit (see `docs/channel-authorization.md`).
11114
12039
  */
11115
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;
11116
12052
  /** Frames counted against the channel budget rather than the general one. */
11117
12053
  var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
11118
12054
  "join_channel",
@@ -11214,7 +12150,33 @@ function isAdminSession(session) {
11214
12150
  if (!session.user.roles) return false;
11215
12151
  return session.user.roles.some((r) => r === "admin");
11216
12152
  }
11217
- 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) {
11218
12180
  const clientSessions = /* @__PURE__ */ new Map();
11219
12181
  const isProduction = process.env.NODE_ENV === "production";
11220
12182
  /**
@@ -11237,15 +12199,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11237
12199
  if (extras.length === 0) logger.debug(message);
11238
12200
  else logger.debug(message, { details: extras.length === 1 ? extras[0] : extras });
11239
12201
  };
11240
- 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
+ });
11241
12207
  wss.on("error", (err) => {
11242
12208
  if (err.code === "EADDRINUSE") return;
11243
12209
  logger.error("❌ [WebSocket Server] Error", { error: err });
11244
12210
  });
11245
12211
  const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);
11246
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.");
11247
- wss.on("connection", (ws) => {
12213
+ wss.on("connection", (ws, request) => {
11248
12214
  const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
12215
+ const origin = connectionOrigin(request);
11249
12216
  wsDebug(`WebSocket client connected: ${clientId}`);
11250
12217
  clientSessions.set(clientId, {
11251
12218
  ws,
@@ -11260,9 +12227,16 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11260
12227
  wsDebug(`WebSocket client disconnected: ${clientId}`);
11261
12228
  clientSessions.delete(clientId);
11262
12229
  });
12230
+ ws.on("error", (error) => {
12231
+ wsDebug(`WebSocket client ${clientId} error: ${error.message}`);
12232
+ });
11263
12233
  ws.on("message", async (message) => {
11264
12234
  let requestId;
11265
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
+ }
11266
12240
  const { type, payload, requestId: reqId } = JSON.parse(message.toString());
11267
12241
  requestId = reqId;
11268
12242
  wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
@@ -11281,6 +12255,33 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11281
12255
  }
11282
12256
  }));
11283
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
+ }
11284
12285
  if (type === "AUTHENTICATE") {
11285
12286
  const { token } = payload || {};
11286
12287
  if (!token) {
@@ -11294,7 +12295,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11294
12295
  uid: adapterUser.uid,
11295
12296
  roles: adapterUser.roles,
11296
12297
  isAdmin: adapterUser.isAdmin,
11297
- isAnonymous: adapterUser.isAnonymous === true
12298
+ isAnonymous: adapterUser.isAnonymous === true,
12299
+ ...adapterUser.claims ? { claims: adapterUser.claims } : {}
11298
12300
  };
11299
12301
  } catch {}
11300
12302
  else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
@@ -11318,6 +12320,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11318
12320
  if (session) {
11319
12321
  session.user = verifiedUser;
11320
12322
  session.authenticated = true;
12323
+ await realtimeService.rescopeClient(clientId, sessionAuthContext(session));
11321
12324
  }
11322
12325
  wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
11323
12326
  ws.send(JSON.stringify({
@@ -11341,39 +12344,22 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11341
12344
  return;
11342
12345
  }
11343
12346
  }
11344
- {
11345
- const session = clientSessions.get(clientId);
11346
- if (session) {
11347
- const now = Date.now();
11348
- if (CHANNEL_MESSAGE_TYPES.has(type)) {
11349
- if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
11350
- session.channelMessageCount = 0;
11351
- session.channelWindowStart = now;
11352
- }
11353
- session.channelMessageCount++;
11354
- if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
11355
- sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
11356
- return;
11357
- }
11358
- } else {
11359
- if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
11360
- session.messageCount = 0;
11361
- session.messageWindowStart = now;
11362
- }
11363
- session.messageCount++;
11364
- if (session.messageCount > WS_RATE_LIMIT) {
11365
- sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
11366
- return;
11367
- }
11368
- }
11369
- }
11370
- }
11371
12347
  if (ADMIN_ONLY_TYPES.has(type)) {
11372
12348
  if (!isAdminSession(clientSessions.get(clientId))) {
11373
12349
  sendError("ERROR", "FORBIDDEN", "Admin access required for this operation");
11374
12350
  return;
11375
12351
  }
11376
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
+ }
11377
12363
  /**
11378
12364
  * Apply the REST layer's write checks to a socket payload.
11379
12365
  *
@@ -11381,13 +12367,98 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11381
12367
  * driver decides what a path means, and refusing here would
11382
12368
  * turn "unknown collection" into a validation error.
11383
12369
  */
11384
- const assertWriteRequest = (path, values) => {
12370
+ const assertWriteRequest = (path, values, status) => {
11385
12371
  if (!path || !values || typeof values !== "object") return;
11386
12372
  const collection = driver.registry?.getCollectionByPath(path);
11387
12373
  if (!collection) return;
11388
- 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
+ });
11389
12378
  assertFieldOpsValid(values, collection);
11390
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
+ };
11391
12462
  const getScopedDelegate = async () => {
11392
12463
  if (typeof driver.withAuth === "function") try {
11393
12464
  const who = sessionAuthContext(clientSessions.get(clientId));
@@ -11413,6 +12484,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11413
12484
  {
11414
12485
  wsDebug("📋 [WebSocket Server] Processing FETCH_COLLECTION request");
11415
12486
  const request = payload;
12487
+ assertReadRequest(request.path, request);
11416
12488
  const rows = await (await getScopedDelegate()).fetchCollection({
11417
12489
  ...request,
11418
12490
  limit: resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch })
@@ -11450,8 +12522,31 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11450
12522
  depth: null,
11451
12523
  colors: true
11452
12524
  }));
11453
- assertWriteRequest(request.path, request.values);
11454
- 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);
11455
12550
  wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, {
11456
12551
  depth: null,
11457
12552
  colors: true
@@ -11470,13 +12565,19 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11470
12565
  wsDebug("🗑️ [WebSocket Server] Processing DELETE_ENTITY request");
11471
12566
  const request = payload;
11472
12567
  wsDebug("🗑️ [WebSocket Server] Deleting row:", request.row);
11473
- 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({
11474
12574
  row: {
11475
12575
  id: request.row.id,
11476
12576
  path: request.row.path
11477
12577
  },
11478
12578
  hard: request.hard
11479
12579
  });
12580
+ await userAdmin?.finalizeUserDeletions?.(uids);
11480
12581
  wsDebug("🗑️ [WebSocket Server] DELETE_ENTITY completed successfully");
11481
12582
  const response = {
11482
12583
  type: "DELETE_SUCCESS",
@@ -11491,6 +12592,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11491
12592
  {
11492
12593
  wsDebug("🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request");
11493
12594
  const { path, name, value, id, collection } = payload;
12595
+ if (typeof name === "string") assertReadRequest(path, { filter: { [name]: ["==", value] } });
11494
12596
  const isUnique = await (await getScopedDelegate()).checkUniqueField(path, name, value, id, collection);
11495
12597
  wsDebug("🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:", isUnique);
11496
12598
  const response = {
@@ -11505,6 +12607,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11505
12607
  case "COUNT":
11506
12608
  {
11507
12609
  const request = payload;
12610
+ assertReadRequest(request.path, request);
11508
12611
  const response = {
11509
12612
  type: "COUNT_SUCCESS",
11510
12613
  payload: { count: await (await getScopedDelegate()).count(request) },
@@ -11522,7 +12625,10 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11522
12625
  sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
11523
12626
  break;
11524
12627
  }
11525
- const result = await admin.executeSql(sql, options);
12628
+ const result = await admin.executeSql(sql, {
12629
+ ...options,
12630
+ isolateSession: true
12631
+ });
11526
12632
  if (process.env.NODE_ENV !== "production") wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
11527
12633
  const auditSession = clientSessions.get(clientId);
11528
12634
  logger.info("[SQL Audit] WebSocket SQL execution", {
@@ -11929,7 +13035,7 @@ function createBackupCron(config) {
11929
13035
  enabled: config.enabled ?? true,
11930
13036
  timeoutSeconds: 3600,
11931
13037
  async handler({ log }) {
11932
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-CaMOS76G.js").then((n) => n.r);
13038
+ const { createDump, discardPartialDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-HQ9GC4tN.js").then((n) => n.r);
11933
13039
  const { destination } = config;
11934
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 }).`);
11935
13041
  log(`Starting backup of "${dbName}"…`);
@@ -11943,8 +13049,8 @@ function createBackupCron(config) {
11943
13049
  log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
11944
13050
  const check = await validateDump(dump.localFile);
11945
13051
  if (!check.ok) {
11946
- if (destination.kind !== "local" && fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
11947
- 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);
11948
13054
  throw new Error(`New backup failed validation — skipping upload and pruning to protect existing backups. ${check.reason}`);
11949
13055
  }
11950
13056
  let storedKey = dump.localFile;
@@ -12519,6 +13625,14 @@ var UserService = class {
12519
13625
  updatedAt: row.updated_at
12520
13626
  }));
12521
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
+ }
12522
13636
  async linkUserIdentity(uid, provider, providerId, profileData) {
12523
13637
  await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
12524
13638
  uid,
@@ -12533,7 +13647,13 @@ var UserService = class {
12533
13647
  const payload = this.mapPayload(data);
12534
13648
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
12535
13649
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
12536
- 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
+ }
12537
13657
  return row ? this.mapRowToUser(row) : null;
12538
13658
  }
12539
13659
  async deleteUser(id) {
@@ -12586,7 +13706,7 @@ var UserService = class {
12586
13706
  };
12587
13707
  }
12588
13708
  /**
12589
- * Update user's password hash
13709
+ * Update user's password hash; `null` removes the password.
12590
13710
  */
12591
13711
  async updatePassword(id, passwordHash) {
12592
13712
  const idCol = getColumn(this.usersTable, "id");
@@ -12756,7 +13876,18 @@ var RefreshTokenService = class RefreshTokenService {
12756
13876
  if (session && this.has("sessionId")) values.sessionId = session.id;
12757
13877
  if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
12758
13878
  if (session?.aal && this.has("aal")) values.aal = session.aal;
12759
- 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
+ });
12760
13891
  }
12761
13892
  /**
12762
13893
  * Build a {@link RefreshTokenInfo} out of a row of {@link selection}.
@@ -12812,17 +13943,31 @@ var RefreshTokenService = class RefreshTokenService {
12812
13943
  }
12813
13944
  await this.db.update(this.refreshTokensTable).set({ rotatedAt: /* @__PURE__ */ new Date() }).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
12814
13945
  }
12815
- /** 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
+ */
12816
13957
  async revokeSession(sessionId) {
12817
13958
  if (!this.has("sessionId")) return;
12818
- if (this.has("revoked")) {
12819
- await this.db.update(this.refreshTokensTable).set({
12820
- revoked: true,
12821
- ...this.has("rotatedAt") ? { rotatedAt: /* @__PURE__ */ new Date() } : {}
12822
- }).where(eq(this.col("sessionId"), sessionId));
12823
- return;
12824
- }
12825
- 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
+ });
12826
13971
  }
12827
13972
  /**
12828
13973
  * Housekeeping: rotation would otherwise leave a row per refresh forever.
@@ -13013,7 +14158,7 @@ var PostgresTokenRepository = class {
13013
14158
  this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
13014
14159
  }
13015
14160
  async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
13016
- await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
14161
+ return this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
13017
14162
  }
13018
14163
  async markRefreshTokenRotated(tokenHash) {
13019
14164
  await this.refreshTokenService.markRotated(tokenHash);
@@ -13102,6 +14247,9 @@ var PostgresAuthRepository = class {
13102
14247
  async linkUserIdentity(uid, provider, providerId, profileData) {
13103
14248
  return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
13104
14249
  }
14250
+ async unlinkUserIdentity(uid, provider, providerId) {
14251
+ await this.userService.unlinkUserIdentity(uid, provider, providerId);
14252
+ }
13105
14253
  async updateUser(id, data) {
13106
14254
  return this.userService.updateUser(id, data);
13107
14255
  }
@@ -13195,7 +14343,7 @@ var PostgresAuthRepository = class {
13195
14343
  }
13196
14344
  async deleteRole(_id) {}
13197
14345
  async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
13198
- await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
14346
+ return this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
13199
14347
  }
13200
14348
  async markRefreshTokenRotated(tokenHash) {
13201
14349
  await this.tokenRepository.markRefreshTokenRotated(tokenHash);
@@ -13450,7 +14598,9 @@ var MfaService = class {
13450
14598
  };
13451
14599
  }
13452
14600
  /**
13453
- * 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.
13454
14604
  *
13455
14605
  * Incremented in the database rather than in the route so that guesses
13456
14606
  * arriving in parallel — the shape any real brute-force takes — cannot
@@ -15066,9 +16216,13 @@ function createPostgresBootstrapper(pgConfig) {
15066
16216
  const provisioningDb = (driverResult) => {
15067
16217
  return (driverResult?.internals)?.db ?? pgConfig.connection;
15068
16218
  };
15069
- const provisioningQueryable = (driverResult) => {
15070
- const db = (driverResult?.internals)?.db ?? pgConfig.connection;
16219
+ const requireProvisioningDb = (driverResult) => {
16220
+ const db = provisioningDb(driverResult);
15071
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);
15072
16226
  return { async query(text) {
15073
16227
  return { rows: sqlRows(await db.execute(sql.raw(text))) };
15074
16228
  } };
@@ -15081,14 +16235,13 @@ function createPostgresBootstrapper(pgConfig) {
15081
16235
  * Idempotent and cheap to repeat: `CREATE SCHEMA IF NOT EXISTS` plus a
15082
16236
  * handful of `CREATE OR REPLACE FUNCTION`.
15083
16237
  */
15084
- const provisionRlsRuntime = async (queryable) => {
15085
- const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-H3rCFi3F.js").then((n) => n.i);
15086
- await queryable.query("SELECT pg_advisory_lock(hashtext('rebase_auth_functions_init'))");
15087
- try {
15088
- for (const statement of RLS_BOOTSTRAP_STATEMENTS) await queryable.query(statement);
15089
- } finally {
15090
- await queryable.query("SELECT pg_advisory_unlock(hashtext('rebase_auth_functions_init'))");
15091
- }
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
+ });
15092
16245
  };
15093
16246
  return {
15094
16247
  type: "postgres",
@@ -15476,7 +16629,7 @@ function createPostgresBootstrapper(pgConfig) {
15476
16629
  * proved it can bootstrap with.
15477
16630
  */
15478
16631
  async ensureCollectionSchema(collections, driverResult, log) {
15479
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CpAgy51F.js").then((n) => n.n);
16632
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-BEEjn5cn.js").then((n) => n.n);
15480
16633
  const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
15481
16634
  for (const failure of plan.failures) logger.warn(describeEnsureFailure(failure));
15482
16635
  return { applied: plan.actions.length - plan.failures.length };
@@ -15507,13 +16660,13 @@ function createPostgresBootstrapper(pgConfig) {
15507
16660
  * as the auth path and the migration preamble, from the same constant.
15508
16661
  */
15509
16662
  async ensureRlsRuntime(driverResult) {
15510
- await provisionRlsRuntime(provisioningQueryable(driverResult));
16663
+ await provisionRlsRuntime(driverResult);
15511
16664
  },
15512
16665
  async ensureCollectionPolicies(collections, driverResult, log) {
15513
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-Dzd-2S81.js");
16666
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-RHUcEp4v.js");
15514
16667
  const queryable = provisioningQueryable(driverResult);
15515
16668
  const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
15516
- if (!rows[0]?.present) await provisionRlsRuntime(queryable);
16669
+ if (!rows[0]?.present) await provisionRlsRuntime(driverResult);
15517
16670
  const outcome = await ensureCollectionPolicies(queryable, collections, log);
15518
16671
  for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
15519
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}`);
@@ -15521,7 +16674,7 @@ function createPostgresBootstrapper(pgConfig) {
15521
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.`);
15522
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.`);
15523
16676
  try {
15524
- const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-H3rCFi3F.js").then((n) => n.i);
16677
+ const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-B8EclDyM.js").then((n) => n.i);
15525
16678
  await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
15526
16679
  info: (m) => logger.info(m),
15527
16680
  warn: (m) => logger.warn(m)
@@ -15599,9 +16752,9 @@ function createPostgresBootstrapper(pgConfig) {
15599
16752
  return driverResult.internals.driver.admin;
15600
16753
  },
15601
16754
  mountRoutes(app, basePath, driverResult) {},
15602
- async initializeWebsockets(server, realtimeService, driver, config, adapter) {
16755
+ async initializeWebsockets(server, realtimeService, driver, config, adapter, limits) {
15603
16756
  const { createPostgresWebSocket } = await Promise.resolve().then(() => websocket_exports);
15604
- createPostgresWebSocket(server, realtimeService, driver, config, adapter);
16757
+ createPostgresWebSocket(server, realtimeService, driver, config, adapter, limits);
15605
16758
  }
15606
16759
  };
15607
16760
  }
@@ -15626,8 +16779,8 @@ function createPostgresAdapter(pgConfig) {
15626
16779
  async initializeHistory(config, driverResult) {
15627
16780
  if (bootstrapper.initializeHistory) return bootstrapper.initializeHistory(config, driverResult);
15628
16781
  },
15629
- initializeWebsockets(server, realtimeService, driver, config, adapter) {
15630
- 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);
15631
16784
  },
15632
16785
  verifyConnection: bootstrapper.verifyConnection ? (driverResult) => bootstrapper.verifyConnection(driverResult) : void 0,
15633
16786
  ensureCollectionSchema: bootstrapper.ensureCollectionSchema ? (collections, driverResult, log) => bootstrapper.ensureCollectionSchema(collections, driverResult, log) : void 0,