graphile-realtime-subscriptions 0.3.0 → 0.5.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,134 @@
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 { Logger } from '@pgpmjs/logger';
26
+ import { CursorTracker } from './cursor-tracker';
27
+ const log = new Logger('realtime-manager');
28
+ /**
29
+ * Extract row IDs from a ChangeLogEntry.
30
+ *
31
+ * For INSERT/UPDATE the row ID lives in payload_after.id;
32
+ * for DELETE it lives in payload_before.id.
33
+ * Falls back to the change_log entry's own id if payloads are missing.
34
+ */
35
+ function extractRowId(entry) {
36
+ if (entry.operation === 'DELETE') {
37
+ return entry.payload_before?.id ?? null;
38
+ }
39
+ return entry.payload_after?.id ?? null;
40
+ }
41
+ /**
42
+ * Convert a ChangeLogEntry into the NOTIFY payload format used by emit_change.
43
+ * Format: "OPERATION:rowId" (e.g. "INSERT:550e8400-...")
44
+ */
45
+ function entryToNotifyPayload(entry) {
46
+ const rowId = extractRowId(entry);
47
+ if (!rowId) {
48
+ return entry.operation;
49
+ }
50
+ return `${entry.operation}:${rowId}`;
51
+ }
52
+ /**
53
+ * Build the NOTIFY channel name for a change_log entry.
54
+ * Matches the channel format used by emit_change: "realtime:{schema}.{table}"
55
+ */
56
+ function entryToChannel(entry) {
57
+ return `realtime:${entry.source_schema}.${entry.source_table}`;
58
+ }
59
+ export class RealtimeManager {
60
+ cursorTracker;
61
+ subscriber;
62
+ started = false;
63
+ constructor(options) {
64
+ const { pgSubscriber, withPgClient, ...cursorOpts } = options;
65
+ this.subscriber = pgSubscriber;
66
+ this.cursorTracker = new CursorTracker({
67
+ nodeId: cursorOpts.nodeId,
68
+ schema: cursorOpts.schema,
69
+ pollIntervalMs: cursorOpts.pollIntervalMs,
70
+ heartbeatIntervalMs: cursorOpts.heartbeatIntervalMs,
71
+ batchLimit: cursorOpts.batchLimit,
72
+ withPgClient,
73
+ onChanges: (entries) => this.dispatchEntries(entries),
74
+ onError: cursorOpts.onError ?? ((err) => {
75
+ log.error(`RealtimeManager error: ${err.message}`);
76
+ }),
77
+ });
78
+ }
79
+ get nodeId() {
80
+ return this.cursorTracker.nodeId;
81
+ }
82
+ get isRunning() {
83
+ return this.started && this.cursorTracker.isRunning;
84
+ }
85
+ async start() {
86
+ if (this.started)
87
+ return;
88
+ this.started = true;
89
+ log.info(`Starting RealtimeManager: node=${this.nodeId}`);
90
+ await this.cursorTracker.start();
91
+ }
92
+ async stop() {
93
+ if (!this.started)
94
+ return;
95
+ this.started = false;
96
+ log.info(`Stopping RealtimeManager: node=${this.nodeId}`);
97
+ await this.cursorTracker.stop();
98
+ }
99
+ /**
100
+ * Convert ChangeLogEntry objects to NOTIFY-format payloads and emit
101
+ * them on the PgSubscriber's internal EventEmitter.
102
+ */
103
+ dispatchEntries(entries) {
104
+ const emitter = this.getEventEmitter();
105
+ if (!emitter) {
106
+ log.warn('PgSubscriber has no eventEmitter; cursor events cannot be dispatched');
107
+ return;
108
+ }
109
+ for (const entry of entries) {
110
+ const channel = entryToChannel(entry);
111
+ const payload = entryToNotifyPayload(entry);
112
+ emitter.emit(channel, payload);
113
+ }
114
+ log.info(`Dispatched ${entries.length} cursor-tracked event(s) to PgSubscriber`);
115
+ }
116
+ /**
117
+ * Access PgSubscriber's internal EventEmitter.
118
+ *
119
+ * PgSubscriber from @dataplan/pg stores an EventEmitter3 instance as
120
+ * `this.eventEmitter`. It is private but stable across v1.x releases.
121
+ * This is the same emitter that NOTIFY events are dispatched through.
122
+ */
123
+ getEventEmitter() {
124
+ const sub = this.subscriber;
125
+ if (sub && typeof sub === 'object' && 'eventEmitter' in sub) {
126
+ const ee = sub.eventEmitter;
127
+ if (typeof ee?.emit === 'function') {
128
+ return ee;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+ }
134
+ export { extractRowId, entryToNotifyPayload, entryToChannel };
package/esm/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
+ }
package/index.d.ts CHANGED
@@ -18,4 +18,7 @@
18
18
  */
19
19
  export { createRealtimeSubscriptionsPlugin, RealtimeSubscriptionsPlugin } from './plugin';
20
20
  export { RealtimeSubscriptionsPreset } from './preset';
21
+ export { CursorTracker } from './cursor-tracker';
22
+ export { RealtimeManager } from './realtime-manager';
21
23
  export type { RealtimeSubscriptionsPluginOptions } from './types';
24
+ export type { CursorTrackerOptions, ChangeLogEntry, PgClient, WithPgClient, RealtimeManagerOptions, } from './types';
package/index.js CHANGED
@@ -18,9 +18,13 @@
18
18
  * ```
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.RealtimeSubscriptionsPreset = exports.RealtimeSubscriptionsPlugin = exports.createRealtimeSubscriptionsPlugin = void 0;
21
+ exports.RealtimeManager = exports.CursorTracker = exports.RealtimeSubscriptionsPreset = exports.RealtimeSubscriptionsPlugin = exports.createRealtimeSubscriptionsPlugin = void 0;
22
22
  var plugin_1 = require("./plugin");
23
23
  Object.defineProperty(exports, "createRealtimeSubscriptionsPlugin", { enumerable: true, get: function () { return plugin_1.createRealtimeSubscriptionsPlugin; } });
24
24
  Object.defineProperty(exports, "RealtimeSubscriptionsPlugin", { enumerable: true, get: function () { return plugin_1.RealtimeSubscriptionsPlugin; } });
25
25
  var preset_1 = require("./preset");
26
26
  Object.defineProperty(exports, "RealtimeSubscriptionsPreset", { enumerable: true, get: function () { return preset_1.RealtimeSubscriptionsPreset; } });
27
+ var cursor_tracker_1 = require("./cursor-tracker");
28
+ Object.defineProperty(exports, "CursorTracker", { enumerable: true, get: function () { return cursor_tracker_1.CursorTracker; } });
29
+ var realtime_manager_1 = require("./realtime-manager");
30
+ Object.defineProperty(exports, "RealtimeManager", { enumerable: true, get: function () { return realtime_manager_1.RealtimeManager; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-realtime-subscriptions",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Realtime subscription plugin for PostGraphile v5 — per-table GraphQL subscriptions via LISTEN/NOTIFY",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -40,7 +40,8 @@
40
40
  "url": "https://github.com/constructive-io/constructive/issues"
41
41
  },
42
42
  "dependencies": {
43
- "@pgpmjs/logger": "^2.9.0"
43
+ "@pgpmjs/logger": "^2.10.0",
44
+ "@pgsql/quotes": "^17.1.0"
44
45
  },
45
46
  "peerDependencies": {
46
47
  "grafast": "1.0.0",
@@ -55,5 +56,5 @@
55
56
  "@types/node": "^22.19.11",
56
57
  "makage": "^0.3.0"
57
58
  },
58
- "gitHead": "4a575e3e29827e96979cb6aac2c88eb7a07f7fd0"
59
+ "gitHead": "c665e2443ec98da61e9f372cf663cb9973afef4b"
59
60
  }
package/plugin.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * for real-time event delivery.
7
7
  *
8
8
  * Subscription modes:
9
- * - Single record: onXxxChanged(id: UUID!) — subscribe to changes on one row
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
12
  * NOTIFY payload format (from emit_change trigger):
@@ -20,13 +20,30 @@
20
20
  * 4. The plugin parses the payload and fetches the specific changed row(s)
21
21
  * 5. The client receives { event, row, rowId, overflow }
22
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.
31
+ *
23
32
  * Overflow protection:
24
33
  * - Database-side: statements affecting > 50 rows send INVALIDATE
25
34
  * - Plugin-side: per-subscriber throttle (default 50 events/second/table)
26
35
  * drops individual events and sends a single INVALIDATE when exceeded
27
36
  *
28
- * RLS enforcement is automatic — resource.get() queries through the
29
- * authenticated user's connection with their JWT role applied.
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.
30
47
  */
31
48
  import type { GraphileConfig } from 'graphile-config';
32
49
  import type { RealtimeSubscriptionsPluginOptions } from './types';
@@ -61,4 +78,7 @@ declare class EventThrottle {
61
78
  }
62
79
  export declare function createRealtimeSubscriptionsPlugin(options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
63
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';
64
84
  export { parseNotifyPayload, EventThrottle, DEFAULT_OVERFLOW_THRESHOLD };
package/plugin.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * for real-time event delivery.
8
8
  *
9
9
  * Subscription modes:
10
- * - Single record: onXxxChanged(id: UUID!) — subscribe to changes on one row
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
13
  * NOTIFY payload format (from emit_change trigger):
@@ -21,16 +21,33 @@
21
21
  * 4. The plugin parses the payload and fetches the specific changed row(s)
22
22
  * 5. The client receives { event, row, rowId, overflow }
23
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
+ *
24
33
  * Overflow protection:
25
34
  * - Database-side: statements affecting > 50 rows send INVALIDATE
26
35
  * - Plugin-side: per-subscriber throttle (default 50 events/second/table)
27
36
  * drops individual events and sends a single INVALIDATE when exceeded
28
37
  *
29
- * RLS enforcement is automatic — resource.get() queries through the
30
- * authenticated user's connection with their JWT role applied.
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.
31
48
  */
32
49
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.DEFAULT_OVERFLOW_THRESHOLD = exports.EventThrottle = void 0;
50
+ exports.DEFAULT_OVERFLOW_THRESHOLD = exports.EventThrottle = exports.RealtimeManager = exports.CursorTracker = void 0;
34
51
  exports.createRealtimeSubscriptionsPlugin = createRealtimeSubscriptionsPlugin;
35
52
  exports.RealtimeSubscriptionsPlugin = createRealtimeSubscriptionsPlugin;
36
53
  exports.parseNotifyPayload = parseNotifyPayload;
@@ -129,7 +146,7 @@ function discoverRealtimeTables(build) {
129
146
  }
130
147
  function buildTypeDefs(tables) {
131
148
  const subscriptionFields = tables
132
- .map(({ fieldName, payloadTypeName }) => ` """Subscribe to changes on this table. Pass an id to watch a specific record."""\n ${fieldName}(id: UUID): ${payloadTypeName}`)
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}`)
133
150
  .join('\n');
134
151
  const payloadTypes = tables
135
152
  .map(({ payloadTypeName, typeName, rowFieldName }) => `"""Payload delivered when a ${typeName} row changes."""\n` +
@@ -138,7 +155,7 @@ function buildTypeDefs(tables) {
138
155
  ` event: String!\n` +
139
156
  ` """The current state of the row (null for DELETE, INVALIDATE, or if RLS denies access)."""\n` +
140
157
  ` ${rowFieldName}: ${typeName}\n` +
141
- ` """The ID of the changed row (null for INVALIDATE)."""\n` +
158
+ ` """The ID of the changed row (null for INVALIDATE, or masked when RLS denies access)."""\n` +
142
159
  ` rowId: UUID\n` +
143
160
  ` """True when too many changes occurred and the client should refetch."""\n` +
144
161
  ` overflow: Boolean!\n` +
@@ -155,9 +172,10 @@ function buildPlans(tables, overflowThreshold) {
155
172
  subscribePlan(_$root, args) {
156
173
  const $pgSubscriber = (0, grafast_1.context)().get('pgSubscriber');
157
174
  const $topic = (0, grafast_1.constant)(notifyChannel);
158
- const $id = args.get('id');
175
+ const $ids = args.get('ids');
159
176
  return (0, grafast_1.listen)($pgSubscriber, $topic, ($payload) => {
160
- const $parsed = (0, grafast_1.lambda)($payload, (raw) => {
177
+ const $parsed = (0, grafast_1.lambda)([$payload, $ids], (pair) => {
178
+ const [raw, subscribedIds] = pair;
161
179
  const parsed = parseNotifyPayload(String(raw));
162
180
  const action = parsed.overflow ? 'deliver' : throttle.check();
163
181
  if (action === 'drop') {
@@ -170,11 +188,17 @@ function buildPlans(tables, overflowThreshold) {
170
188
  overflow: true,
171
189
  };
172
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
+ }
173
197
  return parsed;
174
198
  });
175
199
  return (0, grafast_1.object)({
176
200
  parsed: $parsed,
177
- subscribedId: $id,
201
+ subscribedIds: $ids,
178
202
  });
179
203
  });
180
204
  },
@@ -189,11 +213,16 @@ function buildPlans(tables, overflowThreshold) {
189
213
  },
190
214
  rowId($parent) {
191
215
  const $parsed = $parent.get('parsed');
192
- return (0, grafast_1.lambda)($parsed, (p) => {
193
- const parsed = p;
194
- if (!parsed || parsed.overflow || parsed.rowIds.length === 0)
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)
195
220
  return null;
196
- return parsed.rowIds[0];
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];
197
226
  });
198
227
  },
199
228
  overflow($parent) {
@@ -202,13 +231,16 @@ function buildPlans(tables, overflowThreshold) {
202
231
  },
203
232
  [rowFieldName]($parent) {
204
233
  const $parsed = $parent.get('parsed');
205
- const $subscribedId = $parent.get('subscribedId');
206
- const $rowId = (0, grafast_1.lambda)([$parsed, $subscribedId], (pair) => {
207
- const [p, subscribedId] = pair;
208
- if (subscribedId)
209
- return subscribedId;
234
+ const $subscribedIds = $parent.get('subscribedIds');
235
+ const $rowId = (0, grafast_1.lambda)([$parsed, $subscribedIds], (tuple) => {
236
+ const [p, subscribedIds] = tuple;
210
237
  if (!p || p.overflow || p.rowIds.length === 0)
211
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
212
244
  return p.rowIds[0];
213
245
  });
214
246
  return resource.get({ id: $rowId });
@@ -233,3 +265,8 @@ function createRealtimeSubscriptionsPlugin(options = {}) {
233
265
  return { typeDefs, plans };
234
266
  }, 'RealtimeSubscriptionsPlugin');
235
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 };