@rebasepro/server-postgres 0.10.1-canary.6f89f77 → 0.10.1-canary.7801eed
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.
- package/dist/PostgresBootstrapper.d.ts +7 -3
- package/dist/auth/schema-version.d.ts +106 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1853 -4900
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/CdcListener.d.ts +7 -14
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
- package/dist/services/channel-bus/index.d.ts +55 -0
- package/dist/services/channel-history.d.ts +11 -0
- package/dist/services/channel-presence.d.ts +66 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/pg-notify-listener.d.ts +47 -0
- package/dist/services/realtimeService.d.ts +133 -6
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/src-3VmUJ8Xn.js +3994 -0
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/src-D5xBTl32.js +346 -0
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +87 -5
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/index.ts +4 -0
- package/src/schema/doctor.ts +14 -14
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +31 -24
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/CdcListener.ts +27 -91
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/channel-bus/ChannelBus.ts +44 -0
- package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
- package/src/services/channel-bus/index.ts +123 -0
- package/src/services/channel-history.ts +35 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/pg-notify-listener.ts +137 -0
- package/src/services/realtimeService.ts +430 -11
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +268 -330
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
2
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
3
|
+
/**
|
|
4
|
+
* The last hop of a nested collection path, e.g. `authors/1/posts`.
|
|
5
|
+
*
|
|
6
|
+
* The walk that produces this was written out four separate times — in
|
|
7
|
+
* `FetchService.fetchCollectionFromPath`, `FetchService.countEntitiesFromPath`,
|
|
8
|
+
* `PersistService.save` and `CollectionRegistry.getCollectionByPath` — and had
|
|
9
|
+
* drifted, so the read path and the write path did not agree on which relation
|
|
10
|
+
* a path named. It lives here once now.
|
|
11
|
+
*/
|
|
12
|
+
export interface NestedPathHop {
|
|
13
|
+
/** The collection the final relation is declared on (e.g. `authors`). */
|
|
14
|
+
parentCollection: CollectionConfig;
|
|
15
|
+
/** The parent's id as it appeared in the path, unparsed. */
|
|
16
|
+
parentId: string;
|
|
17
|
+
/** The path segment that named the relation (e.g. `posts`). */
|
|
18
|
+
relationKey: string;
|
|
19
|
+
relation: ResolvedRelation;
|
|
20
|
+
/** `relation.target()`, resolved once. */
|
|
21
|
+
targetCollection: CollectionConfig;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* True when `path` addresses rows through a relation rather than a root
|
|
25
|
+
* collection.
|
|
26
|
+
*
|
|
27
|
+
* Any separator at all counts — a root collection slug never contains one — so
|
|
28
|
+
* a malformed path like `collection/id` is a *broken* nested path and gets
|
|
29
|
+
* reported as one by {@link resolveNestedPath}, rather than being looked up as
|
|
30
|
+
* a root collection whose slug happens to contain a slash.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isNestedPath(path: string): boolean;
|
|
33
|
+
export declare function splitPathSegments(path: string): string[];
|
|
34
|
+
/**
|
|
35
|
+
* Walk a nested collection path down to the relation it ends in.
|
|
36
|
+
*
|
|
37
|
+
* Returns `undefined` for a plain root-collection path so callers can keep the
|
|
38
|
+
* root case on its existing code path. Throws when the path is malformed, or
|
|
39
|
+
* when a segment names a relation that does not exist — the same errors the
|
|
40
|
+
* individual walks used to raise, with the available names attached.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveNestedPath(path: string, registry: PostgresCollectionRegistry): NestedPathHop | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* A relation reached through a junction table — many-to-many, or a multi-hop
|
|
45
|
+
* `joinPath`. The target row is shared with other parents, so writing "through"
|
|
46
|
+
* such a path addresses the *link*, not the row.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isJunctionBackedRelation(relation: ResolvedRelation): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Reject a nested write whose final segment is a to-one relation.
|
|
51
|
+
*
|
|
52
|
+
* There is no column on the target row that records a to-one parent — the
|
|
53
|
+
* foreign key lives on the *parent* table. The write path used to fall through
|
|
54
|
+
* to `relation.localKey` here and stamp the parent's own FK column onto the
|
|
55
|
+
* target row, which either raised an opaque "column does not exist" or, when a
|
|
56
|
+
* column of that name happened to exist on the target, silently wrote the wrong
|
|
57
|
+
* one.
|
|
58
|
+
*/
|
|
59
|
+
export declare function assertWritableThrough(hop: NestedPathHop, path: string): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dedicated, self-healing Postgres `LISTEN` connection.
|
|
3
|
+
*
|
|
4
|
+
* Every cross-instance feature in the backend needs the same thing: one
|
|
5
|
+
* connection *outside* the Drizzle pool that stays open, holds a `LISTEN`, and
|
|
6
|
+
* comes back on its own after the database or the network drops it. CDC needed
|
|
7
|
+
* it first; the channel bus needs it too. This is that connection, with the one
|
|
8
|
+
* behaviour that matters to callers preserved: the **first** connect is
|
|
9
|
+
* validated and rethrown, so a caller can fall back to a different strategy,
|
|
10
|
+
* while every later drop is repaired quietly in the background.
|
|
11
|
+
*
|
|
12
|
+
* `LISTEN` is session state, so this connection must not go through a
|
|
13
|
+
* transaction-mode pooler (PgBouncer): give it the direct database URL.
|
|
14
|
+
*/
|
|
15
|
+
export interface PgNotifyListenerOptions {
|
|
16
|
+
/** Direct Postgres connection string (must bypass a transaction-mode pooler). */
|
|
17
|
+
connectionString: string;
|
|
18
|
+
/** NOTIFY channel to LISTEN on. Must be a plain identifier — it is interpolated. */
|
|
19
|
+
channel: string;
|
|
20
|
+
/** Called for every notification payload received. */
|
|
21
|
+
onPayload: (payload: string) => void | Promise<void>;
|
|
22
|
+
/** Prefix for log lines, e.g. `"[CDC]"`. */
|
|
23
|
+
logLabel: string;
|
|
24
|
+
/** Delay before a reconnect attempt. */
|
|
25
|
+
reconnectDelayMs?: number;
|
|
26
|
+
}
|
|
27
|
+
export declare class PgNotifyListener {
|
|
28
|
+
private readonly options;
|
|
29
|
+
private client?;
|
|
30
|
+
private running;
|
|
31
|
+
private reconnectTimer?;
|
|
32
|
+
constructor(options: PgNotifyListenerOptions);
|
|
33
|
+
/** Whether the listener is meant to be connected right now. */
|
|
34
|
+
get active(): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Connect and begin listening. Idempotent.
|
|
37
|
+
*
|
|
38
|
+
* Rejects if the *initial* connection or `LISTEN` fails, leaving the
|
|
39
|
+
* listener stopped — callers use that to degrade deliberately instead of
|
|
40
|
+
* running blind against a channel nothing is delivering.
|
|
41
|
+
*/
|
|
42
|
+
start(): Promise<void>;
|
|
43
|
+
/** Stop listening and release the connection. Idempotent. */
|
|
44
|
+
stop(): Promise<void>;
|
|
45
|
+
private connect;
|
|
46
|
+
private scheduleReconnect;
|
|
47
|
+
}
|
|
@@ -4,6 +4,7 @@ import { DataDriver, WebSocketMessage } from "@rebasepro/types";
|
|
|
4
4
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
5
5
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
6
6
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
7
|
+
import { ChannelBus } from "./channel-bus";
|
|
7
8
|
import type { ChannelRetentionRule } from "@rebasepro/types";
|
|
8
9
|
/**
|
|
9
10
|
* Auth context stored per-subscription so real-time refetches respect RLS.
|
|
@@ -45,8 +46,33 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
45
46
|
* wait on each other.
|
|
46
47
|
*/
|
|
47
48
|
private channelSendQueues;
|
|
49
|
+
/**
|
|
50
|
+
* Cross-instance transport for channel frames and presence.
|
|
51
|
+
*
|
|
52
|
+
* Defaults to the memory bus, which publishes nowhere — so a single-instance
|
|
53
|
+
* deployment runs the same fan-out it always did, with one resolved promise
|
|
54
|
+
* per broadcast for company. See `channel-bus/ChannelBus.ts`.
|
|
55
|
+
*/
|
|
56
|
+
private bus;
|
|
57
|
+
/**
|
|
58
|
+
* The shared presence roster, present only when a real bus is active.
|
|
59
|
+
*
|
|
60
|
+
* Fan-out alone is not enough for presence: `presence_state` has to answer
|
|
61
|
+
* with everyone in the channel, and per-process maps can only answer for
|
|
62
|
+
* this replica's clients. See `channel-presence.ts`.
|
|
63
|
+
*/
|
|
64
|
+
private presenceStore?;
|
|
65
|
+
/** Sweeps roster rows left behind by instances that stopped heartbeating. */
|
|
66
|
+
private presenceSweepInterval?;
|
|
67
|
+
/**
|
|
68
|
+
* Channels whose oversized ephemeral broadcasts have already been reported,
|
|
69
|
+
* so a hot channel logs the problem once rather than once per message.
|
|
70
|
+
*/
|
|
71
|
+
private oversizedBroadcastWarned;
|
|
48
72
|
private presenceInterval?;
|
|
49
73
|
private static readonly PRESENCE_TIMEOUT_MS;
|
|
74
|
+
/** How often stale roster rows from other instances are reaped. */
|
|
75
|
+
private static readonly PRESENCE_SWEEP_INTERVAL_MS;
|
|
50
76
|
private dataService;
|
|
51
77
|
private _subscriptions;
|
|
52
78
|
private subscriptionCallbacks;
|
|
@@ -69,6 +95,8 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
69
95
|
private cdcListener?;
|
|
70
96
|
/** Whether database-level CDC is the active cross-instance change source. */
|
|
71
97
|
private cdcActive;
|
|
98
|
+
/** Junction table → the child lists its rows belong to, built when CDC starts. */
|
|
99
|
+
private junctionLinkMap?;
|
|
72
100
|
/** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
|
|
73
101
|
private cdcTableMap?;
|
|
74
102
|
/**
|
|
@@ -245,6 +273,44 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
245
273
|
private persistAndFanOut;
|
|
246
274
|
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
247
275
|
private fanOutBroadcast;
|
|
276
|
+
/**
|
|
277
|
+
* Install the transport that carries channel frames between instances.
|
|
278
|
+
*
|
|
279
|
+
* Called once at boot. A bus that cannot start is reported and replaced with
|
|
280
|
+
* the memory bus: losing cross-instance fan-out degrades collaboration to
|
|
281
|
+
* what it was before this existed, whereas refusing to boot takes the whole
|
|
282
|
+
* backend down for it.
|
|
283
|
+
*/
|
|
284
|
+
configureChannelBus(bus: ChannelBus): Promise<void>;
|
|
285
|
+
/** Which transport is in use — `"memory"` means per-instance only. */
|
|
286
|
+
getChannelBusKind(): ChannelBus["kind"];
|
|
287
|
+
/**
|
|
288
|
+
* Send a broadcast to the other instances.
|
|
289
|
+
*
|
|
290
|
+
* Fire-and-forget by design: the clients on this instance have already been
|
|
291
|
+
* served, and a bus that is briefly unreachable must not turn a broadcast
|
|
292
|
+
* into an error for the sender.
|
|
293
|
+
*/
|
|
294
|
+
private publishBroadcast;
|
|
295
|
+
private publishFrame;
|
|
296
|
+
/**
|
|
297
|
+
* Tell the sender that a message was delivered locally but nowhere else.
|
|
298
|
+
*
|
|
299
|
+
* Staying quiet here would be the worst option available: on one instance
|
|
300
|
+
* the app works, on two it works for half the users, and nothing in the
|
|
301
|
+
* logs connects the two. The fix is a one-liner in config — give the
|
|
302
|
+
* channel a retention rule and the message travels as a pointer instead —
|
|
303
|
+
* so the message says exactly that.
|
|
304
|
+
*/
|
|
305
|
+
private reportOversizedBroadcast;
|
|
306
|
+
/**
|
|
307
|
+
* Deliver a frame published by another instance to this one's clients.
|
|
308
|
+
*
|
|
309
|
+
* Frames we published ourselves are dropped on arrival — the local fan-out
|
|
310
|
+
* happened before the publish — exactly as the entity-change handler skips
|
|
311
|
+
* its own `sid`.
|
|
312
|
+
*/
|
|
313
|
+
private handleBusFrame;
|
|
248
314
|
/**
|
|
249
315
|
* Install retention rules and create the tables they need.
|
|
250
316
|
*
|
|
@@ -266,16 +332,60 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
266
332
|
*/
|
|
267
333
|
private handleChannelHistoryRequest;
|
|
268
334
|
private sendChannelHistory;
|
|
269
|
-
/**
|
|
335
|
+
/**
|
|
336
|
+
* Track presence in a channel.
|
|
337
|
+
*
|
|
338
|
+
* The client re-sends this every ~20s as a heartbeat against the 30s
|
|
339
|
+
* timeout, so most calls carry the state that is already recorded. Those
|
|
340
|
+
* refresh `last_seen` and stop there: re-announcing an unchanged state to
|
|
341
|
+
* every instance would put a bus message per client per heartbeat on the
|
|
342
|
+
* wire to tell everyone nothing happened.
|
|
343
|
+
*/
|
|
270
344
|
trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void;
|
|
271
|
-
/**
|
|
272
|
-
|
|
273
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Remove presence from a channel.
|
|
347
|
+
*
|
|
348
|
+
* `skipStore` is for the socket-close path, which clears every channel at
|
|
349
|
+
* once and then deletes the client's rows in a single statement instead of
|
|
350
|
+
* one per channel.
|
|
351
|
+
*/
|
|
352
|
+
removePresence(clientId: string, channel: string, options?: {
|
|
353
|
+
skipStore?: boolean;
|
|
354
|
+
}): void;
|
|
355
|
+
/**
|
|
356
|
+
* Send the full roster for a channel to one client.
|
|
357
|
+
*
|
|
358
|
+
* Answered from the shared table when there is one, because "who is in this
|
|
359
|
+
* document?" has a single answer that must not depend on which replica the
|
|
360
|
+
* asker happens to be connected to. Without a bus there is nothing to share
|
|
361
|
+
* and the local map *is* the roster — that path stays synchronous, which is
|
|
362
|
+
* what it always was.
|
|
363
|
+
*/
|
|
274
364
|
sendPresenceState(clientId: string, channel: string): void;
|
|
275
|
-
/**
|
|
276
|
-
private
|
|
365
|
+
/** Presence of the clients connected to this instance. */
|
|
366
|
+
private localPresences;
|
|
367
|
+
private sendPresenceStateMessage;
|
|
368
|
+
/** Deliver a presence diff to this instance's members of the channel. */
|
|
369
|
+
private deliverPresenceDiff;
|
|
370
|
+
/** Tell the other instances about a presence change. */
|
|
371
|
+
private publishPresenceDiff;
|
|
372
|
+
/** Run a roster write when there is a roster, and never let it throw. */
|
|
373
|
+
private presenceStoreOp;
|
|
277
374
|
/** Periodic cleanup for stale presences */
|
|
278
375
|
private ensurePresenceCleanup;
|
|
376
|
+
/**
|
|
377
|
+
* Reap roster rows whose owning instance stopped heartbeating.
|
|
378
|
+
*
|
|
379
|
+
* This is the cross-instance half of the sweep above, and it doubles as
|
|
380
|
+
* crash recovery: a pod that dies takes its clients with it but leaves
|
|
381
|
+
* their rows behind, and after one TTL window they look exactly like any
|
|
382
|
+
* other client that went quiet. The delete returns what it removed, so
|
|
383
|
+
* whichever instance wins the race is the one that announces the
|
|
384
|
+
* departures — once for the cluster, not once per replica.
|
|
385
|
+
*/
|
|
386
|
+
private ensurePresenceSweep;
|
|
387
|
+
/** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
|
|
388
|
+
private sweepStalePresence;
|
|
279
389
|
/**
|
|
280
390
|
* Gracefully tear down all realtime resources.
|
|
281
391
|
*
|
|
@@ -329,6 +439,23 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
329
439
|
* subscriber, never per publisher.
|
|
330
440
|
*/
|
|
331
441
|
private handleCdcEvent;
|
|
442
|
+
/**
|
|
443
|
+
* Deliver a change on a many-to-many junction table as a change to the child
|
|
444
|
+
* lists it belongs to.
|
|
445
|
+
*
|
|
446
|
+
* Linking a tag to a post writes only `posts_tags`. That table backs no
|
|
447
|
+
* collection, so change capture dropped the event as unmapped and the
|
|
448
|
+
* subscribers of `posts/1/tags` never heard about it — every other write in
|
|
449
|
+
* the system was realtime, and this one silently was not. The junction row
|
|
450
|
+
* carries both ids, so it names its own paths exactly.
|
|
451
|
+
*
|
|
452
|
+
* Notifies the nested path rather than either endpoint collection, because
|
|
453
|
+
* invalidation walks *parent* paths and never child ones: telling `tags` it
|
|
454
|
+
* changed would not reach a subscription on `posts/1/tags`.
|
|
455
|
+
*
|
|
456
|
+
* Returns whether the table was recognised as a junction.
|
|
457
|
+
*/
|
|
458
|
+
private handleJunctionCdcEvent;
|
|
332
459
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
333
460
|
private extractIdFromCdcRow;
|
|
334
461
|
private dedupKey;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CollectionConfig,
|
|
1
|
+
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
2
2
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
3
3
|
/**
|
|
4
4
|
* Turning a drizzle result into a row we serve.
|
|
@@ -26,7 +26,7 @@ export type RelationStyle = "ref" | "inline";
|
|
|
26
26
|
* the query has to nest one level deeper for a junction, and the row walk has
|
|
27
27
|
* to unwrap that same level back out.
|
|
28
28
|
*/
|
|
29
|
-
export declare function isJunctionRelation(relation:
|
|
29
|
+
export declare function isJunctionRelation(relation: ResolvedRelation): boolean;
|
|
30
30
|
/**
|
|
31
31
|
* The address a relation ref points at.
|
|
32
32
|
*
|