@rebasepro/server-postgres 0.22.0 → 0.24.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 (123) hide show
  1. package/dist/{BranchService-CucnFcSE.js → BranchService-BRt78gfa.js} +35 -18
  2. package/dist/BranchService-BRt78gfa.js.map +1 -0
  3. package/dist/PostgresBackendDriver.d.ts +116 -4
  4. package/dist/auth/services.d.ts +99 -22
  5. package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
  6. package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
  7. package/dist/backup/backup-cli.d.ts +22 -0
  8. package/dist/{backup-cli-DqBakiMO.js → backup-cli-DW5p9_zv.js} +17 -14
  9. package/dist/backup-cli-DW5p9_zv.js.map +1 -0
  10. package/dist/{backup-service-CaMOS76G.js → backup-service-EwcDVG-8.js} +7 -9
  11. package/dist/{backup-service-CaMOS76G.js.map → backup-service-EwcDVG-8.js.map} +1 -1
  12. package/dist/{cli-errors-Dka89exj.js → cli-errors-DsA-K9uP.js} +101 -1
  13. package/dist/cli-errors-DsA-K9uP.js.map +1 -0
  14. package/dist/cli-errors.d.ts +42 -0
  15. package/dist/cli-flags-BglvpjHv.js +138 -0
  16. package/dist/cli-flags-BglvpjHv.js.map +1 -0
  17. package/dist/cli-flags.d.ts +28 -0
  18. package/dist/cli-helpers.d.ts +18 -12
  19. package/dist/cli-scratch-database.d.ts +34 -0
  20. package/dist/cli.js +177 -287
  21. package/dist/cli.js.map +1 -1
  22. package/dist/{column-plan-helpers-CpILzHJS.js → column-plan-helpers-1-LQD0yI.js} +44 -38
  23. package/dist/column-plan-helpers-1-LQD0yI.js.map +1 -0
  24. package/dist/data-transformer.d.ts +0 -8
  25. package/dist/{doctor-CU9IogdL.js → doctor-C-sYWbmt.js} +228 -46
  26. package/dist/doctor-C-sYWbmt.js.map +1 -0
  27. package/dist/{ensure-collection-policies-Dzd-2S81.js → ensure-collection-policies-B1ureSIV.js} +73 -18
  28. package/dist/ensure-collection-policies-B1ureSIV.js.map +1 -0
  29. package/dist/{ensure-collection-tables-CpAgy51F.js → ensure-collection-tables-kkkHk8oo.js} +124 -37
  30. package/dist/ensure-collection-tables-kkkHk8oo.js.map +1 -0
  31. package/dist/{ensure-tables-Cr5B4UmH.js → ensure-tables-BmI_tRxc.js} +10 -3
  32. package/dist/ensure-tables-BmI_tRxc.js.map +1 -0
  33. package/dist/{generate-drizzle-schema-B537GIvz.js → generate-drizzle-schema-93M0lUxK.js} +2 -2
  34. package/dist/{generate-drizzle-schema-B537GIvz.js.map → generate-drizzle-schema-93M0lUxK.js.map} +1 -1
  35. package/dist/{generate-drizzle-schema-logic-BcMl7VSy.js → generate-drizzle-schema-logic-sSFDp6LR.js} +26 -8
  36. package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +1 -0
  37. package/dist/generate-postgres-ddl-logic-BJsLaVNX.js +152 -0
  38. package/dist/generate-postgres-ddl-logic-BJsLaVNX.js.map +1 -0
  39. package/dist/generated-sql.d.ts +28 -0
  40. package/dist/index.es.js +4768 -1117
  41. package/dist/index.es.js.map +1 -1
  42. package/dist/{introspect-db-logic-C6LQdTxj.js → introspect-db-logic-kCETE8TY.js} +532 -39
  43. package/dist/introspect-db-logic-kCETE8TY.js.map +1 -0
  44. package/dist/introspect-db-queries-C_Q5VgQw.js +317 -0
  45. package/dist/introspect-db-queries-C_Q5VgQw.js.map +1 -0
  46. package/dist/{plan-schema-CboAIwLN.js → plan-schema-DU9exq6C.js} +291 -529
  47. package/dist/plan-schema-DU9exq6C.js.map +1 -0
  48. package/dist/{policy-drift-xJfy9xG7.js → policy-drift-B-J2hhm0.js} +3 -3
  49. package/dist/policy-drift-B-J2hhm0.js.map +1 -0
  50. package/dist/{generate-postgres-ddl-logic-D7imhYV8.js → render-ddl-Ds2t_d9V.js} +11 -149
  51. package/dist/render-ddl-Ds2t_d9V.js.map +1 -0
  52. package/dist/{rls-bootstrap-sql-H3rCFi3F.js → rls-bootstrap-sql-_KNnjanK.js} +562 -35
  53. package/dist/rls-bootstrap-sql-_KNnjanK.js.map +1 -0
  54. package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-CfXOJJaW.js} +10 -2
  55. package/dist/rls-enforcement-CfXOJJaW.js.map +1 -0
  56. package/dist/schema/atlas-argv.d.ts +15 -0
  57. package/dist/schema/auth-schema.d.ts +170 -0
  58. package/dist/schema/classify-change.d.ts +29 -1
  59. package/dist/schema/column-plan-helpers.d.ts +51 -20
  60. package/dist/schema/destructive-sql.d.ts +71 -1
  61. package/dist/schema/doctor-cli.js +4 -4
  62. package/dist/schema/doctor.d.ts +34 -1
  63. package/dist/schema/ensure-collection-policies.d.ts +22 -0
  64. package/dist/schema/generate-drizzle-schema.js +1 -1
  65. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  66. package/dist/schema/generate-postgres-ddl.js +1 -1
  67. package/dist/schema/generate-schema-commit.d.ts +12 -0
  68. package/dist/schema/introspect-db-logic.d.ts +31 -0
  69. package/dist/schema/introspect-db-queries.d.ts +1 -1
  70. package/dist/schema/introspect-db-search.d.ts +22 -0
  71. package/dist/schema/introspect-db-storage.d.ts +83 -0
  72. package/dist/schema/introspect-db.js +15 -314
  73. package/dist/schema/introspect-db.js.map +1 -1
  74. package/dist/schema/plan/diff-plan.d.ts +4 -3
  75. package/dist/schema/plan/plan-schema.d.ts +26 -11
  76. package/dist/schema/plan/render-ddl.d.ts +6 -0
  77. package/dist/schema/plan/types.d.ts +43 -12
  78. package/dist/search-column-BM-GV6vH.js +442 -0
  79. package/dist/search-column-BM-GV6vH.js.map +1 -0
  80. package/dist/security/policy-drift.d.ts +1 -1
  81. package/dist/security/rls-enforcement.d.ts +7 -0
  82. package/dist/services/BranchService.d.ts +22 -1
  83. package/dist/services/FetchService.d.ts +102 -32
  84. package/dist/services/PersistService.d.ts +41 -5
  85. package/dist/services/RelationService.d.ts +29 -0
  86. package/dist/services/RelationWriteService.d.ts +6 -0
  87. package/dist/services/cdc/CdcListener.d.ts +15 -5
  88. package/dist/services/cdc/identity-columns.d.ts +19 -0
  89. package/dist/services/cdc/trigger-cdc.d.ts +45 -7
  90. package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
  91. package/dist/services/channel-history.d.ts +17 -1
  92. package/dist/services/collection-helpers.d.ts +21 -0
  93. package/dist/services/dataService.d.ts +3 -20
  94. package/dist/services/field-op-sql.d.ts +71 -0
  95. package/dist/services/junction-writes.d.ts +19 -3
  96. package/dist/services/pg-notify-listener.d.ts +95 -6
  97. package/dist/services/read-field-access.d.ts +19 -0
  98. package/dist/services/realtimeService.d.ts +232 -44
  99. package/dist/services/row-pipeline.d.ts +5 -0
  100. package/dist/services/socket-liveness.d.ts +45 -0
  101. package/dist/services/soft-delete.d.ts +12 -2
  102. package/dist/services/sql-script.d.ts +71 -0
  103. package/dist/services/write-depth.d.ts +16 -0
  104. package/dist/services/write-transaction-scope.d.ts +42 -0
  105. package/dist/utils/drizzle-conditions.d.ts +49 -4
  106. package/dist/utils/sql-redaction.d.ts +21 -0
  107. package/dist/websocket.d.ts +57 -18
  108. package/package.json +9 -9
  109. package/dist/BranchService-CucnFcSE.js.map +0 -1
  110. package/dist/backup-cli-DqBakiMO.js.map +0 -1
  111. package/dist/cli-errors-Dka89exj.js.map +0 -1
  112. package/dist/column-plan-helpers-CpILzHJS.js.map +0 -1
  113. package/dist/doctor-CU9IogdL.js.map +0 -1
  114. package/dist/ensure-collection-policies-Dzd-2S81.js.map +0 -1
  115. package/dist/ensure-collection-tables-CpAgy51F.js.map +0 -1
  116. package/dist/ensure-tables-Cr5B4UmH.js.map +0 -1
  117. package/dist/generate-drizzle-schema-logic-BcMl7VSy.js.map +0 -1
  118. package/dist/generate-postgres-ddl-logic-D7imhYV8.js.map +0 -1
  119. package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
  120. package/dist/plan-schema-CboAIwLN.js.map +0 -1
  121. package/dist/policy-drift-xJfy9xG7.js.map +0 -1
  122. package/dist/rls-bootstrap-sql-H3rCFi3F.js.map +0 -1
  123. package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
@@ -5,7 +5,7 @@ import { NodePgDatabase } from "drizzle-orm/node-postgres";
5
5
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces.js";
6
6
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry.js";
7
7
  import { ChannelBus } from "./channel-bus/index.js";
8
- import type { ChannelRetentionRule } from "@rebasepro/types";
8
+ import type { ChannelRetentionRule, RealtimeListenerHealth } from "@rebasepro/types";
9
9
  /**
10
10
  * Auth context stored per-subscription so real-time refetches respect RLS.
11
11
  * Mirrors the session variables set by PostgresBackendDriver.withAuth().
@@ -133,7 +133,11 @@ export type DataDriverSubscriptionRequest = {
133
133
  * last delivery *started* the last one *delivered*.
134
134
  */
135
135
  type Subscription = {
136
+ /** The id the client named it by — what every frame for it carries. */
137
+ subscriptionId: string;
136
138
  clientId: string;
139
+ /** The {@link SubscriptionGroup} whose refetch answers it. */
140
+ groupKey: string;
137
141
  type: "collection" | "single";
138
142
  path: string;
139
143
  id?: string | number;
@@ -150,6 +154,24 @@ type Subscription = {
150
154
  /** The highest started-sequence that has already reached the subscriber. */
151
155
  delivered: number;
152
156
  };
157
+ /**
158
+ * The default ceiling on subscriptions one socket may hold.
159
+ *
160
+ * Each collection subscription is a refetch on every write to its collection;
161
+ * without a ceiling one socket could open tens of thousands and turn every
162
+ * write anyone made into as many transactions. The SDK shares identical
163
+ * subscriptions on a socket, so a real page holds one per distinct list or
164
+ * record on screen — the admin panel's tables, forms and reference previews
165
+ * stay well under this. See `REALTIME_MAX_SUBSCRIPTIONS_PER_SOCKET`.
166
+ */
167
+ export declare const DEFAULT_MAX_SUBSCRIPTIONS_PER_SOCKET = 1000;
168
+ /**
169
+ * Read a subscription ceiling, refusing anything that is not a positive whole
170
+ * number. A typo here does not fall back to a default: the ceiling is a limit
171
+ * someone chose, and silently running with another one is how it stops
172
+ * meaning anything.
173
+ */
174
+ export declare function parseMaxSubscriptionsPerSocket(value: unknown, source: string): number;
153
175
  /**
154
176
  * PostgreSQL-specific realtime service.
155
177
  * Handles WebSocket connections and subscriptions for real-time row updates.
@@ -188,6 +210,8 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
188
210
  * wait on each other.
189
211
  */
190
212
  private channelSendQueues;
213
+ /** The receiving half of the same ordering: retained bus frames per channel, one at a time. */
214
+ private channelReceiveQueues;
191
215
  /**
192
216
  * Cross-instance transport for channel frames and presence.
193
217
  *
@@ -233,21 +257,46 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
233
257
  /** How often stale roster rows from other instances are reaped. */
234
258
  private static readonly PRESENCE_SWEEP_INTERVAL_MS;
235
259
  private dataService;
260
+ /**
261
+ * Every subscription, by {@link subscriptionKey} — the client's own id
262
+ * qualified by the client that chose it.
263
+ *
264
+ * Keyed by the bare id, this was one namespace for every socket: two raw
265
+ * clients that both counted from `"sub-1"` replaced each other's
266
+ * subscription without a word, and either one's `unsubscribe` ended the
267
+ * other's.
268
+ */
236
269
  private _subscriptions;
270
+ /** In-process listeners' callbacks, by subscription key. */
237
271
  private subscriptionCallbacks;
272
+ /** Each client's subscriptions, by key — for its ceiling and its disconnect. */
273
+ private subscriptionsByClient;
274
+ /** Subscriptions one refetch answers — see {@link SubscriptionGroup}. */
275
+ private groups;
276
+ /**
277
+ * The collection groups on each path, and the single-row groups at each
278
+ * `path` + id: what a change looks up, instead of walking every
279
+ * subscription on the server once per changed row.
280
+ */
281
+ private collectionGroupsByPath;
282
+ private singleGroupsByAddress;
283
+ /**
284
+ * Every path a subscription holds, with the collection it lands on and how
285
+ * many hold it — the candidates `aliasPaths` considers, resolved once.
286
+ */
287
+ private subscribedPaths;
288
+ /**
289
+ * How many subscriptions one socket may hold; set from config at boot. See
290
+ * {@link DEFAULT_MAX_SUBSCRIPTIONS_PER_SOCKET}.
291
+ */
292
+ maxSubscriptionsPerSocket: number;
238
293
  private driver?;
239
294
  /** Unique identifier for this process instance, used to skip own notifications. */
240
295
  private readonly instanceId;
241
- /** Dedicated pg.Client for LISTEN (outside the Drizzle pool). */
242
- private listenClient?;
243
- /** Connection string used for reconnecting the LISTEN client. */
244
- private listenConnectionString?;
296
+ /** The LISTEN connection on {@link PG_NOTIFY_CHANNEL}, outside the Drizzle pool. */
297
+ private crossInstanceListener?;
245
298
  /** Whether cross-instance broadcasting is active. */
246
299
  private broadcasting;
247
- /** Reconnection timer handle. */
248
- private reconnectTimer?;
249
- /** Debounce timers for collection refetches to prevent refetch storms. */
250
- private refetchTimers;
251
300
  /** Debounce window (ms) for coalescing rapid row updates into a single correctness refetch. */
252
301
  private static readonly REFETCH_DEBOUNCE_MS;
253
302
  /** Dedicated LISTEN client for DB-level change events (undefined unless CDC is enabled). */
@@ -267,7 +316,19 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
267
316
  * and always flow through. Keyed → expiry timestamp (ms).
268
317
  */
269
318
  private recentAppEmits;
270
- /** How long an app-emit key suppresses its own CDC echo. Covers NOTIFY round-trip latency. */
319
+ /**
320
+ * How long an app-emit key suppresses its own CDC echo: the refetch
321
+ * debounce, and no longer.
322
+ *
323
+ * The mark cannot tell an echo from another writer's change to the same
324
+ * row, and a save that touches no row of the table (a to-many relation
325
+ * only, an empty payload) is announced with no echo ever coming to consume
326
+ * it. Held for seconds, it swallowed the next change psql, a cron or
327
+ * another instance made to that row. Inside the debounce nothing is lost:
328
+ * the refetch the app emit scheduled has not started yet — its timer fires
329
+ * no earlier than this — and it reads after any commit whose NOTIFY has
330
+ * already arrived. An echo later than that costs one more refetch.
331
+ */
271
332
  private static readonly CDC_DEDUP_WINDOW_MS;
272
333
  constructor(db: NodePgDatabase<any>, registry: PostgresCollectionRegistry);
273
334
  /**
@@ -289,7 +350,50 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
289
350
  */
290
351
  private debugLog;
291
352
  setDataDriver(driver: DataDriver): void;
292
- get subscriptions(): Map<string, Subscription>;
353
+ /**
354
+ * The live subscriptions by the id their client gave them — for tests and
355
+ * diagnostics. Two clients may use one id; this view shows the last.
356
+ */
357
+ get subscriptions(): ReadonlyMap<string, Subscription>;
358
+ /** The internal key of `subscriptionId` as `clientId` named it. */
359
+ private subscriptionKey;
360
+ /**
361
+ * The group a subscription's refetch belongs to. Socket subscriptions that
362
+ * would run the same read as the same principal share one; an in-process
363
+ * listener is always alone — see {@link SubscriptionGroup}.
364
+ */
365
+ private groupKeyFor;
366
+ /**
367
+ * Register (or replace) a subscription under `key`, in its group and in
368
+ * every index that finds it.
369
+ */
370
+ private registerSubscription;
371
+ /** Remove the subscription under `key` from everything that holds it. */
372
+ private dropSubscription;
373
+ /** Take `subscription` out of its group and path count; a group left empty goes, timer and all. */
374
+ private leaveGroup;
375
+ /**
376
+ * Ask for a refetch of `group` once changes have been quiet for the
377
+ * debounce window — for every member, or only for `member`.
378
+ *
379
+ * The window is measured from the last change, as a debounce is, but the
380
+ * timer is not re-armed per change: a 10k-row statement is 10k changes,
381
+ * and re-arming a timer per change per group was work in proportion to
382
+ * both. A change only moves the timestamp; the timer, when it fires early,
383
+ * waits out the rest.
384
+ */
385
+ private scheduleGroupRefetch;
386
+ private armGroupTimer;
387
+ /**
388
+ * One read for the group, delivered to each member it is for.
389
+ *
390
+ * Each member still claims its own delivery slot before the read and
391
+ * checks it after (see {@link beginDelivery}), so a member that left, was
392
+ * replaced or was overtaken while the read ran is skipped exactly as it
393
+ * was when it had a read of its own. A socket frame is serialised once and
394
+ * only its subscription id differs per member.
395
+ */
396
+ private refetchGroup;
293
397
  /**
294
398
  * Claim a delivery slot for a subscription, before doing the work.
295
399
  *
@@ -343,13 +447,27 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
343
447
  */
344
448
  subscribeToOne(subscriptionId: string, config: SingleSubscriptionConfig, callback?: (row: Record<string, unknown> | null) => void, onError?: (error: unknown) => void): void;
345
449
  /**
346
- * Unsubscribe from a subscription (RealtimeProvider interface)
450
+ * Unsubscribe an in-process subscription (RealtimeProvider interface).
451
+ *
452
+ * By id alone, because the in-process callers hold nothing else — so a
453
+ * socket's subscription under the same id is not this one, and is left
454
+ * alone. A socket ends its own through the `unsubscribe` frame.
347
455
  */
348
456
  unsubscribe(subscriptionId: string): void;
349
457
  addClient(clientId: string, ws: WebSocket): void;
350
458
  handleClientMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext): Promise<void>;
351
459
  removeClient(clientId: string): Promise<void>;
352
460
  private handleMessage;
461
+ /**
462
+ * Refuse a new subscription past the socket's ceiling, and say so.
463
+ *
464
+ * Every collection subscription is a refetch on every write to its
465
+ * collection, so a socket allowed to open them without limit could turn
466
+ * each write anyone makes into tens of thousands of transactions. Replacing
467
+ * a subscription the socket already holds — the same id again — is not a
468
+ * new one and always passes. Returns whether the subscription may go ahead.
469
+ */
470
+ private admitSubscription;
353
471
  private handleCollectionSubscription;
354
472
  private handleEntitySubscription;
355
473
  /**
@@ -366,6 +484,29 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
366
484
  * synchronously, so nothing else can have answered the request yet.
367
485
  */
368
486
  private reportSocketFetchFailure;
487
+ /**
488
+ * Re-judge everything a socket holds open as the identity it has now.
489
+ *
490
+ * A socket re-authenticates on every session refresh, and when a role is
491
+ * taken away, a tenant claim removed or a different account signs in, the
492
+ * next token says so. Requests read as the new identity at once; the
493
+ * subscriptions kept refetching as the one they were opened with, so a
494
+ * demoted user went on receiving editor-only or another tenant's rows for
495
+ * as long as the view stayed mounted.
496
+ *
497
+ * Each of this client's subscriptions is replaced by one carrying the new
498
+ * identity — a replacement, so a refetch already running as the old one
499
+ * cannot deliver — and refetched, so the view narrows now rather than at
500
+ * the next write. A subscription whose filter, sort or projection names a
501
+ * field the new roles may not read is ended with the refusal a new
502
+ * subscription would get. Channel memberships are put to the authorizer
503
+ * again, when one is installed, and left when it refuses.
504
+ */
505
+ rescopeClient(clientId: string, authContext: SubscriptionAuthContext): Promise<void>;
506
+ /**
507
+ * End a socket's own subscription. Only its own: the id is the client's,
508
+ * so another socket's subscription under the same id is a different one.
509
+ */
369
510
  private handleUnsubscribe;
370
511
  /**
371
512
  * Enhanced notification method that handles nested relation updates.
@@ -415,15 +556,6 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
415
556
  * whether this subscriber may see the row at all.
416
557
  */
417
558
  private notifyPathUpdate;
418
- /**
419
- * Debounce a collection refetch for a WebSocket subscription.
420
- * Coalesces rapid row mutations into a single database query.
421
- */
422
- private debouncedCollectionRefetch;
423
- /**
424
- * Debounce a collection refetch for a DataDriver callback subscription.
425
- */
426
- private debouncedDriverRefetch;
427
559
  /**
428
560
  * Tell an in-process listener its refetch failed, through the slot the
429
561
  * rows would have used.
@@ -453,19 +585,17 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
453
585
  * the total describes the same set they came from.
454
586
  */
455
587
  private collectionMetaWithAuth;
456
- /**
457
- * Debounce an row refetch for a WebSocket subscription.
458
- */
459
- private debouncedSingleRefetch;
460
- /**
461
- * Debounce an row refetch for a Driver callback subscription.
462
- */
463
- private debouncedSingleDriverRefetch;
464
588
  /**
465
589
  * Fetch a single row with optional RLS auth context.
466
590
  */
467
591
  private fetchEntityWithAuth;
468
592
  private sendCollectionUpdate;
593
+ /**
594
+ * Everything in a `collection_update` frame after its subscription id,
595
+ * serialised once for every member of a group: the same rows, keys and
596
+ * meta as {@link sendCollectionUpdate} sends, in the same order.
597
+ */
598
+ private collectionFrameTail;
469
599
  private sendSingleUpdate;
470
600
  /**
471
601
  * Send a lightweight row-level patch to a collection subscriber.
@@ -493,10 +623,36 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
493
623
  */
494
624
  private sendError;
495
625
  private sendMessage;
626
+ /**
627
+ * Send an already-serialised frame to a client, if it is still connected.
628
+ *
629
+ * Every frame to a socket goes through here, so this is where a client
630
+ * that does not read what it is sent is let go of — see
631
+ * {@link terminateIfBacklogged}. Terminated, it closes like any other
632
+ * socket, and {@link removeClient} drops what it held.
633
+ */
634
+ private sendRaw;
496
635
  /**
497
636
  * Extract parent paths from a nested path like "posts/70/tags"
498
637
  * Returns ["posts", "posts/70"] for the example above
499
638
  */
639
+ /**
640
+ * The other paths a subscriber can address the row written at `path` by.
641
+ *
642
+ * `authors/1/posts` and `posts` are two addresses for the same rows, and
643
+ * subscriptions were matched by the written path's exact string. So a post
644
+ * saved through `authors/1/posts` never reached a subscriber of `posts` or
645
+ * of `posts/43`, and one saved through `posts` never reached a subscriber
646
+ * of `authors/1/posts`, whose list it may just have joined or left.
647
+ *
648
+ * The aliases are the target collection's root path, and every nested path
649
+ * a live subscription holds that lands on the same collection. Each still
650
+ * refetches under its own scope and its own parent, so naming a path here
651
+ * decides only who is asked to look again — never what they are shown.
652
+ */
653
+ private aliasPaths;
654
+ /** The slug of the collection a path lands on, or `undefined` for one that names none. */
655
+ private collectionSlugAt;
500
656
  private getParentPaths;
501
657
  /**
502
658
  * Install a channel authorizer — see {@link ChannelAuthorizer}.
@@ -620,6 +776,7 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
620
776
  * its own `sid`.
621
777
  */
622
778
  private handleBusFrame;
779
+ private deliverBusFrame;
623
780
  /**
624
781
  * Install retention rules and create the tables they need.
625
782
  *
@@ -705,12 +862,20 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
705
862
  * 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
706
863
  * 2. All subscription state and callbacks are cleared.
707
864
  * 3. The dedicated LISTEN client (outside the pool) is disconnected.
708
- * 4. All WebSocket clients are removed (but not forcefully closed — the
709
- * HTTP server close will handle that).
865
+ * 4. All WebSocket clients are closed with 1001 ("going away"); one that
866
+ * does not answer the close frame is dropped after a short grace.
710
867
  */
711
868
  destroy(): Promise<void>;
712
- /** Whether database-level change capture is currently the active source. */
869
+ /**
870
+ * Whether database-level change capture is the source and is listening.
871
+ *
872
+ * Configured is not enough: a CDC connection that went half-open used to
873
+ * leave this `true` while nothing arrived. False while the connection is
874
+ * down and being replaced.
875
+ */
713
876
  isCdcActive(): boolean;
877
+ /** The LISTEN connections this service depends on — see `RealtimeProvider.health`. */
878
+ health(): RealtimeListenerHealth[];
714
879
  /**
715
880
  * Enable database-level change capture as the realtime source.
716
881
  *
@@ -729,6 +894,18 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
729
894
  * (bypass PgBouncer — LISTEN needs a session connection).
730
895
  */
731
896
  enableCdc(connectionString: string): Promise<void>;
897
+ /**
898
+ * Refetch every live subscription, as a change to its path would.
899
+ *
900
+ * Called when a LISTEN connection (CDC, or the cross-instance broadcast)
901
+ * is listening again after a drop. Postgres does not queue NOTIFY for a
902
+ * session that is not listening, so every change committed while it was
903
+ * down — by another instance, psql, a cron — reached nobody here, and a
904
+ * subscriber held its pre-gap rows until some later change to the same
905
+ * collection happened to arrive. Each refetch runs under the
906
+ * subscription's own scope, so this decides only who looks again.
907
+ */
908
+ private resyncSubscriptions;
732
909
  /** Stop the CDC listener and clear its state. */
733
910
  stopCdc(): Promise<void>;
734
911
  /**
@@ -742,8 +919,8 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
742
919
  /**
743
920
  * Route a captured database change into the realtime pipeline.
744
921
  *
745
- * Delivery is RLS-safe by construction: the raw tuple from the WAL/trigger is
746
- * NOT forwarded to subscribers. Instead the change is marked invalidated, so
922
+ * Delivery is RLS-safe by construction: the event carries the changed row's
923
+ * key and nothing else, and even that is NOT forwarded to subscribers. Instead the change is marked invalidated, so
747
924
  * every matching subscription re-reads the row under its own auth context via
748
925
  * {@link fetchCollectionWithAuth} / {@link fetchEntityWithAuth}. A subscriber
749
926
  * therefore only ever receives rows its RLS policies permit — filtering is per
@@ -769,6 +946,16 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
769
946
  private handleJunctionCdcEvent;
770
947
  /** Compute the canonical (possibly composite) id string from a captured row. */
771
948
  private extractIdFromCdcRow;
949
+ /**
950
+ * A captured row with its key under the key's *field* names.
951
+ *
952
+ * The trigger captures the tuple by column — `user_id` — and an address is
953
+ * built from the key's property names — `userId`. Read by property name
954
+ * alone, a key declared apart from its column was never on the captured
955
+ * row, the address fell back to `*`, and no single-row subscriber heard
956
+ * about any write made outside this server.
957
+ */
958
+ private cdcRowByKeyFields;
772
959
  private dedupKey;
773
960
  /** Record that this instance just delivered `key` via the app path. */
774
961
  private markAppEmit;
@@ -776,8 +963,15 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
776
963
  private consumeAppEmit;
777
964
  /**
778
965
  * Enable cross-instance realtime broadcasting via Postgres LISTEN/NOTIFY.
779
- * Creates a dedicated pg.Client (outside the Drizzle pool) that stays
780
- * connected and listens for change notifications from other instances.
966
+ * Listens on a dedicated connection (outside the Drizzle pool) for the
967
+ * changes other instances publish, through a {@link PgNotifyListener}: it
968
+ * proves itself with a heartbeat, is replaced when it drops or stops
969
+ * answering, and every subscription is refetched once it is back, since
970
+ * whatever was published meanwhile reached nobody here.
971
+ *
972
+ * A database that cannot be reached at boot does not fail this call: the
973
+ * listener reports itself down (see {@link health}) and keeps dialling.
974
+ * There is no other cross-instance path to fall back to.
781
975
  *
782
976
  * This is an **optional** feature — if never called, the backend operates
783
977
  * in single-instance mode (the default, perfectly fine for most setups).
@@ -794,14 +988,8 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
794
988
  * Uses the main Drizzle connection (pooled) — NOT the LISTEN client.
795
989
  */
796
990
  private broadcastChange;
797
- /**
798
- * Create and connect the dedicated LISTEN client with auto-reconnect.
799
- */
800
- private connectListenClient;
801
- /**
802
- * Schedule a reconnection attempt with a fixed 3s delay.
803
- */
804
- private scheduleReconnect;
991
+ /** One change another instance published on {@link PG_NOTIFY_CHANNEL}. */
992
+ private handleCrossInstanceNotification;
805
993
  }
806
994
  /**
807
995
  * Alias for RealtimeService for consistent naming with other database implementations.
@@ -59,6 +59,11 @@ export declare function toRestValues(row: Record<string, unknown>, collection: C
59
59
  * an `update` that echoes the row back overwrite the real value with the null it
60
60
  * was handed.
61
61
  *
62
+ * A withheld to-one relation takes its foreign key with it. `bandId: 7` names
63
+ * the salary band exactly as `band: { id: 7 }` does, and the row carries the
64
+ * column beside the relation — so withholding only the relation withheld
65
+ * nothing.
66
+ *
62
67
  * `_matches` is filtered rather than deleted: it is the list of fields a text
63
68
  * search hit, and a field the caller cannot read must not appear in it even
64
69
  * though the array itself is theirs to see.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Letting go of client sockets the server can no longer reach.
3
+ *
4
+ * A phone that loses signal, a laptop that sleeps, a NAT that forgets the
5
+ * connection: the peer is gone, and nothing tells the server. No close frame
6
+ * arrives and no FIN, so the socket stays open until the operating system gives
7
+ * up on the TCP connection — about two hours on Linux. Meanwhile every
8
+ * subscription it holds is refetched on every write to its collection, and
9
+ * every frame for it is serialised into a send buffer nobody drains.
10
+ *
11
+ * Two checks catch it. The socket must answer a ping: one that has not answered
12
+ * by the next is terminated. And it must read what it is sent: one whose unsent
13
+ * backlog passes {@link MAX_SOCKET_BUFFERED_BYTES} is terminated at the next
14
+ * frame for it. A terminated socket closes like any other, so the realtime
15
+ * service drops its subscriptions, channels and presence on its `close`.
16
+ */
17
+ import { WebSocket, type WebSocketServer } from "ws";
18
+ /**
19
+ * How often every socket is pinged. A socket that has not answered one ping by
20
+ * the next is terminated, so a peer that vanished is let go of within two
21
+ * intervals. Every browser and `ws` client answers a ping by itself.
22
+ */
23
+ export declare const SOCKET_PING_INTERVAL_MS = 30000;
24
+ /**
25
+ * The unsent bytes a socket may have queued before it is terminated.
26
+ *
27
+ * A healthy client drains its queue as fast as the network allows; one whose
28
+ * backlog keeps growing is not reading, or is so far behind that what it would
29
+ * read is stale. Set well above a single frame — the largest body the data API
30
+ * accepts is 10 MiB, and a list frame can approach it — so a burst of large
31
+ * frames to a client that is keeping up does not trip it. A terminated client
32
+ * reconnects and resubscribes, which brings it up to date with one frame per
33
+ * subscription instead of the backlog.
34
+ */
35
+ export declare const MAX_SOCKET_BUFFERED_BYTES: number;
36
+ /**
37
+ * Ping every socket of `wss` each `intervalMs`, and terminate the ones that did
38
+ * not answer the previous ping. Returns the function that stops it.
39
+ */
40
+ export declare function reapSilentSockets(wss: WebSocketServer, intervalMs?: number): () => void;
41
+ /**
42
+ * Terminate `ws` if its unsent backlog has passed {@link MAX_SOCKET_BUFFERED_BYTES}.
43
+ * Returns whether it did; the caller then sends nothing.
44
+ */
45
+ export declare function terminateIfBacklogged(ws: WebSocket, clientId: string): boolean;
@@ -1,6 +1,7 @@
1
1
  import { SQL } from "drizzle-orm";
2
2
  import type { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import type { CollectionConfig } from "@rebasepro/types";
4
+ import { ApiError } from "@rebasepro/server";
4
5
  /**
5
6
  * Soft delete, in one place.
6
7
  *
@@ -23,8 +24,6 @@ import type { CollectionConfig } from "@rebasepro/types";
23
24
  * (`assertCollectionConfigs`), where the answer is a config change, rather than
24
25
  * at the first delete, where it would be a 500 for whoever pressed the button.
25
26
  */
26
- /** The default field, when `softDelete: true` names none. */
27
- export declare const DEFAULT_SOFT_DELETE_FIELD = "deletedAt";
28
27
  /** How a caller asks about deleted rows. See `FetchCollectionProps.withDeleted`. */
29
28
  export type WithDeleted = boolean | "only" | undefined;
30
29
  export interface SoftDeleteField {
@@ -71,3 +70,14 @@ export declare function withSoftDelete(conditions: SQL[], collection: Collection
71
70
  * For the call sites that hold one composed condition rather than a list.
72
71
  */
73
72
  export declare function andSoftDelete(where: SQL | undefined, collection: CollectionConfig | undefined, table: PgTable, withDeleted?: WithDeleted): SQL | undefined;
73
+ /**
74
+ * The refusal of an upsert whose key belongs to a row in the trash.
75
+ *
76
+ * The key is taken — the stamped row still holds it — so the write cannot be a
77
+ * create, and an upsert is not the operation that brings a deleted row back:
78
+ * that is the restore, an update setting the field to `null`, which goes
79
+ * through the update gate and is what history records as one. Left to the
80
+ * statement, `INSERT … ON CONFLICT DO UPDATE` wrote the new values into the
81
+ * hidden row and the caller was told "created", with the row still in the trash.
82
+ */
83
+ export declare function rowInTrashError(path: string, key: string, softDelete: SoftDeleteField): ApiError;
@@ -0,0 +1,71 @@
1
+ import type { Pool, PoolClient } from "pg";
2
+ import type { SqlScriptResult } from "@rebasepro/types";
3
+ /**
4
+ * `INSERT 0 1` → `INSERT`, 1. `CREATE TABLE` → `CREATE TABLE`, no count.
5
+ *
6
+ * Only the commands whose tag ends in a count have one; the numbers are
7
+ * stripped from the end of every other tag, of which there are none.
8
+ */
9
+ export declare function parseCommandTag(tag: string | undefined): {
10
+ command?: string;
11
+ rowCount?: number;
12
+ };
13
+ /**
14
+ * What puts a session back after caller SQL: the session user and role, then
15
+ * every setting. Two statements, because the extended protocol refuses a
16
+ * multi-command string. Pinned settings (`search_path` from the connection's
17
+ * startup options) come back as they were pinned.
18
+ */
19
+ export declare const RESET_SESSION_STATEMENTS: readonly ["SET SESSION AUTHORIZATION DEFAULT", "RESET ALL"];
20
+ /**
21
+ * A connection's transaction state, as its last ReadyForQuery reported it:
22
+ * `I` idle, `T` in a transaction, `E` in a failed one. `null` when the client
23
+ * does not say (node-postgres has since 8.16; the package requires 8.22).
24
+ */
25
+ export declare function transactionStatus(connection: object): string | null;
26
+ /**
27
+ * SQL a person ran began a transaction and did not end it.
28
+ *
29
+ * Each run is a session of its own, so a transaction cannot outlive the run
30
+ * that began it: a `BEGIN` alone, then an `UPDATE` in the next run, then a
31
+ * `ROLLBACK` in a third, committed the UPDATE and reported three successes.
32
+ * Refused instead, with the transaction rolled back.
33
+ */
34
+ export declare class SqlTransactionLeftOpenError extends Error {
35
+ readonly code = "SQL_TRANSACTION_LEFT_OPEN";
36
+ constructor();
37
+ }
38
+ export interface OwnSessionOptions {
39
+ /**
40
+ * Put the session in the role the SQL runs as. Called once, before it, at
41
+ * session level — so the role holds for every statement, a `COMMIT` in the
42
+ * middle included. `SET LOCAL ROLE` inside a wrapping transaction did not:
43
+ * the caller's `COMMIT` ended that transaction, and every statement after
44
+ * it ran as the connection owner.
45
+ */
46
+ assumeRole?: (connection: PoolClient) => Promise<void>;
47
+ }
48
+ /**
49
+ * Run `work` — SQL a person wrote — on a connection of its own from `pool`,
50
+ * and put the connection back the way it was handed out.
51
+ *
52
+ * A transaction the SQL left open is rolled back, and the run refused with
53
+ * {@link SqlTransactionLeftOpenError}; a run that failed with one open says
54
+ * that it was rolled back. The session is then reset — role, session
55
+ * authorization, settings — and destroyed instead when it cannot be. Last,
56
+ * `afterReset` reads what it needs on the clean session, as the owner.
57
+ */
58
+ export declare function onOwnSession<T>(pool: Pool, options: OwnSessionOptions, work: (connection: PoolClient) => Promise<T>, afterReset?: (connection: PoolClient, value: T) => Promise<T>): Promise<T>;
59
+ /**
60
+ * Run `sql` on a connection of its own from `pool` and return what the last
61
+ * statement returned, with each column's provenance and what the database
62
+ * said along the way — `there is no transaction in progress` for a ROLLBACK
63
+ * with nothing to roll back, which used to read as success.
64
+ */
65
+ export declare function runSqlScriptOnPool(pool: Pool, sql: string, options?: OwnSessionOptions): Promise<SqlScriptResult>;
66
+ /**
67
+ * The same shape for rows that came back parsed, from a handle that is not a
68
+ * pool (PGlite in process): every value as text, and no column with a source —
69
+ * nothing to say where a value came from, so nothing the console will edit.
70
+ */
71
+ export declare function sqlScriptResultFromRows(rows: Record<string, unknown>[]): SqlScriptResult;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Deep enough for any chain that ends (a hook writing another collection whose
3
+ * hook writes a third is depth 3), shallow enough that a loop stops at once.
4
+ */
5
+ export declare const MAX_WRITE_DEPTH = 16;
6
+ /** One write in progress, and which of its hooks is running. */
7
+ export interface WriteFrame {
8
+ readonly path: string;
9
+ readonly operation: "save" | "delete";
10
+ readonly depth: number;
11
+ readonly parent: WriteFrame | undefined;
12
+ /** The hook running right now — what a nested write was started from. */
13
+ stage: string | undefined;
14
+ }
15
+ /** Run one write as a frame, refusing it when the chain is already too deep. */
16
+ export declare function inWriteFrame<T>(path: string, operation: "save" | "delete", fn: (frame: WriteFrame) => Promise<T>): Promise<T>;
@@ -1,4 +1,5 @@
1
1
  import { type AmbientTransaction } from "@rebasepro/server";
2
+ import type { RebaseCallContext } from "@rebasepro/types";
2
3
  import type { DrizzleClient } from "../interfaces.js";
3
4
  type RunSql = (sqlText: string, params?: unknown[]) => Promise<Record<string, unknown>[]>;
4
5
  /**
@@ -24,6 +25,10 @@ export declare class WriteTransactionScope implements AmbientTransaction {
24
25
  private open;
25
26
  private readonly inflight;
26
27
  private readonly commitHooks;
28
+ /** The first tracked statement that failed, whether or not its caller caught it. */
29
+ private failure?;
30
+ private readonly settledHooks;
31
+ private settledContext?;
27
32
  /** The transaction handle, for statements in this package that are already written against drizzle. */
28
33
  tx?: DrizzleClient;
29
34
  /** Attach the transaction, once it has begun. */
@@ -49,6 +54,43 @@ export declare class WriteTransactionScope implements AmbientTransaction {
49
54
  * connection, as if outside any write.
50
55
  */
51
56
  settle(): Promise<void>;
57
+ /**
58
+ * Refuse to commit a transaction a failed statement has aborted.
59
+ *
60
+ * Once any statement fails, Postgres aborts the whole transaction, and
61
+ * catching the error in JavaScript does not undo that. The COMMIT that
62
+ * follows is answered with the tag `ROLLBACK` and no error, so the driver
63
+ * resolves it: the write reported success — 200, webhooks, realtime,
64
+ * the idempotency record — while nothing was stored. A callback that
65
+ * wrapped a lookup or a job enqueue in `try/catch` was enough.
66
+ *
67
+ * So one statement is asked of the transaction before its commit. On an
68
+ * aborted one it fails with `25P02`, and the write is refused and rolled
69
+ * back instead. The database refusing a `context.data` create, update or
70
+ * delete is not caught by this: each of those statements runs in a
71
+ * savepoint, is undone on its own, and leaves the transaction usable —
72
+ * which is what makes catching one safe. (Deletes ran without one, so a
73
+ * caught refused delete landed here, under a message saying catching it
74
+ * was safe.)
75
+ */
76
+ assertCommittable(): Promise<void>;
77
+ /**
78
+ * What a hook deferred with {@link afterSettled} is handed as `context`:
79
+ * the same caller, on a driver where every call is a transaction of its
80
+ * own. The write's transaction is gone by the time the hook runs, so a
81
+ * context bound to it would be bound to a connection back in the pool.
82
+ */
83
+ setSettledContext(context: RebaseCallContext): void;
84
+ /**
85
+ * Run `fn` once this write's transaction is over, whichever way it went,
86
+ * outside it — for what a failure hook hands off (a job, a queue message,
87
+ * a webhook), which must not ride the transaction the failure rolls back.
88
+ * `false` when this scope has no context to hand it; the caller runs it
89
+ * itself then.
90
+ */
91
+ afterSettled(fn: (context: RebaseCallContext) => Promise<void>): boolean;
92
+ /** The transaction committed or rolled back: run what was waiting for either. */
93
+ settled(): Promise<void>;
52
94
  /**
53
95
  * The transaction is over, whichever way it went. Nothing may reach it now:
54
96
  * its client is back in the pool, and a callback's detached promise still