@rebasepro/server-postgres 0.21.1-canary.g8c5a265 → 0.21.1

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 (27) hide show
  1. package/dist/{BranchService-W3DMfcZZ.js → BranchService-CucnFcSE.js} +2 -2
  2. package/dist/{BranchService-W3DMfcZZ.js.map → BranchService-CucnFcSE.js.map} +1 -1
  3. package/dist/PostgresBackendDriver.d.ts +23 -6
  4. package/dist/{cli-errors-C3g_kHBw.js → cli-errors-Dka89exj.js} +19 -2
  5. package/dist/cli-errors-Dka89exj.js.map +1 -0
  6. package/dist/cli.js +14 -7
  7. package/dist/cli.js.map +1 -1
  8. package/dist/{doctor-ByFWL_ET.js → doctor-Gzzd8wTq.js} +4 -4
  9. package/dist/{doctor-ByFWL_ET.js.map → doctor-Gzzd8wTq.js.map} +1 -1
  10. package/dist/{ensure-collection-policies-CQGHln-y.js → ensure-collection-policies-B4IDFtQ-.js} +2 -2
  11. package/dist/{ensure-collection-policies-CQGHln-y.js.map → ensure-collection-policies-B4IDFtQ-.js.map} +1 -1
  12. package/dist/{ensure-collection-tables-Bkvehxdm.js → ensure-collection-tables-Cr2ye5JC.js} +2 -2
  13. package/dist/{ensure-collection-tables-Bkvehxdm.js.map → ensure-collection-tables-Cr2ye5JC.js.map} +1 -1
  14. package/dist/{generate-drizzle-schema-vSK-VdYT.js → generate-drizzle-schema-D2MDdJt-.js} +2 -2
  15. package/dist/{generate-drizzle-schema-vSK-VdYT.js.map → generate-drizzle-schema-D2MDdJt-.js.map} +1 -1
  16. package/dist/{generate-drizzle-schema-logic-Dfyf_MRu.js → generate-drizzle-schema-logic-DFa9qy9u.js} +4 -2
  17. package/dist/{generate-drizzle-schema-logic-Dfyf_MRu.js.map → generate-drizzle-schema-logic-DFa9qy9u.js.map} +1 -1
  18. package/dist/{generated-schema-staleness-DRQe2BpC.js → generated-schema-staleness-Di9c4ZxN.js} +9 -5
  19. package/dist/{generated-schema-staleness-DRQe2BpC.js.map → generated-schema-staleness-Di9c4ZxN.js.map} +1 -1
  20. package/dist/index.es.js +216 -145
  21. package/dist/index.es.js.map +1 -1
  22. package/dist/schema/doctor-cli.js +2 -2
  23. package/dist/schema/generate-drizzle-schema.js +1 -1
  24. package/dist/schema/generated-schema-staleness.d.ts +22 -0
  25. package/dist/services/realtimeService.d.ts +75 -12
  26. package/package.json +7 -7
  27. package/dist/cli-errors-C3g_kHBw.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -3,20 +3,20 @@ __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
5
  import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-Hgl62S-w.js";
6
- import { d as extractPgError, f as isRoleSwitchingPermissionError, g as sanitizeErrorForClient, h as reachedDatabase, i as diagnoseDbError, l as classifyConnectFailure, m as pgErrorToFriendlyMessage, n as deepestErrorMessage, o as isEconnrefused, p as isRowLevelSecurityDenial, s as parseHostInfo, u as extractCauseMessage } from "./cli-errors-C3g_kHBw.js";
7
- import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-W3DMfcZZ.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";
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
9
  import { l as resolveColumnName } from "./column-plan-helpers-CpILzHJS.js";
10
10
  import { i as isAuthCollection, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
11
11
  import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-CR2xcS7e.js";
12
- import { t as generateSchema } from "./generate-drizzle-schema-logic-Dfyf_MRu.js";
13
- import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-Bkvehxdm.js";
14
- import { t as main } from "./generate-drizzle-schema-vSK-VdYT.js";
12
+ import { t as generateSchema } from "./generate-drizzle-schema-logic-DFa9qy9u.js";
13
+ import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-Cr2ye5JC.js";
14
+ import { t as main } from "./generate-drizzle-schema-D2MDdJt-.js";
15
15
  import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as selectBackupsToPrune, h as validateDump, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as listBackups, m as uploadBackup, n as applyGlobals, o as discardPartialDump, p as restoreDump, s as ensureDatabaseExists, t as BackupToolError, u as preflight, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-BNLwvxuy.js";
16
16
  import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-BnEvEJPr.js";
17
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
18
  import { Client } from "pg";
19
- import { ApiError, assertFieldOpsValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
19
+ import { ApiError, assertFieldOpsValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, declaredErrorAnswer, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
20
20
  import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, not, notInArray, or, relations, sql } from "drizzle-orm";
21
21
  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
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";
@@ -6092,6 +6092,35 @@ function assertActingUserForAutoValues(properties, status, uid, path) {
6092
6092
  });
6093
6093
  }
6094
6094
  }
6095
+ /**
6096
+ * A listener's `onError`, in the shape the realtime service calls it with.
6097
+ *
6098
+ * Registered with the subscription so a failed refetch reaches it. Before, only
6099
+ * the initial fetch did: a refetch after a change failed into a log line, and
6100
+ * the listener kept its last rows as if they were current. The error arrives as
6101
+ * thrown, not masked the way a socket frame is: this is trusted server code.
6102
+ */
6103
+ function fetchErrorListener(onError) {
6104
+ if (!onError) return void 0;
6105
+ return (error) => onError(error instanceof Error ? error : new Error(String(error)));
6106
+ }
6107
+ /**
6108
+ * The principal a user's reads run as, for the transaction a request opens and
6109
+ * for a subscription alike.
6110
+ *
6111
+ * One definition because there were two. The subscription's copy was built by
6112
+ * hand after the fact and left out `isAnonymous`, so a guest's refetches ran as
6113
+ * an account: a policy that excludes guests filtered the first read and not the
6114
+ * ones after a change.
6115
+ */
6116
+ function authContextOf(user) {
6117
+ return {
6118
+ uid: user?.uid || ANONYMOUS_USER_ID,
6119
+ roles: user?.roles ?? [],
6120
+ isAnonymous: user?.isAnonymous === true,
6121
+ ...user?.claims ? { claims: user.claims } : {}
6122
+ };
6123
+ }
6095
6124
  var PostgresBackendDriver = class PostgresBackendDriver {
6096
6125
  db;
6097
6126
  registry;
@@ -6410,46 +6439,31 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6410
6439
  }
6411
6440
  return rows;
6412
6441
  }
6413
- listenCollection({ path, collection, filter, limit, offset, startAfter, orderBy, searchString, order, onUpdate, onError }) {
6442
+ /**
6443
+ * Listen to a collection: the rows now, then again after every change.
6444
+ *
6445
+ * The realtime service runs every read, the first one included, as
6446
+ * `authContext` — which {@link AuthenticatedPostgresBackendDriver} supplies,
6447
+ * and which defaults to this driver's own user (a driver bound to a
6448
+ * request's transaction has one). With neither, the reads run as the
6449
+ * anonymous user, as the refetches always did.
6450
+ */
6451
+ listenCollection({ path, collection, startAfter, onUpdate, onError, vectorSearch: _vectorSearch, page: _page, withDeleted: _withDeleted, ...query }, authContext = this.user ? authContextOf(this.user) : void 0) {
6414
6452
  const subscriptionId = this.generateSubscriptionId();
6415
- const callbackWrapper = (rows) => {
6416
- onUpdate(rows);
6417
- };
6418
- this.realtimeService.registerDataDriverSubscription(subscriptionId, {
6419
- clientId: "driver",
6453
+ this.realtimeService.startDataDriverSubscription(subscriptionId, {
6420
6454
  type: "collection",
6421
6455
  path,
6422
6456
  collectionRequest: {
6423
- filter,
6424
- orderBy,
6425
- order,
6426
- limit,
6427
- offset,
6457
+ ...query,
6428
6458
  startAfter,
6429
- databaseId: collection?.databaseId,
6430
- searchString
6431
- }
6432
- });
6433
- this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper);
6434
- this.fetchCollection({
6435
- path,
6436
- collection,
6437
- filter,
6438
- limit,
6439
- offset,
6440
- startAfter,
6441
- orderBy,
6442
- searchString,
6443
- order
6444
- }).then((rows) => {
6445
- callbackWrapper(rows);
6446
- }).catch((error) => {
6447
- if (onError) onError(error);
6459
+ databaseId: collection?.databaseId
6460
+ },
6461
+ authContext,
6462
+ onError: fetchErrorListener(onError)
6463
+ }, (rows) => {
6464
+ if (Array.isArray(rows)) onUpdate(rows);
6448
6465
  });
6449
- return () => {
6450
- this.realtimeService.removeSubscriptionCallback(subscriptionId);
6451
- this.realtimeService.subscriptions.delete(subscriptionId);
6452
- };
6466
+ return () => this.realtimeService.unsubscribe(subscriptionId);
6453
6467
  }
6454
6468
  async fetchOne({ path, id, databaseId, collection, withDeleted }) {
6455
6469
  let row = await this.dataService.fetchOne(path, id, databaseId || collection?.databaseId, withDeleted);
@@ -6478,31 +6492,23 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6478
6492
  }
6479
6493
  return row;
6480
6494
  }
6481
- listenOne({ path, id, collection, onUpdate, onError }) {
6495
+ /**
6496
+ * Listen to one row: the row now, then again after every change, and
6497
+ * `null` when it is not there — deleted, never created, or not readable by
6498
+ * the subscriber. Reads as {@link listenCollection} does.
6499
+ */
6500
+ listenOne({ path, id, onUpdate, onError }, authContext = this.user ? authContextOf(this.user) : void 0) {
6482
6501
  const subscriptionId = this.generateSubscriptionId();
6483
- const callbackWrapper = (row) => {
6484
- if (row) onUpdate(row);
6485
- };
6486
- this.realtimeService.registerDataDriverSubscription(subscriptionId, {
6487
- clientId: "driver",
6502
+ this.realtimeService.startDataDriverSubscription(subscriptionId, {
6488
6503
  type: "single",
6489
- path,
6490
- id
6491
- });
6492
- this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper);
6493
- this.fetchOne({
6494
6504
  path,
6495
6505
  id,
6496
- collection
6497
- }).then((row) => {
6498
- if (row) onUpdate(row);
6499
- }).catch((error) => {
6500
- if (onError) onError(error);
6506
+ authContext,
6507
+ onError: fetchErrorListener(onError)
6508
+ }, (row) => {
6509
+ if (!Array.isArray(row)) onUpdate(row);
6501
6510
  });
6502
- return () => {
6503
- this.realtimeService.removeSubscriptionCallback(subscriptionId);
6504
- this.realtimeService.subscriptions.delete(subscriptionId);
6505
- };
6511
+ return () => this.realtimeService.unsubscribe(subscriptionId);
6506
6512
  }
6507
6513
  /**
6508
6514
  * How many membership rows a single write will read to answer "which
@@ -7402,19 +7408,9 @@ var AuthenticatedPostgresBackendDriver = class {
7402
7408
  const pendingNotifications = [];
7403
7409
  const writeScope = options?.accessMode === "read only" ? void 0 : new WriteTransactionScope();
7404
7410
  const result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await runInWriteScope(writeScope, () => this.delegate.db.transaction(async (tx) => {
7405
- let uid = this.user?.uid;
7406
- if (!uid) {
7407
- logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
7408
- uid = "anonymous";
7409
- }
7410
- const userRoles = this.user?.roles ?? [];
7411
+ if (!this.user?.uid) logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
7411
7412
  if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
7412
- await applyAuthContext(tx, {
7413
- uid,
7414
- roles: userRoles,
7415
- isAnonymous: this.user?.isAnonymous === true,
7416
- claims: this.user?.claims
7417
- }, this.delegate.rlsUserRole);
7413
+ await applyAuthContext(tx, authContextOf(this.user), this.delegate.rlsUserRole);
7418
7414
  const txEntityService = new DataService(tx, this.delegate.registry);
7419
7415
  const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
7420
7416
  txDelegate.dataService = txEntityService;
@@ -7444,28 +7440,21 @@ var AuthenticatedPostgresBackendDriver = class {
7444
7440
  return this.withTransaction((delegate) => delegate.fetchCollection(props), { accessMode: "read only" });
7445
7441
  }
7446
7442
  /**
7447
- * Injects the authenticated user's context into the most recently
7448
- * registered realtime subscription so RLS-aware polling can apply.
7443
+ * The delegate is the base driver, on the owner connection. The user's
7444
+ * identity goes in with the subscription, so the first read runs as them
7445
+ * like every refetch after it. It used to be stamped onto the last
7446
+ * registered subscription once the delegate had returned — by which time
7447
+ * the first read had been issued unscoped, and a listener was handed rows
7448
+ * its policies deny.
7449
7449
  */
7450
- injectAuthContext(unsubscribe) {
7451
- const authContext = {
7452
- uid: this.user?.uid || "anonymous",
7453
- roles: this.user?.roles ?? [],
7454
- ...this.user?.claims ? { claims: this.user.claims } : {}
7455
- };
7456
- const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
7457
- const lastSub = entries[entries.length - 1]?.[1];
7458
- if (lastSub && lastSub.clientId === "driver") lastSub.authContext = authContext;
7459
- return unsubscribe;
7460
- }
7461
7450
  listenCollection(props) {
7462
- return this.injectAuthContext(this.delegate.listenCollection(props));
7451
+ return this.delegate.listenCollection(props, authContextOf(this.user));
7463
7452
  }
7464
7453
  async fetchOne(props) {
7465
7454
  return this.withTransaction((delegate) => delegate.fetchOne(props), { accessMode: "read only" });
7466
7455
  }
7467
7456
  listenOne(props) {
7468
- return this.injectAuthContext(this.delegate.listenOne(props));
7457
+ return this.delegate.listenOne(props, authContextOf(this.user));
7469
7458
  }
7470
7459
  async save(props) {
7471
7460
  return this.withTransaction((delegate) => delegate.save(props));
@@ -9100,36 +9089,67 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9100
9089
  *
9101
9090
  * The last two are identity, not presence: the map has to still hold *this
9102
9091
  * exact object*, not merely something under this id.
9092
+ *
9093
+ * A failure goes through the same slot, by `mayReportFailure()`: an error
9094
+ * frame from a stale, cancelled or replaced fetch would mark a view that
9095
+ * already shows newer rows as failed, or fail another subscription's view.
9096
+ * It also answers yes to the delivery that already claimed the slot, since
9097
+ * that is the send itself failing (a row that will not serialise) after the
9098
+ * check passed and before anything reached the subscriber.
9103
9099
  */
9104
9100
  beginDelivery(subscriptionId, subscription) {
9105
9101
  const seq = ++subscription.started;
9106
- return () => {
9102
+ const canDeliver = () => {
9107
9103
  if (this._subscriptions.get(subscriptionId) !== subscription) return false;
9108
9104
  if (seq <= subscription.delivered) return false;
9109
9105
  subscription.delivered = seq;
9110
9106
  return true;
9111
9107
  };
9108
+ const mayReportFailure = () => canDeliver() || this._subscriptions.get(subscriptionId) === subscription && subscription.delivered === seq;
9109
+ return Object.assign(canDeliver, { mayReportFailure });
9112
9110
  }
9113
- registerDataDriverSubscription(subscriptionId, subscription) {
9114
- this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
9115
- this._subscriptions.set(subscriptionId, {
9116
- ...subscription,
9111
+ /**
9112
+ * Start an in-process listener: register it, then deliver its first rows.
9113
+ *
9114
+ * The first fetch runs here, as the subscriber and through a delivery slot,
9115
+ * the way every refetch after it does. `PostgresBackendDriver.listen*` used
9116
+ * to run it themselves, outside this service, and got all three wrong: a
9117
+ * listener on `withAuth(user)` read its first rows on the owner connection,
9118
+ * so it was handed rows its policies deny; a slow first fetch landed over a
9119
+ * newer refetch, and after an unsubscribe; and a row listener never heard a
9120
+ * `null`. The subscriber's identity is part of the request because a
9121
+ * subscription registered first and stamped with it afterwards has a first
9122
+ * fetch that ran before the stamp.
9123
+ *
9124
+ * No `authContext` reads as the anonymous user, first fetch and refetches
9125
+ * alike — see {@link fetchCollectionWithAuth}.
9126
+ */
9127
+ startDataDriverSubscription(subscriptionId, request, callback) {
9128
+ this.debugLog("📋 [RealtimeService] Starting DataDriver subscription:", subscriptionId, request.authContext ? "(with auth)" : "(no auth)");
9129
+ const subscription = {
9130
+ clientId: "driver",
9131
+ ...request,
9117
9132
  started: 0,
9118
9133
  delivered: 0
9119
- });
9120
- }
9121
- addSubscriptionCallback(subscriptionId, callback) {
9122
- this.debugLog("📋 [RealtimeService] Adding callback for subscription:", subscriptionId);
9134
+ };
9135
+ this._subscriptions.set(subscriptionId, subscription);
9123
9136
  this.subscriptionCallbacks.set(subscriptionId, callback);
9137
+ this.deliverFirstToDataDriver(subscriptionId, subscription, callback);
9124
9138
  }
9125
- removeSubscriptionCallback(subscriptionId) {
9126
- this.debugLog("📋 [RealtimeService] Removing callback for subscription:", subscriptionId);
9127
- this.subscriptionCallbacks.delete(subscriptionId);
9139
+ async deliverFirstToDataDriver(subscriptionId, subscription, callback) {
9140
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
9141
+ try {
9142
+ const data = subscription.type === "collection" ? await this.fetchCollectionWithAuth(subscription.path, subscription.collectionRequest ?? {}, subscription.authContext) : await this.fetchEntityWithAuth(subscription.path, String(subscription.id), subscription.authContext) ?? null;
9143
+ if (canDeliver()) callback(data);
9144
+ } catch (error) {
9145
+ logger.error(`❌ [RealtimeService] Error in the first fetch for DataDriver subscription ${subscriptionId}`, { error });
9146
+ this.reportDriverFetchFailure(subscriptionId, subscription, canDeliver, error);
9147
+ }
9128
9148
  }
9129
9149
  /**
9130
9150
  * Subscribe to collection changes (RealtimeProvider interface)
9131
9151
  */
9132
- subscribeToCollection(subscriptionId, config, callback) {
9152
+ subscribeToCollection(subscriptionId, config, callback, onError) {
9133
9153
  this._subscriptions.set(subscriptionId, {
9134
9154
  clientId: config.clientId,
9135
9155
  type: "collection",
@@ -9144,6 +9164,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9144
9164
  searchString: config.searchString,
9145
9165
  searchExplain: config.searchExplain
9146
9166
  },
9167
+ onError,
9147
9168
  started: 0,
9148
9169
  delivered: 0
9149
9170
  });
@@ -9152,12 +9173,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9152
9173
  /**
9153
9174
  * Subscribe to single row changes (RealtimeProvider interface)
9154
9175
  */
9155
- subscribeToOne(subscriptionId, config, callback) {
9176
+ subscribeToOne(subscriptionId, config, callback, onError) {
9156
9177
  this._subscriptions.set(subscriptionId, {
9157
9178
  clientId: config.clientId,
9158
9179
  type: "single",
9159
9180
  path: config.path,
9160
9181
  id: config.id,
9182
+ onError,
9161
9183
  started: 0,
9162
9184
  delivered: 0
9163
9185
  });
@@ -9239,6 +9261,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9239
9261
  }
9240
9262
  async handleCollectionSubscription(clientId, request, authContext) {
9241
9263
  const subscriptionId = request.subscriptionId;
9264
+ let canDeliver;
9242
9265
  try {
9243
9266
  if (!this.registry.getCollectionByPath(request.path)) {
9244
9267
  const registered = this.registry.getCollections().map((c) => c.slug).join(", ");
@@ -9295,17 +9318,17 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9295
9318
  delivered: 0
9296
9319
  };
9297
9320
  this._subscriptions.set(subscriptionId, subscription);
9298
- const canDeliver = this.beginDelivery(subscriptionId, subscription);
9321
+ canDeliver = this.beginDelivery(subscriptionId, subscription);
9299
9322
  const rows = await this.fetchCollectionWithAuth(request.path, subscription.collectionRequest, authContext);
9300
9323
  const meta = await this.collectionMetaWithAuth(request.path, subscription.collectionRequest, rows, authContext);
9301
9324
  if (canDeliver()) this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path, meta);
9302
9325
  } catch (error) {
9303
- const sanitized = sanitizeErrorForClient(error, request.path);
9304
- this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
9326
+ this.reportSocketFetchFailure(clientId, subscriptionId, request.path, error, canDeliver);
9305
9327
  }
9306
9328
  }
9307
9329
  async handleEntitySubscription(clientId, request, authContext) {
9308
9330
  const subscriptionId = request.subscriptionId;
9331
+ let canDeliver;
9309
9332
  try {
9310
9333
  if (!this.registry.getCollectionByPath(request.path)) {
9311
9334
  const registered = this.registry.getCollections().map((c) => c.slug).join(", ");
@@ -9324,14 +9347,31 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9324
9347
  delivered: 0
9325
9348
  };
9326
9349
  this._subscriptions.set(subscriptionId, subscription);
9327
- const canDeliver = this.beginDelivery(subscriptionId, subscription);
9350
+ canDeliver = this.beginDelivery(subscriptionId, subscription);
9328
9351
  const row = await this.fetchEntityWithAuth(request.path, String(request.id), authContext);
9329
9352
  if (canDeliver()) this.sendSingleUpdate(clientId, subscriptionId, row || null);
9330
9353
  } catch (error) {
9331
- const sanitized = sanitizeErrorForClient(error, request.path);
9332
- this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
9354
+ this.reportSocketFetchFailure(clientId, subscriptionId, request.path, error, canDeliver);
9333
9355
  }
9334
9356
  }
9357
+ /**
9358
+ * Answer a subscriber's failed fetch with an error frame, through the slot
9359
+ * its rows would have used.
9360
+ *
9361
+ * The error is always logged (`sanitizeErrorForClient` does that), but the
9362
+ * frame goes out only while this fetch holds the newest slot. A failed
9363
+ * straggler used to send one regardless. The client routes errors by
9364
+ * `subscriptionId`, so it called `onError` for a view already showing newer
9365
+ * rows, or for the subscription that replaced this one under the same id.
9366
+ *
9367
+ * With no slot, the subscribe failed before its fetch began. That part runs
9368
+ * synchronously, so nothing else can have answered the request yet.
9369
+ */
9370
+ reportSocketFetchFailure(clientId, subscriptionId, path, error, canDeliver) {
9371
+ const sanitized = sanitizeErrorForClient(error, path);
9372
+ if (canDeliver && !canDeliver.mayReportFailure()) return;
9373
+ this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
9374
+ }
9335
9375
  async handleUnsubscribe(_clientId, subscriptionId) {
9336
9376
  this._subscriptions.delete(subscriptionId);
9337
9377
  this.subscriptionCallbacks.delete(subscriptionId);
@@ -9464,8 +9504,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9464
9504
  const meta = await this.collectionMetaWithAuth(notifyPath, subscription.collectionRequest, rows, subscription.authContext);
9465
9505
  if (canDeliver()) this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath, meta);
9466
9506
  } catch (error) {
9467
- const sanitized = sanitizeErrorForClient(error, notifyPath);
9468
- this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
9507
+ this.reportSocketFetchFailure(subscription.clientId, subscriptionId, notifyPath, error, canDeliver);
9469
9508
  }
9470
9509
  }, RealtimeService.REFETCH_DEBOUNCE_MS));
9471
9510
  }
@@ -9485,10 +9524,30 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9485
9524
  if (canDeliver()) callback(rows);
9486
9525
  } catch (error) {
9487
9526
  logger.error(`❌ [RealtimeService] Error in debounced driver refetch for ${subscriptionId}`, { error });
9527
+ this.reportDriverFetchFailure(subscriptionId, subscription, canDeliver, error);
9488
9528
  }
9489
9529
  }, RealtimeService.REFETCH_DEBOUNCE_MS));
9490
9530
  }
9491
9531
  /**
9532
+ * Tell an in-process listener its refetch failed, through the slot the
9533
+ * rows would have used.
9534
+ *
9535
+ * The two driver-refetch `catch` blocks used to log and stop, so the
9536
+ * listener heard nothing and kept its last rows as if they were current.
9537
+ * The error goes to it as thrown: this is trusted server code, and the
9538
+ * masking on the socket path is for clients. The slot keeps a failed
9539
+ * straggler from reporting over newer rows, and keeps a failure away from
9540
+ * a subscription that was cancelled or replaced while it ran.
9541
+ */
9542
+ reportDriverFetchFailure(subscriptionId, subscription, canDeliver, error) {
9543
+ if (!subscription.onError || !canDeliver()) return;
9544
+ try {
9545
+ subscription.onError(error);
9546
+ } catch (listenerError) {
9547
+ logger.error(`❌ [RealtimeService] onError threw for DataDriver subscription ${subscriptionId}`, { error: listenerError });
9548
+ }
9549
+ }
9550
+ /**
9492
9551
  * Fetch a collection with optional RLS auth context.
9493
9552
  * When authContext is provided, the fetch runs inside a transaction
9494
9553
  * with set_config calls so PostgreSQL RLS policies are enforced.
@@ -9644,8 +9703,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9644
9703
  const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
9645
9704
  if (canDeliver()) this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
9646
9705
  } catch (error) {
9647
- const sanitized = sanitizeErrorForClient(error, notifyPath);
9648
- this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
9706
+ this.reportSocketFetchFailure(subscription.clientId, subscriptionId, notifyPath, error, canDeliver);
9649
9707
  }
9650
9708
  }, RealtimeService.REFETCH_DEBOUNCE_MS));
9651
9709
  }
@@ -9665,6 +9723,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9665
9723
  if (canDeliver()) callback(row || null);
9666
9724
  } catch (error) {
9667
9725
  logger.error(`❌ [RealtimeService] Error in debounced row driver refetch for ${subscriptionId}`, { error });
9726
+ this.reportDriverFetchFailure(subscriptionId, subscription, canDeliver, error);
9668
9727
  }
9669
9728
  }, RealtimeService.REFETCH_DEBOUNCE_MS));
9670
9729
  }
@@ -10787,6 +10846,31 @@ function extractErrorMessage(error) {
10787
10846
  /**
10788
10847
  * Check if the current session belongs to an admin user.
10789
10848
  */
10849
+ /**
10850
+ * Who a socket reads and writes as, for its request frames and its
10851
+ * subscriptions alike.
10852
+ *
10853
+ * One answer because there were two, and they disagreed about guests. The
10854
+ * subscriptions carried the session's `isAnonymous`; the request frames wrote
10855
+ * `false` for every signed-in session, so a guest read and wrote as an account
10856
+ * through `FETCH_*`, `SAVE` and `DELETE` while its subscriptions did not. And a
10857
+ * socket with no session was a guest on the request frames (`true`) but not on
10858
+ * its subscriptions or over REST: an unauthenticated caller is not a guest
10859
+ * session.
10860
+ */
10861
+ function sessionAuthContext(session) {
10862
+ if (!session?.user) return {
10863
+ uid: ANONYMOUS_USER_ID,
10864
+ roles: ["anon"],
10865
+ isAnonymous: false
10866
+ };
10867
+ return {
10868
+ uid: session.user.uid,
10869
+ roles: session.user.roles ?? [],
10870
+ isAnonymous: session.user.isAnonymous === true,
10871
+ ...session.user.claims ? { claims: session.user.claims } : {}
10872
+ };
10873
+ }
10790
10874
  function isAdminSession(session) {
10791
10875
  if (!session?.user) return false;
10792
10876
  if (session.user.isAdmin) return true;
@@ -10872,13 +10956,15 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10872
10956
  if (adapterUser) verifiedUser = {
10873
10957
  uid: adapterUser.uid,
10874
10958
  roles: adapterUser.roles,
10875
- isAdmin: adapterUser.isAdmin
10959
+ isAdmin: adapterUser.isAdmin,
10960
+ isAnonymous: adapterUser.isAnonymous === true
10876
10961
  };
10877
10962
  } catch {}
10878
10963
  else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
10879
10964
  uid: "service",
10880
10965
  roles: ["admin"],
10881
- isAdmin: true
10966
+ isAdmin: true,
10967
+ isAnonymous: false
10882
10968
  };
10883
10969
  else {
10884
10970
  const jwtPayload = await extractUserFromToken(token);
@@ -10966,25 +11052,17 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10966
11052
  assertFieldOpsValid(values, collection);
10967
11053
  };
10968
11054
  const getScopedDelegate = async () => {
10969
- const session = clientSessions.get(clientId);
10970
11055
  if (typeof driver.withAuth === "function") try {
10971
- const userForAuth = session?.user ? {
10972
- uid: session.user.uid,
10973
- displayName: null,
10974
- email: null,
10975
- photoURL: null,
10976
- providerId: "websocket",
10977
- isAnonymous: false,
10978
- roles: session.user.roles ?? [],
10979
- claims: session.user.claims
10980
- } : {
10981
- uid: ANONYMOUS_USER_ID,
11056
+ const who = sessionAuthContext(clientSessions.get(clientId));
11057
+ const userForAuth = {
11058
+ uid: who.uid,
10982
11059
  displayName: null,
10983
11060
  email: null,
10984
11061
  photoURL: null,
10985
11062
  providerId: "websocket",
10986
- isAnonymous: true,
10987
- roles: ["anon"]
11063
+ isAnonymous: who.isAnonymous === true,
11064
+ roles: who.roles,
11065
+ ...who.claims ? { claims: who.claims } : {}
10988
11066
  };
10989
11067
  return await driver.withAuth(userForAuth);
10990
11068
  } catch (e) {
@@ -11287,17 +11365,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11287
11365
  case "presence_state":
11288
11366
  case "channel_history": {
11289
11367
  wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
11290
- const session = clientSessions.get(clientId);
11291
- const authContext = session?.user ? {
11292
- uid: session.user.uid,
11293
- roles: session.user.roles ?? [],
11294
- isAnonymous: session.user.isAnonymous === true,
11295
- claims: session.user.claims
11296
- } : {
11297
- uid: ANONYMOUS_USER_ID,
11298
- roles: ["anon"],
11299
- isAnonymous: false
11300
- };
11368
+ const authContext = sessionAuthContext(clientSessions.get(clientId));
11301
11369
  await realtimeService.handleClientMessage(clientId, {
11302
11370
  type,
11303
11371
  payload,
@@ -11333,15 +11401,18 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11333
11401
  }));
11334
11402
  return;
11335
11403
  }
11336
- if (error instanceof ApiError || error?.name === "ApiError") {
11337
- const apiError = error;
11338
- logger.warn(`[WebSocket Server] Refused a write: ${apiError.message}`);
11404
+ const answer = declaredErrorAnswer(error);
11405
+ if (answer) {
11406
+ const line = `[WebSocket Server] Refused: ${answer.code} ${answer.message}`;
11407
+ if (answer.expected) logger.debug(line);
11408
+ else logger.warn(line);
11339
11409
  ws.send(JSON.stringify({
11340
11410
  type: "ERROR",
11341
11411
  requestId,
11342
11412
  payload: { error: {
11343
- message: apiError.message,
11344
- code: apiError.code
11413
+ message: answer.message,
11414
+ code: answer.code,
11415
+ ...answer.details !== void 0 && { details: answer.details }
11345
11416
  } }
11346
11417
  }));
11347
11418
  return;
@@ -15068,7 +15139,7 @@ function createPostgresBootstrapper(pgConfig) {
15068
15139
  * proved it can bootstrap with.
15069
15140
  */
15070
15141
  async ensureCollectionSchema(collections, driverResult, log) {
15071
- const { ensureCollectionTables } = await import("./ensure-collection-tables-Bkvehxdm.js").then((n) => n.n);
15142
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-Cr2ye5JC.js").then((n) => n.n);
15072
15143
  const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
15073
15144
  for (const failure of plan.failures) logger.warn(describeEnsureFailure(failure));
15074
15145
  return { applied: plan.actions.length - plan.failures.length };
@@ -15102,7 +15173,7 @@ function createPostgresBootstrapper(pgConfig) {
15102
15173
  await provisionRlsRuntime(provisioningQueryable(driverResult));
15103
15174
  },
15104
15175
  async ensureCollectionPolicies(collections, driverResult, log) {
15105
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-CQGHln-y.js");
15176
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-B4IDFtQ-.js");
15106
15177
  const queryable = provisioningQueryable(driverResult);
15107
15178
  const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
15108
15179
  if (!rows[0]?.present) await provisionRlsRuntime(queryable);