graphile-realtime-subscriptions 0.2.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.
- package/cursor-tracker.d.ts +42 -0
- package/cursor-tracker.js +139 -0
- package/esm/cursor-tracker.d.ts +42 -0
- package/esm/cursor-tracker.js +132 -0
- package/esm/index.d.ts +3 -0
- package/esm/index.js +2 -0
- package/esm/plugin.d.ts +67 -8
- package/esm/plugin.js +165 -20
- package/esm/realtime-manager.d.ts +67 -0
- package/esm/realtime-manager.js +134 -0
- package/esm/types.d.ts +141 -4
- package/index.d.ts +3 -0
- package/index.js +5 -1
- package/package.json +4 -3
- package/plugin.d.ts +67 -8
- package/plugin.js +168 -19
- package/realtime-manager.d.ts +67 -0
- package/realtime-manager.js +140 -0
- package/types.d.ts +141 -4
package/plugin.d.ts
CHANGED
|
@@ -5,21 +5,80 @@
|
|
|
5
5
|
* subscription fields (onXxxChanged) that use PostgreSQL LISTEN/NOTIFY
|
|
6
6
|
* for real-time event delivery.
|
|
7
7
|
*
|
|
8
|
-
* Subscription modes
|
|
9
|
-
* -
|
|
8
|
+
* Subscription modes:
|
|
9
|
+
* - Specific rows: onXxxChanged(ids: [UUID!]) — subscribe to changes on specific rows
|
|
10
10
|
* - Full collection: onXxxChanged (no args) — subscribe to any change on the table
|
|
11
11
|
*
|
|
12
|
+
* NOTIFY payload format (from emit_change trigger):
|
|
13
|
+
* - Normal: "INSERT:uuid1,uuid2,..." or "UPDATE:uuid1" or "DELETE:uuid1"
|
|
14
|
+
* - Overflow: "INVALIDATE" (when a single statement affects > 50 rows)
|
|
15
|
+
*
|
|
12
16
|
* Event flow:
|
|
13
17
|
* 1. A row is inserted/updated/deleted
|
|
14
|
-
* 2. The emit_change trigger fires pg_notify
|
|
18
|
+
* 2. The emit_change trigger fires pg_notify with TG_OP:row_ids or INVALIDATE
|
|
15
19
|
* 3. PostGraphile's pgSubscriber receives the NOTIFY
|
|
16
|
-
* 4. The
|
|
17
|
-
* 5. The client receives { event, row
|
|
20
|
+
* 4. The plugin parses the payload and fetches the specific changed row(s)
|
|
21
|
+
* 5. The client receives { event, row, rowId, overflow }
|
|
22
|
+
*
|
|
23
|
+
* Cursor tracking (at-least-once delivery):
|
|
24
|
+
* The CursorTracker class provides a complementary polling-based delivery
|
|
25
|
+
* path via drain_changes(). It manages the listener_node lifecycle:
|
|
26
|
+
* - start() registers the node via touch_listener()
|
|
27
|
+
* - Periodic polling via drain_changes() fetches change_log entries
|
|
28
|
+
* - Periodic heartbeat via touch_listener() keeps the node alive
|
|
29
|
+
* - stop() calls cleanup_ephemeral() to remove ephemeral subscriptions
|
|
30
|
+
* This enables at-least-once semantics by tracking cursor position.
|
|
18
31
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
32
|
+
* Overflow protection:
|
|
33
|
+
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
34
|
+
* - Plugin-side: per-subscriber throttle (default 50 events/second/table)
|
|
35
|
+
* drops individual events and sends a single INVALIDATE when exceeded
|
|
36
|
+
*
|
|
37
|
+
* Security / RLS enforcement:
|
|
38
|
+
* - Row data is always fetched via resource.get() which runs through the
|
|
39
|
+
* authenticated user's connection with their JWT role and pgSettings applied.
|
|
40
|
+
* - For INSERT/UPDATE events, if RLS denies access (resource.get returns null),
|
|
41
|
+
* the rowId is masked (set to null) to prevent metadata leaks.
|
|
42
|
+
* - For DELETE events, row is naturally null (the row no longer exists).
|
|
43
|
+
* - For INVALIDATE (overflow), the client should refetch via a normal query
|
|
44
|
+
* which is also RLS-gated.
|
|
45
|
+
* - When ids are provided, only events for those specific rows are delivered,
|
|
46
|
+
* preventing cross-tenant event leaks.
|
|
21
47
|
*/
|
|
22
48
|
import type { GraphileConfig } from 'graphile-config';
|
|
23
49
|
import type { RealtimeSubscriptionsPluginOptions } from './types';
|
|
24
|
-
|
|
50
|
+
/** Default overflow threshold: events per second per table per subscriber */
|
|
51
|
+
declare const DEFAULT_OVERFLOW_THRESHOLD = 50;
|
|
52
|
+
interface ParsedPayload {
|
|
53
|
+
event: string;
|
|
54
|
+
rowIds: string[];
|
|
55
|
+
overflow: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse the NOTIFY payload from emit_change.
|
|
59
|
+
* Format: "TG_OP:id1,id2,..." or "INVALIDATE"
|
|
60
|
+
*/
|
|
61
|
+
declare function parseNotifyPayload(raw: string): ParsedPayload;
|
|
62
|
+
/**
|
|
63
|
+
* Per-subscriber, per-table event rate tracker.
|
|
64
|
+
* Counts events in a sliding 1-second window.
|
|
65
|
+
*/
|
|
66
|
+
declare class EventThrottle {
|
|
67
|
+
private readonly threshold;
|
|
68
|
+
private windowStart;
|
|
69
|
+
private eventCount;
|
|
70
|
+
private overflowSent;
|
|
71
|
+
constructor(threshold: number);
|
|
72
|
+
/**
|
|
73
|
+
* Record an event and return whether it should be delivered.
|
|
74
|
+
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
75
|
+
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
76
|
+
*/
|
|
77
|
+
check(): 'deliver' | 'overflow' | 'drop';
|
|
78
|
+
}
|
|
79
|
+
export declare function createRealtimeSubscriptionsPlugin(options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
|
|
25
80
|
export { createRealtimeSubscriptionsPlugin as RealtimeSubscriptionsPlugin };
|
|
81
|
+
export { CursorTracker } from './cursor-tracker';
|
|
82
|
+
export { RealtimeManager } from './realtime-manager';
|
|
83
|
+
export type { CursorTrackerOptions, ChangeLogEntry, WithPgClient, PgClient, RealtimeManagerOptions } from './types';
|
|
84
|
+
export { parseNotifyPayload, EventThrottle, DEFAULT_OVERFLOW_THRESHOLD };
|
package/plugin.js
CHANGED
|
@@ -6,27 +6,111 @@
|
|
|
6
6
|
* subscription fields (onXxxChanged) that use PostgreSQL LISTEN/NOTIFY
|
|
7
7
|
* for real-time event delivery.
|
|
8
8
|
*
|
|
9
|
-
* Subscription modes
|
|
10
|
-
* -
|
|
9
|
+
* Subscription modes:
|
|
10
|
+
* - Specific rows: onXxxChanged(ids: [UUID!]) — subscribe to changes on specific rows
|
|
11
11
|
* - Full collection: onXxxChanged (no args) — subscribe to any change on the table
|
|
12
12
|
*
|
|
13
|
+
* NOTIFY payload format (from emit_change trigger):
|
|
14
|
+
* - Normal: "INSERT:uuid1,uuid2,..." or "UPDATE:uuid1" or "DELETE:uuid1"
|
|
15
|
+
* - Overflow: "INVALIDATE" (when a single statement affects > 50 rows)
|
|
16
|
+
*
|
|
13
17
|
* Event flow:
|
|
14
18
|
* 1. A row is inserted/updated/deleted
|
|
15
|
-
* 2. The emit_change trigger fires pg_notify
|
|
19
|
+
* 2. The emit_change trigger fires pg_notify with TG_OP:row_ids or INVALIDATE
|
|
16
20
|
* 3. PostGraphile's pgSubscriber receives the NOTIFY
|
|
17
|
-
* 4. The
|
|
18
|
-
* 5. The client receives { event, row
|
|
21
|
+
* 4. The plugin parses the payload and fetches the specific changed row(s)
|
|
22
|
+
* 5. The client receives { event, row, rowId, overflow }
|
|
23
|
+
*
|
|
24
|
+
* Cursor tracking (at-least-once delivery):
|
|
25
|
+
* The CursorTracker class provides a complementary polling-based delivery
|
|
26
|
+
* path via drain_changes(). It manages the listener_node lifecycle:
|
|
27
|
+
* - start() registers the node via touch_listener()
|
|
28
|
+
* - Periodic polling via drain_changes() fetches change_log entries
|
|
29
|
+
* - Periodic heartbeat via touch_listener() keeps the node alive
|
|
30
|
+
* - stop() calls cleanup_ephemeral() to remove ephemeral subscriptions
|
|
31
|
+
* This enables at-least-once semantics by tracking cursor position.
|
|
32
|
+
*
|
|
33
|
+
* Overflow protection:
|
|
34
|
+
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
35
|
+
* - Plugin-side: per-subscriber throttle (default 50 events/second/table)
|
|
36
|
+
* drops individual events and sends a single INVALIDATE when exceeded
|
|
19
37
|
*
|
|
20
|
-
* RLS enforcement
|
|
21
|
-
*
|
|
38
|
+
* Security / RLS enforcement:
|
|
39
|
+
* - Row data is always fetched via resource.get() which runs through the
|
|
40
|
+
* authenticated user's connection with their JWT role and pgSettings applied.
|
|
41
|
+
* - For INSERT/UPDATE events, if RLS denies access (resource.get returns null),
|
|
42
|
+
* the rowId is masked (set to null) to prevent metadata leaks.
|
|
43
|
+
* - For DELETE events, row is naturally null (the row no longer exists).
|
|
44
|
+
* - For INVALIDATE (overflow), the client should refetch via a normal query
|
|
45
|
+
* which is also RLS-gated.
|
|
46
|
+
* - When ids are provided, only events for those specific rows are delivered,
|
|
47
|
+
* preventing cross-tenant event leaks.
|
|
22
48
|
*/
|
|
23
49
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.DEFAULT_OVERFLOW_THRESHOLD = exports.EventThrottle = exports.RealtimeManager = exports.CursorTracker = void 0;
|
|
24
51
|
exports.createRealtimeSubscriptionsPlugin = createRealtimeSubscriptionsPlugin;
|
|
25
52
|
exports.RealtimeSubscriptionsPlugin = createRealtimeSubscriptionsPlugin;
|
|
53
|
+
exports.parseNotifyPayload = parseNotifyPayload;
|
|
26
54
|
const grafast_1 = require("grafast");
|
|
27
55
|
const graphile_utils_1 = require("graphile-utils");
|
|
28
56
|
const logger_1 = require("@pgpmjs/logger");
|
|
29
57
|
const log = new logger_1.Logger('graphile-realtime-subscriptions');
|
|
58
|
+
/** Default overflow threshold: events per second per table per subscriber */
|
|
59
|
+
const DEFAULT_OVERFLOW_THRESHOLD = 50;
|
|
60
|
+
exports.DEFAULT_OVERFLOW_THRESHOLD = DEFAULT_OVERFLOW_THRESHOLD;
|
|
61
|
+
/**
|
|
62
|
+
* Parse the NOTIFY payload from emit_change.
|
|
63
|
+
* Format: "TG_OP:id1,id2,..." or "INVALIDATE"
|
|
64
|
+
*/
|
|
65
|
+
function parseNotifyPayload(raw) {
|
|
66
|
+
if (raw === 'INVALIDATE') {
|
|
67
|
+
return { event: 'INVALIDATE', rowIds: [], overflow: true };
|
|
68
|
+
}
|
|
69
|
+
const colonIdx = raw.indexOf(':');
|
|
70
|
+
if (colonIdx === -1) {
|
|
71
|
+
return { event: raw || 'UNKNOWN', rowIds: [], overflow: false };
|
|
72
|
+
}
|
|
73
|
+
const event = raw.substring(0, colonIdx);
|
|
74
|
+
const idsPart = raw.substring(colonIdx + 1);
|
|
75
|
+
const rowIds = idsPart.length > 0 ? idsPart.split(',') : [];
|
|
76
|
+
return { event, rowIds, overflow: false };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Per-subscriber, per-table event rate tracker.
|
|
80
|
+
* Counts events in a sliding 1-second window.
|
|
81
|
+
*/
|
|
82
|
+
class EventThrottle {
|
|
83
|
+
threshold;
|
|
84
|
+
windowStart = 0;
|
|
85
|
+
eventCount = 0;
|
|
86
|
+
overflowSent = false;
|
|
87
|
+
constructor(threshold) {
|
|
88
|
+
this.threshold = threshold;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Record an event and return whether it should be delivered.
|
|
92
|
+
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
93
|
+
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
94
|
+
*/
|
|
95
|
+
check() {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
if (now - this.windowStart >= 1000) {
|
|
98
|
+
this.windowStart = now;
|
|
99
|
+
this.eventCount = 0;
|
|
100
|
+
this.overflowSent = false;
|
|
101
|
+
}
|
|
102
|
+
this.eventCount++;
|
|
103
|
+
if (this.eventCount <= this.threshold) {
|
|
104
|
+
return 'deliver';
|
|
105
|
+
}
|
|
106
|
+
if (!this.overflowSent) {
|
|
107
|
+
this.overflowSent = true;
|
|
108
|
+
return 'overflow';
|
|
109
|
+
}
|
|
110
|
+
return 'drop';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
exports.EventThrottle = EventThrottle;
|
|
30
114
|
function discoverRealtimeTables(build) {
|
|
31
115
|
const { pgRegistry } = build.input;
|
|
32
116
|
const resources = pgRegistry.pgResources;
|
|
@@ -62,32 +146,59 @@ function discoverRealtimeTables(build) {
|
|
|
62
146
|
}
|
|
63
147
|
function buildTypeDefs(tables) {
|
|
64
148
|
const subscriptionFields = tables
|
|
65
|
-
.map(({ fieldName, payloadTypeName }) => ` """Subscribe to changes on this table. Pass
|
|
149
|
+
.map(({ fieldName, payloadTypeName }) => ` """Subscribe to changes on this table. Pass ids to watch specific rows, or no args for the full collection."""\n ${fieldName}(ids: [UUID!]): ${payloadTypeName}`)
|
|
66
150
|
.join('\n');
|
|
67
151
|
const payloadTypes = tables
|
|
68
152
|
.map(({ payloadTypeName, typeName, rowFieldName }) => `"""Payload delivered when a ${typeName} row changes."""\n` +
|
|
69
153
|
`type ${payloadTypeName} {\n` +
|
|
70
|
-
` """The DML operation: INSERT, UPDATE, or
|
|
154
|
+
` """The DML operation: INSERT, UPDATE, DELETE, or INVALIDATE."""\n` +
|
|
71
155
|
` event: String!\n` +
|
|
72
|
-
` """The current state of the row (null for DELETE or if RLS denies access)."""\n` +
|
|
156
|
+
` """The current state of the row (null for DELETE, INVALIDATE, or if RLS denies access)."""\n` +
|
|
73
157
|
` ${rowFieldName}: ${typeName}\n` +
|
|
158
|
+
` """The ID of the changed row (null for INVALIDATE, or masked when RLS denies access)."""\n` +
|
|
159
|
+
` rowId: UUID\n` +
|
|
160
|
+
` """True when too many changes occurred and the client should refetch."""\n` +
|
|
161
|
+
` overflow: Boolean!\n` +
|
|
74
162
|
`}`)
|
|
75
163
|
.join('\n\n');
|
|
76
164
|
return `extend type Subscription {\n${subscriptionFields}\n}\n\n${payloadTypes}`;
|
|
77
165
|
}
|
|
78
|
-
function buildPlans(tables) {
|
|
166
|
+
function buildPlans(tables, overflowThreshold) {
|
|
79
167
|
const subscriptionPlans = {};
|
|
80
168
|
const allPlans = {};
|
|
81
169
|
for (const { resource, fieldName, payloadTypeName, rowFieldName, notifyChannel } of tables) {
|
|
170
|
+
const throttle = new EventThrottle(overflowThreshold);
|
|
82
171
|
subscriptionPlans[fieldName] = {
|
|
83
172
|
subscribePlan(_$root, args) {
|
|
84
173
|
const $pgSubscriber = (0, grafast_1.context)().get('pgSubscriber');
|
|
85
174
|
const $topic = (0, grafast_1.constant)(notifyChannel);
|
|
86
|
-
const $
|
|
175
|
+
const $ids = args.get('ids');
|
|
87
176
|
return (0, grafast_1.listen)($pgSubscriber, $topic, ($payload) => {
|
|
177
|
+
const $parsed = (0, grafast_1.lambda)([$payload, $ids], (pair) => {
|
|
178
|
+
const [raw, subscribedIds] = pair;
|
|
179
|
+
const parsed = parseNotifyPayload(String(raw));
|
|
180
|
+
const action = parsed.overflow ? 'deliver' : throttle.check();
|
|
181
|
+
if (action === 'drop') {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
if (action === 'overflow') {
|
|
185
|
+
return {
|
|
186
|
+
event: 'INVALIDATE',
|
|
187
|
+
rowIds: [],
|
|
188
|
+
overflow: true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// Sparse set filtering: only deliver events for subscribed row IDs
|
|
192
|
+
if (subscribedIds && subscribedIds.length > 0) {
|
|
193
|
+
const hasMatch = parsed.rowIds.some((rid) => subscribedIds.includes(rid));
|
|
194
|
+
if (!hasMatch)
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return parsed;
|
|
198
|
+
});
|
|
88
199
|
return (0, grafast_1.object)({
|
|
89
|
-
|
|
90
|
-
|
|
200
|
+
parsed: $parsed,
|
|
201
|
+
subscribedIds: $ids,
|
|
91
202
|
});
|
|
92
203
|
});
|
|
93
204
|
},
|
|
@@ -97,18 +208,50 @@ function buildPlans(tables) {
|
|
|
97
208
|
};
|
|
98
209
|
allPlans[payloadTypeName] = {
|
|
99
210
|
event($parent) {
|
|
100
|
-
|
|
211
|
+
const $parsed = $parent.get('parsed');
|
|
212
|
+
return (0, grafast_1.lambda)($parsed, (p) => p?.event ?? 'UNKNOWN');
|
|
213
|
+
},
|
|
214
|
+
rowId($parent) {
|
|
215
|
+
const $parsed = $parent.get('parsed');
|
|
216
|
+
const $subscribedIds = $parent.get('subscribedIds');
|
|
217
|
+
return (0, grafast_1.lambda)([$parsed, $subscribedIds], (pair) => {
|
|
218
|
+
const [p, subscribedIds] = pair;
|
|
219
|
+
if (!p || p.overflow || p.rowIds.length === 0)
|
|
220
|
+
return null;
|
|
221
|
+
// When ids are provided, return the first matching row ID
|
|
222
|
+
if (subscribedIds && subscribedIds.length > 0) {
|
|
223
|
+
return p.rowIds.find((rid) => subscribedIds.includes(rid)) ?? null;
|
|
224
|
+
}
|
|
225
|
+
return p.rowIds[0];
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
overflow($parent) {
|
|
229
|
+
const $parsed = $parent.get('parsed');
|
|
230
|
+
return (0, grafast_1.lambda)($parsed, (p) => p?.overflow ?? false);
|
|
101
231
|
},
|
|
102
232
|
[rowFieldName]($parent) {
|
|
103
|
-
const $
|
|
104
|
-
|
|
233
|
+
const $parsed = $parent.get('parsed');
|
|
234
|
+
const $subscribedIds = $parent.get('subscribedIds');
|
|
235
|
+
const $rowId = (0, grafast_1.lambda)([$parsed, $subscribedIds], (tuple) => {
|
|
236
|
+
const [p, subscribedIds] = tuple;
|
|
237
|
+
if (!p || p.overflow || p.rowIds.length === 0)
|
|
238
|
+
return null;
|
|
239
|
+
// When ids are provided, return first matching row ID
|
|
240
|
+
if (subscribedIds && subscribedIds.length > 0) {
|
|
241
|
+
return p.rowIds.find((rid) => subscribedIds.includes(rid)) ?? null;
|
|
242
|
+
}
|
|
243
|
+
// Full collection mode: return first row ID
|
|
244
|
+
return p.rowIds[0];
|
|
245
|
+
});
|
|
246
|
+
return resource.get({ id: $rowId });
|
|
105
247
|
},
|
|
106
248
|
};
|
|
107
249
|
}
|
|
108
250
|
allPlans['Subscription'] = subscriptionPlans;
|
|
109
251
|
return allPlans;
|
|
110
252
|
}
|
|
111
|
-
function createRealtimeSubscriptionsPlugin(
|
|
253
|
+
function createRealtimeSubscriptionsPlugin(options = {}) {
|
|
254
|
+
const overflowThreshold = options.overflowThreshold ?? DEFAULT_OVERFLOW_THRESHOLD;
|
|
112
255
|
return (0, graphile_utils_1.extendSchema)((build) => {
|
|
113
256
|
const tables = discoverRealtimeTables(build);
|
|
114
257
|
if (tables.length === 0) {
|
|
@@ -116,8 +259,14 @@ function createRealtimeSubscriptionsPlugin(_options = {}) {
|
|
|
116
259
|
return { typeDefs: '', plans: {} };
|
|
117
260
|
}
|
|
118
261
|
log.info(`Generating subscription fields for ${tables.length} realtime table(s)`);
|
|
262
|
+
log.info(`Overflow threshold: ${overflowThreshold} events/second/table`);
|
|
119
263
|
const typeDefs = buildTypeDefs(tables);
|
|
120
|
-
const plans = buildPlans(tables);
|
|
264
|
+
const plans = buildPlans(tables, overflowThreshold);
|
|
121
265
|
return { typeDefs, plans };
|
|
122
266
|
}, 'RealtimeSubscriptionsPlugin');
|
|
123
267
|
}
|
|
268
|
+
// Re-export CursorTracker and RealtimeManager for convenience
|
|
269
|
+
var cursor_tracker_1 = require("./cursor-tracker");
|
|
270
|
+
Object.defineProperty(exports, "CursorTracker", { enumerable: true, get: function () { return cursor_tracker_1.CursorTracker; } });
|
|
271
|
+
var realtime_manager_1 = require("./realtime-manager");
|
|
272
|
+
Object.defineProperty(exports, "RealtimeManager", { enumerable: true, get: function () { return realtime_manager_1.RealtimeManager; } });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RealtimeManager — bridges CursorTracker (polling drain_changes) into
|
|
3
|
+
* PostGraphile's PgSubscriber so cursor-tracked events flow through the
|
|
4
|
+
* same subscription plans as NOTIFY events.
|
|
5
|
+
*
|
|
6
|
+
* Architecture:
|
|
7
|
+
* PgSubscriber uses an internal EventEmitter. NOTIFY payloads arrive via
|
|
8
|
+
* pg's `notification` event and are emitted as `eventEmitter.emit(channel, payload)`.
|
|
9
|
+
* The `listen()` step in grafast subscribes to the same EventEmitter.
|
|
10
|
+
*
|
|
11
|
+
* RealtimeManager converts ChangeLogEntry objects from drain_changes() into
|
|
12
|
+
* the same NOTIFY payload format ("OP:rowId1,rowId2,...") and emits them on
|
|
13
|
+
* the PgSubscriber's EventEmitter, so existing subscription plans handle
|
|
14
|
+
* them identically to real NOTIFY events.
|
|
15
|
+
*
|
|
16
|
+
* This provides at-least-once delivery: NOTIFY is instant but best-effort;
|
|
17
|
+
* cursor polling catches up on anything missed (disconnects, restarts).
|
|
18
|
+
* Duplicates are expected and acceptable — clients should be idempotent.
|
|
19
|
+
*
|
|
20
|
+
* Lifecycle:
|
|
21
|
+
* 1. start() → registers listener node, begins polling + heartbeat
|
|
22
|
+
* 2. drain_changes() results are converted and emitted on PgSubscriber
|
|
23
|
+
* 3. stop() → cleans up ephemeral subscriptions, removes listener node
|
|
24
|
+
*/
|
|
25
|
+
import type { ChangeLogEntry, RealtimeManagerOptions } from './types';
|
|
26
|
+
/**
|
|
27
|
+
* Extract row IDs from a ChangeLogEntry.
|
|
28
|
+
*
|
|
29
|
+
* For INSERT/UPDATE the row ID lives in payload_after.id;
|
|
30
|
+
* for DELETE it lives in payload_before.id.
|
|
31
|
+
* Falls back to the change_log entry's own id if payloads are missing.
|
|
32
|
+
*/
|
|
33
|
+
declare function extractRowId(entry: ChangeLogEntry): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Convert a ChangeLogEntry into the NOTIFY payload format used by emit_change.
|
|
36
|
+
* Format: "OPERATION:rowId" (e.g. "INSERT:550e8400-...")
|
|
37
|
+
*/
|
|
38
|
+
declare function entryToNotifyPayload(entry: ChangeLogEntry): string;
|
|
39
|
+
/**
|
|
40
|
+
* Build the NOTIFY channel name for a change_log entry.
|
|
41
|
+
* Matches the channel format used by emit_change: "realtime:{schema}.{table}"
|
|
42
|
+
*/
|
|
43
|
+
declare function entryToChannel(entry: ChangeLogEntry): string;
|
|
44
|
+
export declare class RealtimeManager {
|
|
45
|
+
private readonly cursorTracker;
|
|
46
|
+
private readonly subscriber;
|
|
47
|
+
private started;
|
|
48
|
+
constructor(options: RealtimeManagerOptions);
|
|
49
|
+
get nodeId(): string;
|
|
50
|
+
get isRunning(): boolean;
|
|
51
|
+
start(): Promise<void>;
|
|
52
|
+
stop(): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Convert ChangeLogEntry objects to NOTIFY-format payloads and emit
|
|
55
|
+
* them on the PgSubscriber's internal EventEmitter.
|
|
56
|
+
*/
|
|
57
|
+
private dispatchEntries;
|
|
58
|
+
/**
|
|
59
|
+
* Access PgSubscriber's internal EventEmitter.
|
|
60
|
+
*
|
|
61
|
+
* PgSubscriber from @dataplan/pg stores an EventEmitter3 instance as
|
|
62
|
+
* `this.eventEmitter`. It is private but stable across v1.x releases.
|
|
63
|
+
* This is the same emitter that NOTIFY events are dispatched through.
|
|
64
|
+
*/
|
|
65
|
+
private getEventEmitter;
|
|
66
|
+
}
|
|
67
|
+
export { extractRowId, entryToNotifyPayload, entryToChannel };
|
|
@@ -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;
|