@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. 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, LogicalCondition, 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
  }
@@ -61,6 +92,8 @@ type StoredCollectionRequest = {
61
92
  startAfter?: Record<string, unknown>;
62
93
  databaseId?: string;
63
94
  searchString?: string;
95
+ /** Ask each row which declared search field matched — populates `_matches`. */
96
+ searchExplain?: boolean;
64
97
  };
65
98
 
66
99
  type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
@@ -72,6 +105,13 @@ type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
72
105
  * Implements the RealtimeProvider interface for database abstraction.
73
106
  */
74
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
+
75
115
  private clients = new Map<string, WebSocket>();
76
116
 
77
117
  // Broadcast channels: channel name → set of client IDs
@@ -129,6 +169,26 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
129
169
  */
130
170
  private oversizedBroadcastWarned = new Set<string>();
131
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
+
132
192
  private presenceInterval?: ReturnType<typeof setInterval>;
133
193
  private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
134
194
  /** How often stale roster rows from other instances are reaped. */
@@ -266,7 +326,8 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
266
326
  limit: config.limit,
267
327
  startAfter: config.startAfter as Record<string, unknown> | undefined,
268
328
  databaseId: config.databaseId,
269
- searchString: config.searchString
329
+ searchString: config.searchString,
330
+ searchExplain: config.searchExplain
270
331
  }
271
332
  });
272
333
 
@@ -375,45 +436,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
375
436
  await this.handleUnsubscribe(clientId, message.subscriptionId!);
376
437
  break;
377
438
 
378
- // ── 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`.
379
444
  case "join_channel":
380
- this.joinChannel(clientId, payload?.channel as string);
381
- break;
382
445
  case "leave_channel":
383
- this.leaveChannel(clientId, payload?.channel as string);
384
- break;
385
446
  case "broadcast":
386
- this.broadcastToChannel(
387
- clientId,
388
- payload?.channel as string,
389
- payload?.event as string,
390
- payload?.payload
391
- );
392
- break;
393
447
  case "channel_history":
394
- await this.handleChannelHistoryRequest(
395
- clientId,
396
- payload?.channel as string,
397
- payload?.sinceSeq as number | undefined,
398
- payload?.limit as number | undefined
399
- );
400
- break;
401
-
402
- // ── Presence ──
403
448
  case "presence_track":
404
- // Auto-join the channel so presence works without a separate join
405
- this.joinChannel(clientId, payload?.channel as string);
406
- this.trackPresence(
407
- clientId,
408
- payload?.channel as string,
409
- payload?.state as Record<string, unknown> ?? {}
410
- );
411
- break;
412
449
  case "presence_untrack":
413
- this.removePresence(clientId, payload?.channel as string);
414
- break;
415
450
  case "presence_state":
416
- this.sendPresenceState(clientId, payload?.channel as string);
451
+ await this.handleChannelMessage(clientId, message.type, payload, authContext);
417
452
  break;
418
453
 
419
454
  default:
@@ -435,15 +470,42 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
435
470
  return;
436
471
  }
437
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
+
438
489
  // Bound the client-supplied limit with the SAME guarantee the REST
439
- // ingress applies (`resolveClientListLimit`): clamp to the hard max
440
- // and default an absent limit by mode. A subscription is re-fetched
441
- // on every matching write, so an unbounded one is a DoS amplified
442
- // per write — resolve it once and reuse for the stored request and
443
- // the initial fetch.
444
- const boundedLimit = resolveClientListLimit(request.limit, {
445
- vectorSearch: !!request.vectorSearch
446
- });
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
+ }
447
509
 
448
510
  // Store subscription with full request parameters and auth context for RLS
449
511
  this._subscriptions.set(subscriptionId, {
@@ -459,7 +521,8 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
459
521
  offset: request.offset,
460
522
  startAfter: request.startAfter as Record<string, unknown> | undefined,
461
523
  databaseId: request.collection?.databaseId,
462
- searchString: request.searchString
524
+ searchString: request.searchString,
525
+ searchExplain: request.searchExplain
463
526
  },
464
527
  authContext
465
528
  });
@@ -592,7 +655,39 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
592
655
  }
593
656
 
594
657
  /**
595
- * Notify subscriptions for a specific path
658
+ * Notify subscriptions for a specific path.
659
+ *
660
+ * **A subscriber only ever receives rows re-read under its own scope.**
661
+ * `row` is used to decide *that* something changed, never to say *what* —
662
+ * every delivery below goes through a refetch that binds the subscription's
663
+ * own auth context.
664
+ *
665
+ * It used to be conditional. The CDC path already did the right thing: it
666
+ * discards the captured tuple and emits `{_rebase_invalidated: true}`, and
667
+ * that marker selected the refetch branch. But the marker is produced in
668
+ * exactly two places, and the *other* side of each branch here shipped the
669
+ * row it was handed straight to the socket. Two of the three entry paths
670
+ * took that side — every API mutation (`PostgresBackendDriver.save` passes
671
+ * the row it just wrote, read under the **writer's** scope) and the legacy
672
+ * cross-instance LISTEN handler (which re-reads on the owner connection,
673
+ * bypassing RLS altogether). Path matching was the only filter applied: the
674
+ * subscription's own `filter`/`logical` was never evaluated, and any
675
+ * `afterRead` redaction was the writer's rather than the reader's.
676
+ *
677
+ * A single-row subscription was the sharpest case. `subscribe_one` on a row
678
+ * RLS denies is accepted and answered `null`; the next update then pushed
679
+ * the full row with no later correction. The collection variant was merely
680
+ * papered over ~300 ms later by the debounced refetch — after the bytes had
681
+ * already reached the browser.
682
+ *
683
+ * The same defect was found and fixed on the Mongo driver in `065e2b615`
684
+ * (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is
685
+ * the Postgres half, stated as one rule rather than three patched branches.
686
+ *
687
+ * The cost is the instant row-level patch that used to precede the refetch:
688
+ * cross-tab feedback now waits for the debounce. That is the price of not
689
+ * being able to know, without asking the database as this subscriber,
690
+ * whether this subscriber may see the row at all.
596
691
  */
597
692
  private async notifyPathUpdate(notifyPath: string, originalPath: string, id: string, row: Record<string, unknown> | null, _databaseId?: string) {
598
693
  this.debugLog(`📡 [RealtimeService] Notifying path: ${notifyPath} (original: ${originalPath})`);
@@ -627,21 +722,8 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
627
722
  for (const [subscriptionId, subscription] of webSocketSubscriptions) {
628
723
  try {
629
724
  if (subscription.type === "single" && notifyPath === originalPath) {
630
- // Send row update directly (only for exact path matches)
631
- if (row && (row as Record<string, unknown>)?._rebase_invalidated) {
632
- this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
633
- } else {
634
- this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
635
- }
725
+ this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
636
726
  } else if (subscription.type === "collection" && subscription.collectionRequest) {
637
- // Phase 1: Send instant row-level patch (no DB query)
638
- // This gives immediate cross-tab feedback
639
- if (!row || !(row as Record<string, unknown>)?._rebase_invalidated) {
640
- this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
641
- }
642
-
643
- // Phase 2: Schedule a deferred full refetch for correctness
644
- // Handles filter/sort changes and ensures consistency
645
727
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
646
728
  }
647
729
  } catch (error) {
@@ -657,12 +739,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
657
739
  if (!callback) continue;
658
740
 
659
741
  if (subscription.type === "single" && notifyPath === originalPath) {
660
- if (row && (row as Record<string, unknown>)?._rebase_invalidated) {
661
- this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
662
- } else {
663
- // Call the callback directly with the row (only for exact path matches)
664
- callback(row);
665
- }
742
+ this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
666
743
  } else if (subscription.type === "collection" && subscription.collectionRequest) {
667
744
  // Debounce collection refetches for DataDriver subscriptions too
668
745
  this.debouncedDriverRefetch(subscriptionId, notifyPath, subscription, callback);
@@ -747,7 +824,8 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
747
824
  limit: collectionRequest.limit,
748
825
  offset: collectionRequest.offset,
749
826
  startAfter: collectionRequest.startAfter,
750
- searchString: collectionRequest.searchString
827
+ searchString: collectionRequest.searchString,
828
+ searchExplain: collectionRequest.searchExplain
751
829
  });
752
830
 
753
831
  // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
@@ -765,10 +843,15 @@ roles: ["anon"] };
765
843
  collectionRequest.searchString,
766
844
  {
767
845
  filter: collectionRequest.filter as FilterValues<string>,
846
+ // The subscription stored a group; the search branch
847
+ // did not pass it on, so a filtered live search
848
+ // widened to every row matching the text.
849
+ logical: collectionRequest.logical,
768
850
  orderBy: collectionRequest.orderBy,
769
851
  order: collectionRequest.order,
770
852
  limit: collectionRequest.limit,
771
- databaseId: collectionRequest.databaseId
853
+ databaseId: collectionRequest.databaseId,
854
+ searchExplain: collectionRequest.searchExplain
772
855
  }
773
856
  );
774
857
  } else {
@@ -839,22 +922,28 @@ roles: activeAuth.roles },
839
922
  });
840
923
  }
841
924
 
842
- // No driver — use dataService directly (no auth wrapping possible)
925
+ // No driver — use dataService directly (no auth wrapping possible).
926
+ // The `logical` group is carried here as well: this branch answers the
927
+ // same subscription as the one above, and a fallback that drops a
928
+ // condition returns *more* rows than the path it stands in for.
843
929
  if (collectionRequest.searchString) {
844
930
  return await this.dataService.searchRows(
845
931
  notifyPath,
846
932
  collectionRequest.searchString,
847
933
  {
848
934
  filter: collectionRequest.filter as FilterValues<string>,
935
+ logical: collectionRequest.logical,
849
936
  orderBy: collectionRequest.orderBy,
850
937
  order: collectionRequest.order,
851
938
  limit: collectionRequest.limit,
852
- databaseId: collectionRequest.databaseId
939
+ databaseId: collectionRequest.databaseId,
940
+ searchExplain: collectionRequest.searchExplain
853
941
  }
854
942
  );
855
943
  }
856
944
  return await this.dataService.fetchCollection(notifyPath, {
857
945
  filter: collectionRequest.filter as FilterValues<string>,
946
+ logical: collectionRequest.logical,
858
947
  orderBy: collectionRequest.orderBy,
859
948
  order: collectionRequest.order,
860
949
  limit: collectionRequest.limit,
@@ -1023,23 +1112,6 @@ roles: activeAuth.roles },
1023
1112
  * columns and no address. The SDK holds no collection config to derive one
1024
1113
  * from, so this is the only place the mapping can come from.
1025
1114
  */
1026
- private sendCollectionPatch(
1027
- clientId: string,
1028
- subscriptionId: string,
1029
- id: string,
1030
- row: Record<string, unknown> | null,
1031
- notifyPath: string
1032
- ) {
1033
- const message: CollectionPatchMessage = {
1034
- type: "collection_patch",
1035
- subscriptionId,
1036
- id,
1037
- row: row,
1038
- pks: this.primaryKeysForPath(notifyPath)
1039
- };
1040
- this.sendMessage(clientId, message);
1041
- }
1042
-
1043
1115
  /** The key columns of the collection at `path`, if they can be resolved. */
1044
1116
  private primaryKeysForPath(path: string): PrimaryKeyInfo[] | undefined {
1045
1117
  try {
@@ -1104,15 +1176,197 @@ roles: activeAuth.roles },
1104
1176
  // Broadcast Channels
1105
1177
  // =============================================================================
1106
1178
 
1179
+ /**
1180
+ * Install a channel authorizer — see {@link ChannelAuthorizer}.
1181
+ *
1182
+ * Nothing in the framework calls this yet: it is the seam a rules API will
1183
+ * be built on, kept deliberately separate from the membership floor so the
1184
+ * floor holds whether or not anyone uses it.
1185
+ */
1186
+ setChannelAuthorizer(authorizer: ChannelAuthorizer | undefined): void {
1187
+ this.channelAuthorizer = authorizer;
1188
+ }
1189
+
1190
+ /** Which action each channel frame is asking to perform. */
1191
+ private static readonly CHANNEL_ACTIONS: Record<string, ChannelAction> = {
1192
+ join_channel: "join",
1193
+ broadcast: "broadcast",
1194
+ channel_history: "history",
1195
+ presence_track: "join",
1196
+ presence_state: "presence"
1197
+ };
1198
+
1199
+ /**
1200
+ * The one door every channel frame comes through.
1201
+ *
1202
+ * Returns synchronously — and so dispatches synchronously — unless an
1203
+ * authorizer is installed. That matters: a client sends `join_channel`,
1204
+ * `presence_state` and `channel_history` back to back on connect, and the
1205
+ * socket's message handler processes each frame up to its first `await`,
1206
+ * so a gate that always yielded would let the reads overtake the join that
1207
+ * is about to authorize them.
1208
+ */
1209
+ private handleChannelMessage(
1210
+ clientId: string,
1211
+ type: string,
1212
+ payload: Record<string, unknown> | undefined,
1213
+ authContext?: SubscriptionAuthContext
1214
+ ): void | Promise<void> {
1215
+ const channel = payload?.channel as string;
1216
+
1217
+ // Leaving and untracking only ever remove the caller's own state, so
1218
+ // they need no permission — refusing them could only strand a client.
1219
+ if (type === "leave_channel") {
1220
+ this.leaveChannel(clientId, channel);
1221
+ return;
1222
+ }
1223
+ if (type === "presence_untrack") {
1224
+ this.removePresence(clientId, channel);
1225
+ return;
1226
+ }
1227
+
1228
+ const action = RealtimeService.CHANNEL_ACTIONS[type];
1229
+ const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
1230
+ if (allowed === false) return;
1231
+ if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
1232
+ return allowed.then((ok) => {
1233
+ if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
1234
+ });
1235
+ }
1236
+
1237
+ /** Perform an already-authorized channel frame. */
1238
+ private dispatchChannelMessage(
1239
+ clientId: string,
1240
+ type: string,
1241
+ channel: string,
1242
+ payload: Record<string, unknown> | undefined
1243
+ ): void | Promise<void> {
1244
+ switch (type) {
1245
+ case "join_channel":
1246
+ this.joinChannel(clientId, channel);
1247
+ return;
1248
+ case "broadcast":
1249
+ this.broadcastToChannel(clientId, channel, payload?.event as string, payload?.payload);
1250
+ return;
1251
+ case "channel_history":
1252
+ return this.handleChannelHistoryRequest(
1253
+ clientId,
1254
+ channel,
1255
+ payload?.sinceSeq as number | undefined,
1256
+ payload?.limit as number | undefined
1257
+ );
1258
+ case "presence_track":
1259
+ // Auto-join the channel so presence works without a separate join
1260
+ this.joinChannel(clientId, channel);
1261
+ this.trackPresence(clientId, channel, payload?.state as Record<string, unknown> ?? {});
1262
+ return;
1263
+ case "presence_state":
1264
+ this.sendPresenceState(clientId, channel);
1265
+ return;
1266
+ }
1267
+ }
1268
+
1269
+ /**
1270
+ * Decide whether a client may perform an action on a channel.
1271
+ *
1272
+ * **Membership is the floor.** Reading a channel's presence roster, replaying
1273
+ * its retained history and broadcasting into it all require that this client
1274
+ * has joined it. That is a low bar — joining is open to anyone who can name
1275
+ * the channel — but it is not the bar that was there before, which was none
1276
+ * at all: `channel_history` and `presence_state` answered any socket about
1277
+ * any channel, and a broadcast fanned out to members the sender had never
1278
+ * joined. Two internal tables (`rebase.channel_presence`,
1279
+ * `rebase.channel_messages`) are held outside RLS on the strength of this
1280
+ * check, so it fails closed: an authorizer that throws refuses the frame.
1281
+ *
1282
+ * Anything richer than membership belongs in a {@link ChannelAuthorizer};
1283
+ * this method is where it is consulted, and the only place.
1284
+ */
1285
+ private authorizeChannelAction(
1286
+ clientId: string,
1287
+ channel: string,
1288
+ action: ChannelAction,
1289
+ authContext?: SubscriptionAuthContext
1290
+ ): boolean | Promise<boolean> {
1291
+ // Joining is what establishes membership, so it cannot require it.
1292
+ if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
1293
+ this.denyChannelAction(clientId, channel, action, "not a member of the channel");
1294
+ return false;
1295
+ }
1296
+
1297
+ const authorizer = this.channelAuthorizer;
1298
+ if (!authorizer) return true;
1299
+
1300
+ let verdict: boolean | Promise<boolean>;
1301
+ try {
1302
+ verdict = authorizer({ channel, action, clientId, user: authContext });
1303
+ } catch (error) {
1304
+ logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
1305
+ this.denyChannelAction(clientId, channel, action, "channel authorization failed");
1306
+ return false;
1307
+ }
1308
+
1309
+ if (typeof verdict === "boolean") {
1310
+ if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
1311
+ return verdict;
1312
+ }
1313
+
1314
+ return verdict.then(
1315
+ (ok) => {
1316
+ if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
1317
+ return ok;
1318
+ },
1319
+ (error) => {
1320
+ logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
1321
+ this.denyChannelAction(clientId, channel, action, "channel authorization failed");
1322
+ return false;
1323
+ }
1324
+ );
1325
+ }
1326
+
1327
+ /** Tell the client why its channel frame went nowhere, and say so in the log. */
1328
+ private denyChannelAction(clientId: string, channel: string, action: ChannelAction, reason: string): void {
1329
+ this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
1330
+ this.sendError(
1331
+ clientId,
1332
+ `Refused ${action} on channel "${channel}": ${reason}`,
1333
+ undefined,
1334
+ "CHANNEL_FORBIDDEN"
1335
+ );
1336
+ }
1337
+
1107
1338
  /** Join a broadcast channel */
1108
1339
  joinChannel(clientId: string, channel: string): void {
1109
1340
  if (!this.channels.has(channel)) {
1110
1341
  this.channels.set(channel, new Set());
1111
1342
  }
1112
1343
  this.channels.get(channel)!.add(clientId);
1344
+ this.warnIfMemoryBusOnMultiplePods();
1113
1345
  this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
1114
1346
  }
1115
1347
 
1348
+ /**
1349
+ * Say something the first time channels are used on a deployment that is
1350
+ * demonstrably multi-pod while the bus is still the in-memory default.
1351
+ *
1352
+ * Every other warning in this subsystem covers a *configured* bus failing —
1353
+ * the case where the operator already knew a bus mattered. The common
1354
+ * misconfiguration is the opposite one: scaled to two replicas, never
1355
+ * touched `realtime.bus`, and broadcast and presence quietly serve a
1356
+ * fraction of the room. The evidence is already in the process, so use it.
1357
+ */
1358
+ private warnIfMemoryBusOnMultiplePods(): void {
1359
+ if (this.memoryBusWarned) return;
1360
+ if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
1361
+ this.memoryBusWarned = true;
1362
+ logger.warn(
1363
+ "⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another " +
1364
+ "instance have been seen — this deployment runs more than one process. Broadcast and presence " +
1365
+ "reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) " +
1366
+ "to make channels cross-instance."
1367
+ );
1368
+ }
1369
+
1116
1370
  /** Leave a broadcast channel */
1117
1371
  leaveChannel(clientId: string, channel: string): void {
1118
1372
  const members = this.channels.get(channel);
@@ -1817,7 +2071,9 @@ lastSeen: Date.now() });
1817
2071
  throw err;
1818
2072
  }
1819
2073
  this.cdcActive = true;
1820
- logger.info(
2074
+ // The bootstrapper says the same thing one line later, in the
2075
+ // vocabulary of the setting that produced it (REALTIME_CDC).
2076
+ logger.debug(
1821
2077
  `📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events ` +
1822
2078
  `(${this.cdcTableMap.size} mapped table key(s)).`
1823
2079
  );
@@ -2068,6 +2324,11 @@ lastSeen: Date.now() });
2068
2324
  // Skip our own notifications — already processed locally
2069
2325
  if (sid === this.instanceId) return;
2070
2326
 
2327
+ // A foreign sid is proof of a second process. Nothing here
2328
+ // needs that fact, but the channel path does — see
2329
+ // `warnIfMemoryBusOnMultiplePods`.
2330
+ this.foreignInstanceSeen = true;
2331
+
2071
2332
  this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
2072
2333
 
2073
2334
  // Refetch the row from the DB so row subscriptions
@@ -0,0 +1,55 @@
1
+ import { and, sql, SQL } from "drizzle-orm";
2
+ import { PgTable } from "drizzle-orm/pg-core";
3
+ import { ApiError } from "@rebasepro/server";
4
+ import { DrizzleClient } from "../interfaces";
5
+
6
+ /**
7
+ * Explain a write that matched no rows.
8
+ *
9
+ * Row-level security filters UPDATE and DELETE through the policy's USING
10
+ * clause instead of raising: a denied write is reported by Postgres exactly
11
+ * like a successful one that happened to match nothing. Left unchecked, a
12
+ * caller cannot tell "denied" from "done" — the write returns 200/204 and the
13
+ * row is untouched. An agent handed a key with `orders:delete` and no delete
14
+ * policy is the case that makes it concrete: it deletes nothing, forever, and
15
+ * is told it worked every time.
16
+ *
17
+ * Re-reading the target over the *same* RLS-scoped handle separates the two
18
+ * cases. A visible row means the policy rejected the write (403); an invisible
19
+ * one means there is nothing there to write for this caller (404, matching what
20
+ * a GET would say). The re-read is bound by the caller's own policies, so it
21
+ * discloses nothing a plain read wouldn't.
22
+ *
23
+ * Only reached when zero rows matched, so the happy path pays nothing.
24
+ *
25
+ * It lives here, rather than beside its first caller, because every zero-row
26
+ * write has to answer the same question and answer it identically: the rule
27
+ * that a readable-but-unwritable target is a 403 is the contract, and a second
28
+ * copy of it is a second chance to get it wrong.
29
+ *
30
+ * @param handle The RLS-scoped connection the write ran on — not a fresh
31
+ * one, or the re-read would answer for a different caller.
32
+ * @param table The table the write targeted (the junction, for a link).
33
+ * @param conditions The write's own WHERE terms, reused verbatim.
34
+ * @param denied Message for the 403: the target is there and was refused.
35
+ * @param missing Message for the 404: there is nothing there for this caller.
36
+ */
37
+ export async function explainZeroRowWrite(
38
+ handle: DrizzleClient,
39
+ table: PgTable,
40
+ conditions: SQL[],
41
+ denied: string,
42
+ missing: string
43
+ ): Promise<ApiError> {
44
+ const visible = await handle
45
+ .select({ present: sql<number>`1` })
46
+ .from(table)
47
+ .where(and(...conditions))
48
+ .limit(1);
49
+
50
+ if (visible.length > 0) {
51
+ return ApiError.forbidden(denied, "WRITE_DENIED");
52
+ }
53
+
54
+ return ApiError.notFound(missing);
55
+ }