@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b

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 (85) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/auth/services.d.ts +19 -0
  3. package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
  4. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  5. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  6. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  7. package/dist/cli-helpers.d.ts +57 -1
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
  12. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
  13. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
  14. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  15. package/dist/index.es.js +1365 -338
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  18. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  19. package/dist/schema/auth-schema.d.ts +102 -0
  20. package/dist/schema/auth-users-columns.d.ts +97 -0
  21. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  22. package/dist/schema/doctor.d.ts +23 -25
  23. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  24. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  25. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  26. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  27. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  28. package/dist/schema/search-column.d.ts +248 -0
  29. package/dist/security/rls-enforcement.d.ts +61 -5
  30. package/dist/services/FetchService.d.ts +34 -7
  31. package/dist/services/RelationService.d.ts +30 -0
  32. package/dist/services/collection-helpers.d.ts +26 -0
  33. package/dist/services/dataService.d.ts +5 -0
  34. package/dist/services/realtimeService.d.ts +131 -21
  35. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  36. package/dist/src-C_wvdMnl.js.map +1 -0
  37. package/dist/utils/drizzle-conditions.d.ts +124 -2
  38. package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
  39. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  40. package/package.json +9 -8
  41. package/src/PostgresBackendDriver.ts +172 -6
  42. package/src/PostgresBootstrapper.ts +55 -6
  43. package/src/auth/ensure-tables.ts +207 -86
  44. package/src/auth/services.ts +69 -5
  45. package/src/cli-helpers.ts +129 -10
  46. package/src/cli.ts +232 -30
  47. package/src/collections/validate-relations.ts +124 -17
  48. package/src/data-transformer.ts +120 -17
  49. package/src/history/ensure-history-table.ts +7 -0
  50. package/src/schema/auth-schema.ts +17 -1
  51. package/src/schema/auth-users-columns.ts +131 -0
  52. package/src/schema/doctor-cli.ts +12 -63
  53. package/src/schema/doctor-policy-checks.ts +105 -0
  54. package/src/schema/doctor.ts +135 -76
  55. package/src/schema/ensure-collection-tables.ts +374 -32
  56. package/src/schema/generate-drizzle-schema-logic.ts +132 -42
  57. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  58. package/src/schema/generate-postgres-ddl.ts +25 -2
  59. package/src/schema/generated-schema-staleness.ts +169 -0
  60. package/src/schema/introspect-db-logic.ts +66 -34
  61. package/src/schema/non-sql-collections.test.ts +131 -0
  62. package/src/schema/rls-bootstrap-sql.ts +288 -0
  63. package/src/schema/search-column.ts +643 -0
  64. package/src/security/anonymous-grants.test.ts +4 -2
  65. package/src/security/rls-enforcement.ts +149 -6
  66. package/src/services/BranchService.ts +5 -0
  67. package/src/services/FetchService.ts +175 -108
  68. package/src/services/PersistService.ts +38 -2
  69. package/src/services/RelationService.ts +110 -67
  70. package/src/services/channel-history.ts +14 -0
  71. package/src/services/channel-presence.ts +13 -0
  72. package/src/services/collection-helpers.ts +54 -1
  73. package/src/services/dataService.ts +5 -0
  74. package/src/services/realtimeService.ts +344 -79
  75. package/src/utils/drizzle-conditions.ts +365 -23
  76. package/src/utils/pg-error-utils.ts +8 -3
  77. package/src/websocket.ts +113 -16
  78. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  79. package/dist/policy-CeA1JcxP.js +0 -105
  80. package/dist/policy-CeA1JcxP.js.map +0 -1
  81. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  82. package/dist/src-DlPBctw_.js.map +0 -1
  83. package/dist/src-DoU9yPqq.js.map +0 -1
  84. package/dist/websocket-B2LsrINK.js.map +0 -1
  85. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
4
4
  import { randomUUID } from "crypto";
5
5
  import { DataService } from "./dataService";
6
6
 
7
- import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
7
+ import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, CollectionConfig, RebaseCallContext, resolveClientListLimit, ListLimitError } from "@rebasepro/types";
8
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  import { sql as drizzleSql } from "drizzle-orm";
10
10
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
@@ -33,6 +33,37 @@ export interface SubscriptionAuthContext {
33
33
  roles: string[];
34
34
  }
35
35
 
36
+ /** What a channel frame is asking to do. */
37
+ export type ChannelAction = "join" | "broadcast" | "presence" | "history";
38
+
39
+ /** Everything an authorizer is told about the frame it is asked to allow. */
40
+ export interface ChannelAuthorizationRequest {
41
+ /** The channel the frame names, exactly as the client wrote it. */
42
+ channel: string;
43
+ action: ChannelAction;
44
+ /** The socket, not the principal — one user may hold several. */
45
+ clientId: string;
46
+ /** The socket's authenticated principal, or the anonymous one. */
47
+ user?: SubscriptionAuthContext;
48
+ }
49
+
50
+ /**
51
+ * The extension point for channel access rules.
52
+ *
53
+ * **This is deliberately not a product API yet.** The rule *language* — a
54
+ * config key, a per-pattern DSL, how it composes with `securityRules` — is an
55
+ * open design question (see `docs/channel-authorization.md`), and
56
+ * inventing one here would be inventing the answer. What exists is the single
57
+ * place every channel frame passes through, so that whatever shape the rules
58
+ * eventually take has exactly one seam to plug into and no arm of the switch
59
+ * can be forgotten.
60
+ *
61
+ * Returning `false` — or throwing — refuses the frame. It is consulted *after*
62
+ * the membership floor below, so an authorizer can only ever narrow access,
63
+ * never widen it.
64
+ */
65
+ export type ChannelAuthorizer = (request: ChannelAuthorizationRequest) => boolean | Promise<boolean>;
66
+
36
67
  interface DataDriverWithData extends DataDriver {
37
68
  data: unknown;
38
69
  }
@@ -41,6 +72,30 @@ type RealTimeListenCollectionProps = ListenCollectionProps & {
41
72
  subscriptionId: string
42
73
  };
43
74
 
75
+ /**
76
+ * The narrowing a collection subscription was created with, kept so that every
77
+ * refetch answers the same query the initial fetch did.
78
+ *
79
+ * Named once because it used to be written out inline in five places, and a
80
+ * field missing from one of them is accepted over the wire and then silently
81
+ * ignored: `offset` was declared on the incoming props and never stored, so a
82
+ * live list on page three served page one, and `logical` was never stored
83
+ * either, so an `or(...)` subscription was pushed every row in the table.
84
+ */
85
+ type StoredCollectionRequest = {
86
+ filter?: Record<string, unknown>;
87
+ logical?: LogicalCondition;
88
+ orderBy?: string;
89
+ order?: "desc" | "asc";
90
+ limit?: number;
91
+ offset?: number;
92
+ startAfter?: Record<string, unknown>;
93
+ databaseId?: string;
94
+ searchString?: string;
95
+ /** Ask each row which declared search field matched — populates `_matches`. */
96
+ searchExplain?: boolean;
97
+ };
98
+
44
99
  type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
45
100
 
46
101
  /**
@@ -50,6 +105,13 @@ type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
50
105
  * Implements the RealtimeProvider interface for database abstraction.
51
106
  */
52
107
  export class RealtimeService extends EventEmitter implements RealtimeProvider {
108
+ /**
109
+ * Declares to the multi-engine router that channel frames can be handled
110
+ * here. Read by `createRoutedRealtimeService`, which otherwise would have to
111
+ * guess — and guessed "the default provider", whichever engine that is.
112
+ */
113
+ public readonly supportsChannels = true;
114
+
53
115
  private clients = new Map<string, WebSocket>();
54
116
 
55
117
  // Broadcast channels: channel name → set of client IDs
@@ -107,6 +169,26 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
107
169
  */
108
170
  private oversizedBroadcastWarned = new Set<string>();
109
171
 
172
+ /**
173
+ * Optional narrowing on top of the membership floor — see
174
+ * {@link ChannelAuthorizer}. Unset by default, which leaves membership as
175
+ * the whole of the rule.
176
+ */
177
+ private channelAuthorizer?: ChannelAuthorizer;
178
+
179
+ /**
180
+ * Whether a notification from another instance has ever arrived.
181
+ *
182
+ * The entity LISTEN handler sees a foreign `sid` on every cross-instance
183
+ * change, which is proof that this deployment runs more than one pod — the
184
+ * one fact needed to tell "the memory bus is fine here" from "broadcast and
185
+ * presence silently reach a fraction of your users".
186
+ */
187
+ private foreignInstanceSeen = false;
188
+
189
+ /** So the multi-pod memory-bus warning is emitted once, not once per join. */
190
+ private memoryBusWarned = false;
191
+
110
192
  private presenceInterval?: ReturnType<typeof setInterval>;
111
193
  private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
112
194
  /** How often stale roster rows from other instances are reaped. */
@@ -119,16 +201,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
119
201
  path: string;
120
202
  id?: string | number;
121
203
  // Store full collection request parameters for proper refetching
122
- collectionRequest?: {
123
- filter?: Record<string, unknown>;
124
- orderBy?: string;
125
- order?: "desc" | "asc";
126
- limit?: number;
127
- offset?: number;
128
- startAfter?: Record<string, unknown>;
129
- databaseId?: string;
130
- searchString?: string;
131
- };
204
+ collectionRequest?: StoredCollectionRequest;
132
205
  // Auth context for RLS — when set, refetches run in a transaction
133
206
  // with set_config('app.uid', ...) / set_config('app.user_roles', ...)
134
207
  authContext?: SubscriptionAuthContext;
@@ -212,16 +285,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
212
285
  type: "collection" | "single";
213
286
  path: string;
214
287
  id?: string | number;
215
- collectionRequest?: {
216
- filter?: Record<string, unknown>;
217
- orderBy?: string;
218
- order?: "desc" | "asc";
219
- limit?: number;
220
- offset?: number;
221
- startAfter?: Record<string, unknown>;
222
- databaseId?: string;
223
- searchString?: string;
224
- };
288
+ collectionRequest?: StoredCollectionRequest;
225
289
  authContext?: SubscriptionAuthContext;
226
290
  }) {
227
291
  this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
@@ -262,7 +326,8 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
262
326
  limit: config.limit,
263
327
  startAfter: config.startAfter as Record<string, unknown> | undefined,
264
328
  databaseId: config.databaseId,
265
- searchString: config.searchString
329
+ searchString: config.searchString,
330
+ searchExplain: config.searchExplain
266
331
  }
267
332
  });
268
333
 
@@ -371,45 +436,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
371
436
  await this.handleUnsubscribe(clientId, message.subscriptionId!);
372
437
  break;
373
438
 
374
- // ── Broadcast Channels ──
439
+ // ── Broadcast Channels & Presence ──
440
+ //
441
+ // One arm for all of them, because every one has to pass the same
442
+ // gate and a switch with seven arms is a place to forget it once.
443
+ // See `handleChannelMessage`.
375
444
  case "join_channel":
376
- this.joinChannel(clientId, payload?.channel as string);
377
- break;
378
445
  case "leave_channel":
379
- this.leaveChannel(clientId, payload?.channel as string);
380
- break;
381
446
  case "broadcast":
382
- this.broadcastToChannel(
383
- clientId,
384
- payload?.channel as string,
385
- payload?.event as string,
386
- payload?.payload
387
- );
388
- break;
389
447
  case "channel_history":
390
- await this.handleChannelHistoryRequest(
391
- clientId,
392
- payload?.channel as string,
393
- payload?.sinceSeq as number | undefined,
394
- payload?.limit as number | undefined
395
- );
396
- break;
397
-
398
- // ── Presence ──
399
448
  case "presence_track":
400
- // Auto-join the channel so presence works without a separate join
401
- this.joinChannel(clientId, payload?.channel as string);
402
- this.trackPresence(
403
- clientId,
404
- payload?.channel as string,
405
- payload?.state as Record<string, unknown> ?? {}
406
- );
407
- break;
408
449
  case "presence_untrack":
409
- this.removePresence(clientId, payload?.channel as string);
410
- break;
411
450
  case "presence_state":
412
- this.sendPresenceState(clientId, payload?.channel as string);
451
+ await this.handleChannelMessage(clientId, message.type, payload, authContext);
413
452
  break;
414
453
 
415
454
  default:
@@ -431,15 +470,42 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
431
470
  return;
432
471
  }
433
472
 
473
+ // A vector search cannot be served here, and the parameter used to
474
+ // be read for one thing only — the limit default below — and then
475
+ // dropped: the stored request carries no `vectorSearch` and the
476
+ // refetch has no branch for one. So `.vectorSearch(…).listen()`
477
+ // delivered an ordinary `id DESC` listing, with no `_distance` and
478
+ // no error, forever. Refusing says what the silence did not.
479
+ if (request.vectorSearch) {
480
+ const msg =
481
+ "Realtime subscriptions do not support vector search: a subscription is re-run on every " +
482
+ "matching write, and nothing here computes distances. Use `.vectorSearch(...).find()` for " +
483
+ "the query, and subscribe without it if you need live updates.";
484
+ logger.warn(`[RealtimeService] ${msg}`);
485
+ this.sendError(clientId, msg, subscriptionId, "VECTOR_SEARCH_NOT_LIVE");
486
+ return;
487
+ }
488
+
434
489
  // Bound the client-supplied limit with the SAME guarantee the REST
435
- // ingress applies (`resolveClientListLimit`): clamp to the hard max
436
- // and default an absent limit by mode. A subscription is re-fetched
437
- // on every matching write, so an unbounded one is a DoS amplified
438
- // per write — resolve it once and reuse for the stored request and
439
- // the initial fetch.
440
- const boundedLimit = resolveClientListLimit(request.limit, {
441
- vectorSearch: !!request.vectorSearch
442
- });
490
+ // ingress applies (`resolveClientListLimit`): default an absent
491
+ // limit by mode, refuse one above the ceiling. A subscription is
492
+ // re-fetched on every matching write, so an unbounded one is a DoS
493
+ // amplified per write — resolve it once and reuse for the stored
494
+ // request and the initial fetch.
495
+ //
496
+ // Refusing matters more here than on the REST route: a
497
+ // `collection_update` frame carries rows and nothing else — no
498
+ // `total`, no `hasMore` — so a subscriber handed a quietly smaller
499
+ // page has no way at all to learn it is not seeing the collection.
500
+ let boundedLimit: number;
501
+ try {
502
+ boundedLimit = resolveClientListLimit(request.limit);
503
+ } catch (e) {
504
+ if (!(e instanceof ListLimitError)) throw e;
505
+ logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
506
+ this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
507
+ return;
508
+ }
443
509
 
444
510
  // Store subscription with full request parameters and auth context for RLS
445
511
  this._subscriptions.set(subscriptionId, {
@@ -448,27 +514,25 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
448
514
  path: request.path,
449
515
  collectionRequest: {
450
516
  filter: request.filter,
517
+ logical: request.logical,
451
518
  orderBy: request.orderBy,
452
519
  order: request.order,
453
520
  limit: boundedLimit,
521
+ offset: request.offset,
454
522
  startAfter: request.startAfter as Record<string, unknown> | undefined,
455
523
  databaseId: request.collection?.databaseId,
456
- searchString: request.searchString
524
+ searchString: request.searchString,
525
+ searchExplain: request.searchExplain
457
526
  },
458
527
  authContext
459
528
  });
460
529
 
461
- // Send initial data
530
+ // Send initial data. Built from the request the subscription just
531
+ // stored, so the first answer and every refetch after it cannot
532
+ // describe different queries.
462
533
  const rows = await this.fetchCollectionWithAuth(
463
534
  request.path,
464
- {
465
- filter: request.filter,
466
- orderBy: request.orderBy,
467
- order: request.order,
468
- limit: boundedLimit,
469
- startAfter: request.startAfter as Record<string, unknown> | undefined,
470
- searchString: request.searchString
471
- },
535
+ this._subscriptions.get(subscriptionId)!.collectionRequest!,
472
536
  authContext
473
537
  );
474
538
 
@@ -679,7 +743,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
679
743
  private debouncedCollectionRefetch(
680
744
  subscriptionId: string,
681
745
  notifyPath: string,
682
- subscription: { clientId: string; collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext }
746
+ subscription: { clientId: string; collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext }
683
747
  ) {
684
748
  const timerKey = `ws_${subscriptionId}`;
685
749
  const existing = this.refetchTimers.get(timerKey);
@@ -705,7 +769,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
705
769
  private debouncedDriverRefetch(
706
770
  subscriptionId: string,
707
771
  notifyPath: string,
708
- subscription: { collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext },
772
+ subscription: { collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext },
709
773
  callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
710
774
  ) {
711
775
  const timerKey = `drv_${subscriptionId}`;
@@ -731,7 +795,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
731
795
  */
732
796
  private async fetchCollectionWithAuth(
733
797
  notifyPath: string,
734
- collectionRequest: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string },
798
+ collectionRequest: StoredCollectionRequest,
735
799
  authContext?: SubscriptionAuthContext
736
800
  ): Promise<Record<string, unknown>[]> {
737
801
  if (this.driver) {
@@ -740,12 +804,14 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
740
804
  path: notifyPath,
741
805
  collection: collection,
742
806
  filter: collectionRequest.filter as FetchCollectionProps["filter"],
807
+ logical: collectionRequest.logical,
743
808
  orderBy: collectionRequest.orderBy,
744
809
  order: collectionRequest.order,
745
810
  limit: collectionRequest.limit,
746
811
  offset: collectionRequest.offset,
747
812
  startAfter: collectionRequest.startAfter,
748
- searchString: collectionRequest.searchString
813
+ searchString: collectionRequest.searchString,
814
+ searchExplain: collectionRequest.searchExplain
749
815
  });
750
816
 
751
817
  // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
@@ -763,15 +829,21 @@ roles: ["anon"] };
763
829
  collectionRequest.searchString,
764
830
  {
765
831
  filter: collectionRequest.filter as FilterValues<string>,
832
+ // The subscription stored a group; the search branch
833
+ // did not pass it on, so a filtered live search
834
+ // widened to every row matching the text.
835
+ logical: collectionRequest.logical,
766
836
  orderBy: collectionRequest.orderBy,
767
837
  order: collectionRequest.order,
768
838
  limit: collectionRequest.limit,
769
- databaseId: collectionRequest.databaseId
839
+ databaseId: collectionRequest.databaseId,
840
+ searchExplain: collectionRequest.searchExplain
770
841
  }
771
842
  );
772
843
  } else {
773
844
  fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
774
845
  filter: collectionRequest.filter as FilterValues<string>,
846
+ logical: collectionRequest.logical,
775
847
  orderBy: collectionRequest.orderBy,
776
848
  order: collectionRequest.order,
777
849
  limit: collectionRequest.limit,
@@ -836,22 +908,28 @@ roles: activeAuth.roles },
836
908
  });
837
909
  }
838
910
 
839
- // No driver — use dataService directly (no auth wrapping possible)
911
+ // No driver — use dataService directly (no auth wrapping possible).
912
+ // The `logical` group is carried here as well: this branch answers the
913
+ // same subscription as the one above, and a fallback that drops a
914
+ // condition returns *more* rows than the path it stands in for.
840
915
  if (collectionRequest.searchString) {
841
916
  return await this.dataService.searchRows(
842
917
  notifyPath,
843
918
  collectionRequest.searchString,
844
919
  {
845
920
  filter: collectionRequest.filter as FilterValues<string>,
921
+ logical: collectionRequest.logical,
846
922
  orderBy: collectionRequest.orderBy,
847
923
  order: collectionRequest.order,
848
924
  limit: collectionRequest.limit,
849
- databaseId: collectionRequest.databaseId
925
+ databaseId: collectionRequest.databaseId,
926
+ searchExplain: collectionRequest.searchExplain
850
927
  }
851
928
  );
852
929
  }
853
930
  return await this.dataService.fetchCollection(notifyPath, {
854
931
  filter: collectionRequest.filter as FilterValues<string>,
932
+ logical: collectionRequest.logical,
855
933
  orderBy: collectionRequest.orderBy,
856
934
  order: collectionRequest.order,
857
935
  limit: collectionRequest.limit,
@@ -1101,15 +1179,197 @@ roles: activeAuth.roles },
1101
1179
  // Broadcast Channels
1102
1180
  // =============================================================================
1103
1181
 
1182
+ /**
1183
+ * Install a channel authorizer — see {@link ChannelAuthorizer}.
1184
+ *
1185
+ * Nothing in the framework calls this yet: it is the seam a rules API will
1186
+ * be built on, kept deliberately separate from the membership floor so the
1187
+ * floor holds whether or not anyone uses it.
1188
+ */
1189
+ setChannelAuthorizer(authorizer: ChannelAuthorizer | undefined): void {
1190
+ this.channelAuthorizer = authorizer;
1191
+ }
1192
+
1193
+ /** Which action each channel frame is asking to perform. */
1194
+ private static readonly CHANNEL_ACTIONS: Record<string, ChannelAction> = {
1195
+ join_channel: "join",
1196
+ broadcast: "broadcast",
1197
+ channel_history: "history",
1198
+ presence_track: "join",
1199
+ presence_state: "presence"
1200
+ };
1201
+
1202
+ /**
1203
+ * The one door every channel frame comes through.
1204
+ *
1205
+ * Returns synchronously — and so dispatches synchronously — unless an
1206
+ * authorizer is installed. That matters: a client sends `join_channel`,
1207
+ * `presence_state` and `channel_history` back to back on connect, and the
1208
+ * socket's message handler processes each frame up to its first `await`,
1209
+ * so a gate that always yielded would let the reads overtake the join that
1210
+ * is about to authorize them.
1211
+ */
1212
+ private handleChannelMessage(
1213
+ clientId: string,
1214
+ type: string,
1215
+ payload: Record<string, unknown> | undefined,
1216
+ authContext?: SubscriptionAuthContext
1217
+ ): void | Promise<void> {
1218
+ const channel = payload?.channel as string;
1219
+
1220
+ // Leaving and untracking only ever remove the caller's own state, so
1221
+ // they need no permission — refusing them could only strand a client.
1222
+ if (type === "leave_channel") {
1223
+ this.leaveChannel(clientId, channel);
1224
+ return;
1225
+ }
1226
+ if (type === "presence_untrack") {
1227
+ this.removePresence(clientId, channel);
1228
+ return;
1229
+ }
1230
+
1231
+ const action = RealtimeService.CHANNEL_ACTIONS[type];
1232
+ const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
1233
+ if (allowed === false) return;
1234
+ if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
1235
+ return allowed.then((ok) => {
1236
+ if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
1237
+ });
1238
+ }
1239
+
1240
+ /** Perform an already-authorized channel frame. */
1241
+ private dispatchChannelMessage(
1242
+ clientId: string,
1243
+ type: string,
1244
+ channel: string,
1245
+ payload: Record<string, unknown> | undefined
1246
+ ): void | Promise<void> {
1247
+ switch (type) {
1248
+ case "join_channel":
1249
+ this.joinChannel(clientId, channel);
1250
+ return;
1251
+ case "broadcast":
1252
+ this.broadcastToChannel(clientId, channel, payload?.event as string, payload?.payload);
1253
+ return;
1254
+ case "channel_history":
1255
+ return this.handleChannelHistoryRequest(
1256
+ clientId,
1257
+ channel,
1258
+ payload?.sinceSeq as number | undefined,
1259
+ payload?.limit as number | undefined
1260
+ );
1261
+ case "presence_track":
1262
+ // Auto-join the channel so presence works without a separate join
1263
+ this.joinChannel(clientId, channel);
1264
+ this.trackPresence(clientId, channel, payload?.state as Record<string, unknown> ?? {});
1265
+ return;
1266
+ case "presence_state":
1267
+ this.sendPresenceState(clientId, channel);
1268
+ return;
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * Decide whether a client may perform an action on a channel.
1274
+ *
1275
+ * **Membership is the floor.** Reading a channel's presence roster, replaying
1276
+ * its retained history and broadcasting into it all require that this client
1277
+ * has joined it. That is a low bar — joining is open to anyone who can name
1278
+ * the channel — but it is not the bar that was there before, which was none
1279
+ * at all: `channel_history` and `presence_state` answered any socket about
1280
+ * any channel, and a broadcast fanned out to members the sender had never
1281
+ * joined. Two internal tables (`rebase.channel_presence`,
1282
+ * `rebase.channel_messages`) are held outside RLS on the strength of this
1283
+ * check, so it fails closed: an authorizer that throws refuses the frame.
1284
+ *
1285
+ * Anything richer than membership belongs in a {@link ChannelAuthorizer};
1286
+ * this method is where it is consulted, and the only place.
1287
+ */
1288
+ private authorizeChannelAction(
1289
+ clientId: string,
1290
+ channel: string,
1291
+ action: ChannelAction,
1292
+ authContext?: SubscriptionAuthContext
1293
+ ): boolean | Promise<boolean> {
1294
+ // Joining is what establishes membership, so it cannot require it.
1295
+ if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
1296
+ this.denyChannelAction(clientId, channel, action, "not a member of the channel");
1297
+ return false;
1298
+ }
1299
+
1300
+ const authorizer = this.channelAuthorizer;
1301
+ if (!authorizer) return true;
1302
+
1303
+ let verdict: boolean | Promise<boolean>;
1304
+ try {
1305
+ verdict = authorizer({ channel, action, clientId, user: authContext });
1306
+ } catch (error) {
1307
+ logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
1308
+ this.denyChannelAction(clientId, channel, action, "channel authorization failed");
1309
+ return false;
1310
+ }
1311
+
1312
+ if (typeof verdict === "boolean") {
1313
+ if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
1314
+ return verdict;
1315
+ }
1316
+
1317
+ return verdict.then(
1318
+ (ok) => {
1319
+ if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
1320
+ return ok;
1321
+ },
1322
+ (error) => {
1323
+ logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
1324
+ this.denyChannelAction(clientId, channel, action, "channel authorization failed");
1325
+ return false;
1326
+ }
1327
+ );
1328
+ }
1329
+
1330
+ /** Tell the client why its channel frame went nowhere, and say so in the log. */
1331
+ private denyChannelAction(clientId: string, channel: string, action: ChannelAction, reason: string): void {
1332
+ this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
1333
+ this.sendError(
1334
+ clientId,
1335
+ `Refused ${action} on channel "${channel}": ${reason}`,
1336
+ undefined,
1337
+ "CHANNEL_FORBIDDEN"
1338
+ );
1339
+ }
1340
+
1104
1341
  /** Join a broadcast channel */
1105
1342
  joinChannel(clientId: string, channel: string): void {
1106
1343
  if (!this.channels.has(channel)) {
1107
1344
  this.channels.set(channel, new Set());
1108
1345
  }
1109
1346
  this.channels.get(channel)!.add(clientId);
1347
+ this.warnIfMemoryBusOnMultiplePods();
1110
1348
  this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
1111
1349
  }
1112
1350
 
1351
+ /**
1352
+ * Say something the first time channels are used on a deployment that is
1353
+ * demonstrably multi-pod while the bus is still the in-memory default.
1354
+ *
1355
+ * Every other warning in this subsystem covers a *configured* bus failing —
1356
+ * the case where the operator already knew a bus mattered. The common
1357
+ * misconfiguration is the opposite one: scaled to two replicas, never
1358
+ * touched `realtime.bus`, and broadcast and presence quietly serve a
1359
+ * fraction of the room. The evidence is already in the process, so use it.
1360
+ */
1361
+ private warnIfMemoryBusOnMultiplePods(): void {
1362
+ if (this.memoryBusWarned) return;
1363
+ if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
1364
+ this.memoryBusWarned = true;
1365
+ logger.warn(
1366
+ "⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another " +
1367
+ "instance have been seen — this deployment runs more than one process. Broadcast and presence " +
1368
+ "reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) " +
1369
+ "to make channels cross-instance."
1370
+ );
1371
+ }
1372
+
1113
1373
  /** Leave a broadcast channel */
1114
1374
  leaveChannel(clientId: string, channel: string): void {
1115
1375
  const members = this.channels.get(channel);
@@ -2065,6 +2325,11 @@ lastSeen: Date.now() });
2065
2325
  // Skip our own notifications — already processed locally
2066
2326
  if (sid === this.instanceId) return;
2067
2327
 
2328
+ // A foreign sid is proof of a second process. Nothing here
2329
+ // needs that fact, but the channel path does — see
2330
+ // `warnIfMemoryBusOnMultiplePods`.
2331
+ this.foreignInstanceSeen = true;
2332
+
2068
2333
  this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
2069
2334
 
2070
2335
  // Refetch the row from the DB so row subscriptions