graphile-realtime-subscriptions 0.3.0 → 0.4.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.
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ /**
3
+ * RealtimeManager — bridges CursorTracker (polling drain_changes) into
4
+ * PostGraphile's PgSubscriber so cursor-tracked events flow through the
5
+ * same subscription plans as NOTIFY events.
6
+ *
7
+ * Architecture:
8
+ * PgSubscriber uses an internal EventEmitter. NOTIFY payloads arrive via
9
+ * pg's `notification` event and are emitted as `eventEmitter.emit(channel, payload)`.
10
+ * The `listen()` step in grafast subscribes to the same EventEmitter.
11
+ *
12
+ * RealtimeManager converts ChangeLogEntry objects from drain_changes() into
13
+ * the same NOTIFY payload format ("OP:rowId1,rowId2,...") and emits them on
14
+ * the PgSubscriber's EventEmitter, so existing subscription plans handle
15
+ * them identically to real NOTIFY events.
16
+ *
17
+ * This provides at-least-once delivery: NOTIFY is instant but best-effort;
18
+ * cursor polling catches up on anything missed (disconnects, restarts).
19
+ * Duplicates are expected and acceptable — clients should be idempotent.
20
+ *
21
+ * Lifecycle:
22
+ * 1. start() → registers listener node, begins polling + heartbeat
23
+ * 2. drain_changes() results are converted and emitted on PgSubscriber
24
+ * 3. stop() → cleans up ephemeral subscriptions, removes listener node
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.RealtimeManager = void 0;
28
+ exports.extractRowId = extractRowId;
29
+ exports.entryToNotifyPayload = entryToNotifyPayload;
30
+ exports.entryToChannel = entryToChannel;
31
+ const logger_1 = require("@pgpmjs/logger");
32
+ const cursor_tracker_1 = require("./cursor-tracker");
33
+ const log = new logger_1.Logger('realtime-manager');
34
+ /**
35
+ * Extract row IDs from a ChangeLogEntry.
36
+ *
37
+ * For INSERT/UPDATE the row ID lives in payload_after.id;
38
+ * for DELETE it lives in payload_before.id.
39
+ * Falls back to the change_log entry's own id if payloads are missing.
40
+ */
41
+ function extractRowId(entry) {
42
+ if (entry.operation === 'DELETE') {
43
+ return entry.payload_before?.id ?? null;
44
+ }
45
+ return entry.payload_after?.id ?? null;
46
+ }
47
+ /**
48
+ * Convert a ChangeLogEntry into the NOTIFY payload format used by emit_change.
49
+ * Format: "OPERATION:rowId" (e.g. "INSERT:550e8400-...")
50
+ */
51
+ function entryToNotifyPayload(entry) {
52
+ const rowId = extractRowId(entry);
53
+ if (!rowId) {
54
+ return entry.operation;
55
+ }
56
+ return `${entry.operation}:${rowId}`;
57
+ }
58
+ /**
59
+ * Build the NOTIFY channel name for a change_log entry.
60
+ * Matches the channel format used by emit_change: "realtime:{schema}.{table}"
61
+ */
62
+ function entryToChannel(entry) {
63
+ return `realtime:${entry.source_schema}.${entry.source_table}`;
64
+ }
65
+ class RealtimeManager {
66
+ cursorTracker;
67
+ subscriber;
68
+ started = false;
69
+ constructor(options) {
70
+ const { pgSubscriber, withPgClient, ...cursorOpts } = options;
71
+ this.subscriber = pgSubscriber;
72
+ this.cursorTracker = new cursor_tracker_1.CursorTracker({
73
+ nodeId: cursorOpts.nodeId,
74
+ schema: cursorOpts.schema,
75
+ pollIntervalMs: cursorOpts.pollIntervalMs,
76
+ heartbeatIntervalMs: cursorOpts.heartbeatIntervalMs,
77
+ batchLimit: cursorOpts.batchLimit,
78
+ withPgClient,
79
+ onChanges: (entries) => this.dispatchEntries(entries),
80
+ onError: cursorOpts.onError ?? ((err) => {
81
+ log.error(`RealtimeManager error: ${err.message}`);
82
+ }),
83
+ });
84
+ }
85
+ get nodeId() {
86
+ return this.cursorTracker.nodeId;
87
+ }
88
+ get isRunning() {
89
+ return this.started && this.cursorTracker.isRunning;
90
+ }
91
+ async start() {
92
+ if (this.started)
93
+ return;
94
+ this.started = true;
95
+ log.info(`Starting RealtimeManager: node=${this.nodeId}`);
96
+ await this.cursorTracker.start();
97
+ }
98
+ async stop() {
99
+ if (!this.started)
100
+ return;
101
+ this.started = false;
102
+ log.info(`Stopping RealtimeManager: node=${this.nodeId}`);
103
+ await this.cursorTracker.stop();
104
+ }
105
+ /**
106
+ * Convert ChangeLogEntry objects to NOTIFY-format payloads and emit
107
+ * them on the PgSubscriber's internal EventEmitter.
108
+ */
109
+ dispatchEntries(entries) {
110
+ const emitter = this.getEventEmitter();
111
+ if (!emitter) {
112
+ log.warn('PgSubscriber has no eventEmitter; cursor events cannot be dispatched');
113
+ return;
114
+ }
115
+ for (const entry of entries) {
116
+ const channel = entryToChannel(entry);
117
+ const payload = entryToNotifyPayload(entry);
118
+ emitter.emit(channel, payload);
119
+ }
120
+ log.info(`Dispatched ${entries.length} cursor-tracked event(s) to PgSubscriber`);
121
+ }
122
+ /**
123
+ * Access PgSubscriber's internal EventEmitter.
124
+ *
125
+ * PgSubscriber from @dataplan/pg stores an EventEmitter3 instance as
126
+ * `this.eventEmitter`. It is private but stable across v1.x releases.
127
+ * This is the same emitter that NOTIFY events are dispatched through.
128
+ */
129
+ getEventEmitter() {
130
+ const sub = this.subscriber;
131
+ if (sub && typeof sub === 'object' && 'eventEmitter' in sub) {
132
+ const ee = sub.eventEmitter;
133
+ if (typeof ee?.emit === 'function') {
134
+ return ee;
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+ }
140
+ exports.RealtimeManager = RealtimeManager;
package/types.d.ts CHANGED
@@ -12,3 +12,135 @@ export interface RealtimeSubscriptionsPluginOptions {
12
12
  */
13
13
  overflowThreshold?: number;
14
14
  }
15
+ /**
16
+ * A minimal PostgreSQL client interface used by CursorTracker.
17
+ * Compatible with node-postgres (pg) Client or PoolClient.
18
+ */
19
+ export interface PgClient {
20
+ query<R = Record<string, unknown>>(text: string, values?: unknown[]): Promise<{
21
+ rows: R[];
22
+ }>;
23
+ }
24
+ /**
25
+ * Callback that provides a PgClient for executing queries.
26
+ * The client is released after the callback returns.
27
+ */
28
+ export type WithPgClient = <T>(callback: (client: PgClient) => Promise<T>) => Promise<T>;
29
+ /**
30
+ * A single entry from drain_changes(), representing a change_log row
31
+ * matched against subscriber tables.
32
+ */
33
+ export interface ChangeLogEntry {
34
+ id: string;
35
+ occurred_at: string;
36
+ source_schema: string;
37
+ source_table: string;
38
+ operation: string;
39
+ payload_after: Record<string, unknown> | null;
40
+ payload_before: Record<string, unknown> | null;
41
+ payload_diff: Record<string, unknown> | null;
42
+ subscriber_ids: string[];
43
+ }
44
+ /**
45
+ * Configuration for the CursorTracker background poller.
46
+ */
47
+ export interface CursorTrackerOptions {
48
+ /**
49
+ * Unique identifier for this listener node.
50
+ * Should be stable across restarts if you want cursor continuity.
51
+ * If not provided, a random ID is generated (ephemeral mode).
52
+ */
53
+ nodeId?: string;
54
+ /**
55
+ * The realtime_public schema name where drain_changes(),
56
+ * touch_listener(), and cleanup_ephemeral() live.
57
+ *
58
+ * Default: 'realtime_public'
59
+ */
60
+ schema?: string;
61
+ /**
62
+ * How often to poll drain_changes() for new events (milliseconds).
63
+ *
64
+ * Default: 5000 (5 seconds)
65
+ */
66
+ pollIntervalMs?: number;
67
+ /**
68
+ * How often to send a heartbeat via touch_listener() (milliseconds).
69
+ *
70
+ * Default: 30000 (30 seconds)
71
+ */
72
+ heartbeatIntervalMs?: number;
73
+ /**
74
+ * Maximum number of change_log rows to fetch per drain_changes() call.
75
+ *
76
+ * Default: 500
77
+ */
78
+ batchLimit?: number;
79
+ /**
80
+ * Function to acquire a PgClient for executing queries.
81
+ * The cursor tracker calls this for every poll and heartbeat cycle.
82
+ */
83
+ withPgClient: WithPgClient;
84
+ /**
85
+ * Called when drain_changes() returns new change_log entries.
86
+ * The entries include subscriber_ids for fan-out routing.
87
+ */
88
+ onChanges?: (entries: ChangeLogEntry[]) => void;
89
+ /**
90
+ * Called when an error occurs during polling, heartbeat, or cleanup.
91
+ * If not provided, errors are logged to the console.
92
+ */
93
+ onError?: (error: Error) => void;
94
+ }
95
+ /**
96
+ * Configuration for the RealtimeManager, which bridges CursorTracker
97
+ * events into PostGraphile's PgSubscriber for at-least-once delivery.
98
+ */
99
+ export interface RealtimeManagerOptions {
100
+ /**
101
+ * The PgSubscriber instance from PostGraphile's context.
102
+ * RealtimeManager emits cursor-tracked events on its internal EventEmitter
103
+ * so they flow through existing subscription plans.
104
+ */
105
+ pgSubscriber: unknown;
106
+ /**
107
+ * Function to acquire a PgClient for executing cursor tracking queries.
108
+ */
109
+ withPgClient: WithPgClient;
110
+ /**
111
+ * Unique identifier for this listener node.
112
+ * Should be stable across restarts if you want cursor continuity.
113
+ * If not provided, a random ID is generated (ephemeral mode).
114
+ */
115
+ nodeId?: string;
116
+ /**
117
+ * The realtime_public schema name where drain_changes(),
118
+ * touch_listener(), and cleanup_ephemeral() live.
119
+ *
120
+ * Default: 'realtime_public'
121
+ */
122
+ schema?: string;
123
+ /**
124
+ * How often to poll drain_changes() for new events (milliseconds).
125
+ *
126
+ * Default: 5000 (5 seconds)
127
+ */
128
+ pollIntervalMs?: number;
129
+ /**
130
+ * How often to send a heartbeat via touch_listener() (milliseconds).
131
+ *
132
+ * Default: 30000 (30 seconds)
133
+ */
134
+ heartbeatIntervalMs?: number;
135
+ /**
136
+ * Maximum number of change_log rows to fetch per drain_changes() call.
137
+ *
138
+ * Default: 500
139
+ */
140
+ batchLimit?: number;
141
+ /**
142
+ * Called when an error occurs during polling, heartbeat, or cleanup.
143
+ * If not provided, errors are logged via @pgpmjs/logger.
144
+ */
145
+ onError?: (error: Error) => void;
146
+ }