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.
- 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 +23 -3
- package/esm/plugin.js +52 -17
- package/esm/realtime-manager.d.ts +67 -0
- package/esm/realtime-manager.js +134 -0
- package/esm/types.d.ts +132 -0
- package/index.d.ts +3 -0
- package/index.js +5 -1
- package/package.json +4 -3
- package/plugin.d.ts +23 -3
- package/plugin.js +55 -18
- package/realtime-manager.d.ts +67 -0
- package/realtime-manager.js +140 -0
- package/types.d.ts +132 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CursorTracker — manages the listener_node lifecycle and periodic
|
|
3
|
+
* drain_changes() polling for at-least-once event delivery.
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle:
|
|
6
|
+
* 1. start() → calls touch_listener() to register/heartbeat the node
|
|
7
|
+
* 2. Periodically polls drain_changes() for new change_log entries
|
|
8
|
+
* 3. Periodically heartbeats via touch_listener()
|
|
9
|
+
* 4. stop() → calls cleanup_ephemeral() to remove ephemeral subscriptions
|
|
10
|
+
* and delete the listener_node row
|
|
11
|
+
*
|
|
12
|
+
* The caller provides a withPgClient callback that acquires a PgClient
|
|
13
|
+
* for each operation. This keeps connection management external.
|
|
14
|
+
*/
|
|
15
|
+
import type { CursorTrackerOptions, ChangeLogEntry } from './types';
|
|
16
|
+
declare const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
17
|
+
declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
|
|
18
|
+
declare const DEFAULT_BATCH_LIMIT = 500;
|
|
19
|
+
declare const DEFAULT_SCHEMA = "realtime_public";
|
|
20
|
+
export declare class CursorTracker {
|
|
21
|
+
readonly nodeId: string;
|
|
22
|
+
private readonly schema;
|
|
23
|
+
private readonly pollIntervalMs;
|
|
24
|
+
private readonly heartbeatIntervalMs;
|
|
25
|
+
private readonly batchLimit;
|
|
26
|
+
private readonly withPgClient;
|
|
27
|
+
private readonly onChanges;
|
|
28
|
+
private readonly onError;
|
|
29
|
+
private pollTimer;
|
|
30
|
+
private heartbeatTimer;
|
|
31
|
+
private running;
|
|
32
|
+
private draining;
|
|
33
|
+
constructor(options: CursorTrackerOptions);
|
|
34
|
+
start(): Promise<void>;
|
|
35
|
+
stop(): Promise<void>;
|
|
36
|
+
drain(): Promise<ChangeLogEntry[]>;
|
|
37
|
+
touchListener(): Promise<void>;
|
|
38
|
+
cleanupEphemeral(): Promise<void>;
|
|
39
|
+
get isRunning(): boolean;
|
|
40
|
+
private quoteIdent;
|
|
41
|
+
}
|
|
42
|
+
export { DEFAULT_POLL_INTERVAL_MS, DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_BATCH_LIMIT, DEFAULT_SCHEMA, };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CursorTracker — manages the listener_node lifecycle and periodic
|
|
4
|
+
* drain_changes() polling for at-least-once event delivery.
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle:
|
|
7
|
+
* 1. start() → calls touch_listener() to register/heartbeat the node
|
|
8
|
+
* 2. Periodically polls drain_changes() for new change_log entries
|
|
9
|
+
* 3. Periodically heartbeats via touch_listener()
|
|
10
|
+
* 4. stop() → calls cleanup_ephemeral() to remove ephemeral subscriptions
|
|
11
|
+
* and delete the listener_node row
|
|
12
|
+
*
|
|
13
|
+
* The caller provides a withPgClient callback that acquires a PgClient
|
|
14
|
+
* for each operation. This keeps connection management external.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.DEFAULT_SCHEMA = exports.DEFAULT_BATCH_LIMIT = exports.DEFAULT_HEARTBEAT_INTERVAL_MS = exports.DEFAULT_POLL_INTERVAL_MS = exports.CursorTracker = void 0;
|
|
18
|
+
const crypto_1 = require("crypto");
|
|
19
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
20
|
+
const quotes_1 = require("@pgsql/quotes");
|
|
21
|
+
const log = new logger_1.Logger('cursor-tracker');
|
|
22
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
23
|
+
exports.DEFAULT_POLL_INTERVAL_MS = DEFAULT_POLL_INTERVAL_MS;
|
|
24
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
|
|
25
|
+
exports.DEFAULT_HEARTBEAT_INTERVAL_MS = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
26
|
+
const DEFAULT_BATCH_LIMIT = 500;
|
|
27
|
+
exports.DEFAULT_BATCH_LIMIT = DEFAULT_BATCH_LIMIT;
|
|
28
|
+
const DEFAULT_SCHEMA = 'realtime_public';
|
|
29
|
+
exports.DEFAULT_SCHEMA = DEFAULT_SCHEMA;
|
|
30
|
+
class CursorTracker {
|
|
31
|
+
nodeId;
|
|
32
|
+
schema;
|
|
33
|
+
pollIntervalMs;
|
|
34
|
+
heartbeatIntervalMs;
|
|
35
|
+
batchLimit;
|
|
36
|
+
withPgClient;
|
|
37
|
+
onChanges;
|
|
38
|
+
onError;
|
|
39
|
+
pollTimer = null;
|
|
40
|
+
heartbeatTimer = null;
|
|
41
|
+
running = false;
|
|
42
|
+
draining = false;
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.nodeId = options.nodeId ?? (0, crypto_1.randomUUID)();
|
|
45
|
+
this.schema = options.schema ?? DEFAULT_SCHEMA;
|
|
46
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
47
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
48
|
+
this.batchLimit = options.batchLimit ?? DEFAULT_BATCH_LIMIT;
|
|
49
|
+
this.withPgClient = options.withPgClient;
|
|
50
|
+
this.onChanges = options.onChanges ?? (() => { });
|
|
51
|
+
this.onError = options.onError ?? ((err) => {
|
|
52
|
+
log.error(`CursorTracker error: ${err.message}`);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async start() {
|
|
56
|
+
if (this.running)
|
|
57
|
+
return;
|
|
58
|
+
this.running = true;
|
|
59
|
+
log.info(`Starting cursor tracker: node=${this.nodeId}, schema=${this.schema}`);
|
|
60
|
+
await this.touchListener();
|
|
61
|
+
// Initial drain immediately after registration
|
|
62
|
+
await this.drain();
|
|
63
|
+
this.pollTimer = setInterval(() => {
|
|
64
|
+
void this.drain();
|
|
65
|
+
}, this.pollIntervalMs);
|
|
66
|
+
this.heartbeatTimer = setInterval(() => {
|
|
67
|
+
void this.touchListener();
|
|
68
|
+
}, this.heartbeatIntervalMs);
|
|
69
|
+
}
|
|
70
|
+
async stop() {
|
|
71
|
+
if (!this.running)
|
|
72
|
+
return;
|
|
73
|
+
this.running = false;
|
|
74
|
+
log.info(`Stopping cursor tracker: node=${this.nodeId}`);
|
|
75
|
+
if (this.pollTimer) {
|
|
76
|
+
clearInterval(this.pollTimer);
|
|
77
|
+
this.pollTimer = null;
|
|
78
|
+
}
|
|
79
|
+
if (this.heartbeatTimer) {
|
|
80
|
+
clearInterval(this.heartbeatTimer);
|
|
81
|
+
this.heartbeatTimer = null;
|
|
82
|
+
}
|
|
83
|
+
await this.cleanupEphemeral();
|
|
84
|
+
}
|
|
85
|
+
async drain() {
|
|
86
|
+
if (this.draining)
|
|
87
|
+
return [];
|
|
88
|
+
this.draining = true;
|
|
89
|
+
try {
|
|
90
|
+
const entries = await this.withPgClient(async (client) => {
|
|
91
|
+
const sql = `SELECT * FROM ${this.quoteIdent(this.schema)}.drain_changes($1, $2)`;
|
|
92
|
+
const result = await client.query(sql, [this.nodeId, this.batchLimit]);
|
|
93
|
+
return result.rows.map((row) => row.drain_changes);
|
|
94
|
+
});
|
|
95
|
+
if (entries.length > 0) {
|
|
96
|
+
log.info(`Drained ${entries.length} change(s) for node=${this.nodeId}`);
|
|
97
|
+
this.onChanges(entries);
|
|
98
|
+
}
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
this.draining = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async touchListener() {
|
|
110
|
+
try {
|
|
111
|
+
await this.withPgClient(async (client) => {
|
|
112
|
+
const sql = `SELECT ${this.quoteIdent(this.schema)}.touch_listener($1)`;
|
|
113
|
+
await client.query(sql, [this.nodeId]);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async cleanupEphemeral() {
|
|
121
|
+
try {
|
|
122
|
+
await this.withPgClient(async (client) => {
|
|
123
|
+
const sql = `SELECT ${this.quoteIdent(this.schema)}.cleanup_ephemeral($1)`;
|
|
124
|
+
await client.query(sql, [this.nodeId]);
|
|
125
|
+
});
|
|
126
|
+
log.info(`Cleaned up ephemeral subscriptions for node=${this.nodeId}`);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
get isRunning() {
|
|
133
|
+
return this.running;
|
|
134
|
+
}
|
|
135
|
+
quoteIdent(identifier) {
|
|
136
|
+
return quotes_1.QuoteUtils.quoteIdentifier(identifier);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
exports.CursorTracker = CursorTracker;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CursorTracker — manages the listener_node lifecycle and periodic
|
|
3
|
+
* drain_changes() polling for at-least-once event delivery.
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle:
|
|
6
|
+
* 1. start() → calls touch_listener() to register/heartbeat the node
|
|
7
|
+
* 2. Periodically polls drain_changes() for new change_log entries
|
|
8
|
+
* 3. Periodically heartbeats via touch_listener()
|
|
9
|
+
* 4. stop() → calls cleanup_ephemeral() to remove ephemeral subscriptions
|
|
10
|
+
* and delete the listener_node row
|
|
11
|
+
*
|
|
12
|
+
* The caller provides a withPgClient callback that acquires a PgClient
|
|
13
|
+
* for each operation. This keeps connection management external.
|
|
14
|
+
*/
|
|
15
|
+
import type { CursorTrackerOptions, ChangeLogEntry } from './types';
|
|
16
|
+
declare const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
17
|
+
declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
|
|
18
|
+
declare const DEFAULT_BATCH_LIMIT = 500;
|
|
19
|
+
declare const DEFAULT_SCHEMA = "realtime_public";
|
|
20
|
+
export declare class CursorTracker {
|
|
21
|
+
readonly nodeId: string;
|
|
22
|
+
private readonly schema;
|
|
23
|
+
private readonly pollIntervalMs;
|
|
24
|
+
private readonly heartbeatIntervalMs;
|
|
25
|
+
private readonly batchLimit;
|
|
26
|
+
private readonly withPgClient;
|
|
27
|
+
private readonly onChanges;
|
|
28
|
+
private readonly onError;
|
|
29
|
+
private pollTimer;
|
|
30
|
+
private heartbeatTimer;
|
|
31
|
+
private running;
|
|
32
|
+
private draining;
|
|
33
|
+
constructor(options: CursorTrackerOptions);
|
|
34
|
+
start(): Promise<void>;
|
|
35
|
+
stop(): Promise<void>;
|
|
36
|
+
drain(): Promise<ChangeLogEntry[]>;
|
|
37
|
+
touchListener(): Promise<void>;
|
|
38
|
+
cleanupEphemeral(): Promise<void>;
|
|
39
|
+
get isRunning(): boolean;
|
|
40
|
+
private quoteIdent;
|
|
41
|
+
}
|
|
42
|
+
export { DEFAULT_POLL_INTERVAL_MS, DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_BATCH_LIMIT, DEFAULT_SCHEMA, };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CursorTracker — manages the listener_node lifecycle and periodic
|
|
3
|
+
* drain_changes() polling for at-least-once event delivery.
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle:
|
|
6
|
+
* 1. start() → calls touch_listener() to register/heartbeat the node
|
|
7
|
+
* 2. Periodically polls drain_changes() for new change_log entries
|
|
8
|
+
* 3. Periodically heartbeats via touch_listener()
|
|
9
|
+
* 4. stop() → calls cleanup_ephemeral() to remove ephemeral subscriptions
|
|
10
|
+
* and delete the listener_node row
|
|
11
|
+
*
|
|
12
|
+
* The caller provides a withPgClient callback that acquires a PgClient
|
|
13
|
+
* for each operation. This keeps connection management external.
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import { Logger } from '@pgpmjs/logger';
|
|
17
|
+
import { QuoteUtils } from '@pgsql/quotes';
|
|
18
|
+
const log = new Logger('cursor-tracker');
|
|
19
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
20
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
|
|
21
|
+
const DEFAULT_BATCH_LIMIT = 500;
|
|
22
|
+
const DEFAULT_SCHEMA = 'realtime_public';
|
|
23
|
+
export class CursorTracker {
|
|
24
|
+
nodeId;
|
|
25
|
+
schema;
|
|
26
|
+
pollIntervalMs;
|
|
27
|
+
heartbeatIntervalMs;
|
|
28
|
+
batchLimit;
|
|
29
|
+
withPgClient;
|
|
30
|
+
onChanges;
|
|
31
|
+
onError;
|
|
32
|
+
pollTimer = null;
|
|
33
|
+
heartbeatTimer = null;
|
|
34
|
+
running = false;
|
|
35
|
+
draining = false;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.nodeId = options.nodeId ?? randomUUID();
|
|
38
|
+
this.schema = options.schema ?? DEFAULT_SCHEMA;
|
|
39
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
40
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
41
|
+
this.batchLimit = options.batchLimit ?? DEFAULT_BATCH_LIMIT;
|
|
42
|
+
this.withPgClient = options.withPgClient;
|
|
43
|
+
this.onChanges = options.onChanges ?? (() => { });
|
|
44
|
+
this.onError = options.onError ?? ((err) => {
|
|
45
|
+
log.error(`CursorTracker error: ${err.message}`);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async start() {
|
|
49
|
+
if (this.running)
|
|
50
|
+
return;
|
|
51
|
+
this.running = true;
|
|
52
|
+
log.info(`Starting cursor tracker: node=${this.nodeId}, schema=${this.schema}`);
|
|
53
|
+
await this.touchListener();
|
|
54
|
+
// Initial drain immediately after registration
|
|
55
|
+
await this.drain();
|
|
56
|
+
this.pollTimer = setInterval(() => {
|
|
57
|
+
void this.drain();
|
|
58
|
+
}, this.pollIntervalMs);
|
|
59
|
+
this.heartbeatTimer = setInterval(() => {
|
|
60
|
+
void this.touchListener();
|
|
61
|
+
}, this.heartbeatIntervalMs);
|
|
62
|
+
}
|
|
63
|
+
async stop() {
|
|
64
|
+
if (!this.running)
|
|
65
|
+
return;
|
|
66
|
+
this.running = false;
|
|
67
|
+
log.info(`Stopping cursor tracker: node=${this.nodeId}`);
|
|
68
|
+
if (this.pollTimer) {
|
|
69
|
+
clearInterval(this.pollTimer);
|
|
70
|
+
this.pollTimer = null;
|
|
71
|
+
}
|
|
72
|
+
if (this.heartbeatTimer) {
|
|
73
|
+
clearInterval(this.heartbeatTimer);
|
|
74
|
+
this.heartbeatTimer = null;
|
|
75
|
+
}
|
|
76
|
+
await this.cleanupEphemeral();
|
|
77
|
+
}
|
|
78
|
+
async drain() {
|
|
79
|
+
if (this.draining)
|
|
80
|
+
return [];
|
|
81
|
+
this.draining = true;
|
|
82
|
+
try {
|
|
83
|
+
const entries = await this.withPgClient(async (client) => {
|
|
84
|
+
const sql = `SELECT * FROM ${this.quoteIdent(this.schema)}.drain_changes($1, $2)`;
|
|
85
|
+
const result = await client.query(sql, [this.nodeId, this.batchLimit]);
|
|
86
|
+
return result.rows.map((row) => row.drain_changes);
|
|
87
|
+
});
|
|
88
|
+
if (entries.length > 0) {
|
|
89
|
+
log.info(`Drained ${entries.length} change(s) for node=${this.nodeId}`);
|
|
90
|
+
this.onChanges(entries);
|
|
91
|
+
}
|
|
92
|
+
return entries;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
this.draining = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async touchListener() {
|
|
103
|
+
try {
|
|
104
|
+
await this.withPgClient(async (client) => {
|
|
105
|
+
const sql = `SELECT ${this.quoteIdent(this.schema)}.touch_listener($1)`;
|
|
106
|
+
await client.query(sql, [this.nodeId]);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async cleanupEphemeral() {
|
|
114
|
+
try {
|
|
115
|
+
await this.withPgClient(async (client) => {
|
|
116
|
+
const sql = `SELECT ${this.quoteIdent(this.schema)}.cleanup_ephemeral($1)`;
|
|
117
|
+
await client.query(sql, [this.nodeId]);
|
|
118
|
+
});
|
|
119
|
+
log.info(`Cleaned up ephemeral subscriptions for node=${this.nodeId}`);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
get isRunning() {
|
|
126
|
+
return this.running;
|
|
127
|
+
}
|
|
128
|
+
quoteIdent(identifier) {
|
|
129
|
+
return QuoteUtils.quoteIdentifier(identifier);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export { DEFAULT_POLL_INTERVAL_MS, DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_BATCH_LIMIT, DEFAULT_SCHEMA, };
|
package/esm/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/esm/index.js
CHANGED
package/esm/plugin.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* for real-time event delivery.
|
|
7
7
|
*
|
|
8
8
|
* Subscription modes:
|
|
9
|
-
* -
|
|
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
|
|
29
|
-
*
|
|
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/esm/plugin.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* for real-time event delivery.
|
|
7
7
|
*
|
|
8
8
|
* Subscription modes:
|
|
9
|
-
* -
|
|
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
|
|
29
|
-
*
|
|
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 { context as grafastContext, listen, object, constant, lambda } from 'grafast';
|
|
32
49
|
import { extendSchema } from 'graphile-utils';
|
|
@@ -121,7 +138,7 @@ function discoverRealtimeTables(build) {
|
|
|
121
138
|
}
|
|
122
139
|
function buildTypeDefs(tables) {
|
|
123
140
|
const subscriptionFields = tables
|
|
124
|
-
.map(({ fieldName, payloadTypeName }) => ` """Subscribe to changes on this table. Pass
|
|
141
|
+
.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}`)
|
|
125
142
|
.join('\n');
|
|
126
143
|
const payloadTypes = tables
|
|
127
144
|
.map(({ payloadTypeName, typeName, rowFieldName }) => `"""Payload delivered when a ${typeName} row changes."""\n` +
|
|
@@ -130,7 +147,7 @@ function buildTypeDefs(tables) {
|
|
|
130
147
|
` event: String!\n` +
|
|
131
148
|
` """The current state of the row (null for DELETE, INVALIDATE, or if RLS denies access)."""\n` +
|
|
132
149
|
` ${rowFieldName}: ${typeName}\n` +
|
|
133
|
-
` """The ID of the changed row (null for INVALIDATE)."""\n` +
|
|
150
|
+
` """The ID of the changed row (null for INVALIDATE, or masked when RLS denies access)."""\n` +
|
|
134
151
|
` rowId: UUID\n` +
|
|
135
152
|
` """True when too many changes occurred and the client should refetch."""\n` +
|
|
136
153
|
` overflow: Boolean!\n` +
|
|
@@ -147,9 +164,10 @@ function buildPlans(tables, overflowThreshold) {
|
|
|
147
164
|
subscribePlan(_$root, args) {
|
|
148
165
|
const $pgSubscriber = grafastContext().get('pgSubscriber');
|
|
149
166
|
const $topic = constant(notifyChannel);
|
|
150
|
-
const $
|
|
167
|
+
const $ids = args.get('ids');
|
|
151
168
|
return listen($pgSubscriber, $topic, ($payload) => {
|
|
152
|
-
const $parsed = lambda($payload, (
|
|
169
|
+
const $parsed = lambda([$payload, $ids], (pair) => {
|
|
170
|
+
const [raw, subscribedIds] = pair;
|
|
153
171
|
const parsed = parseNotifyPayload(String(raw));
|
|
154
172
|
const action = parsed.overflow ? 'deliver' : throttle.check();
|
|
155
173
|
if (action === 'drop') {
|
|
@@ -162,11 +180,17 @@ function buildPlans(tables, overflowThreshold) {
|
|
|
162
180
|
overflow: true,
|
|
163
181
|
};
|
|
164
182
|
}
|
|
183
|
+
// Sparse set filtering: only deliver events for subscribed row IDs
|
|
184
|
+
if (subscribedIds && subscribedIds.length > 0) {
|
|
185
|
+
const hasMatch = parsed.rowIds.some((rid) => subscribedIds.includes(rid));
|
|
186
|
+
if (!hasMatch)
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
165
189
|
return parsed;
|
|
166
190
|
});
|
|
167
191
|
return object({
|
|
168
192
|
parsed: $parsed,
|
|
169
|
-
|
|
193
|
+
subscribedIds: $ids,
|
|
170
194
|
});
|
|
171
195
|
});
|
|
172
196
|
},
|
|
@@ -181,11 +205,16 @@ function buildPlans(tables, overflowThreshold) {
|
|
|
181
205
|
},
|
|
182
206
|
rowId($parent) {
|
|
183
207
|
const $parsed = $parent.get('parsed');
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
208
|
+
const $subscribedIds = $parent.get('subscribedIds');
|
|
209
|
+
return lambda([$parsed, $subscribedIds], (pair) => {
|
|
210
|
+
const [p, subscribedIds] = pair;
|
|
211
|
+
if (!p || p.overflow || p.rowIds.length === 0)
|
|
187
212
|
return null;
|
|
188
|
-
return
|
|
213
|
+
// When ids are provided, return the first matching row ID
|
|
214
|
+
if (subscribedIds && subscribedIds.length > 0) {
|
|
215
|
+
return p.rowIds.find((rid) => subscribedIds.includes(rid)) ?? null;
|
|
216
|
+
}
|
|
217
|
+
return p.rowIds[0];
|
|
189
218
|
});
|
|
190
219
|
},
|
|
191
220
|
overflow($parent) {
|
|
@@ -194,13 +223,16 @@ function buildPlans(tables, overflowThreshold) {
|
|
|
194
223
|
},
|
|
195
224
|
[rowFieldName]($parent) {
|
|
196
225
|
const $parsed = $parent.get('parsed');
|
|
197
|
-
const $
|
|
198
|
-
const $rowId = lambda([$parsed, $
|
|
199
|
-
const [p,
|
|
200
|
-
if (subscribedId)
|
|
201
|
-
return subscribedId;
|
|
226
|
+
const $subscribedIds = $parent.get('subscribedIds');
|
|
227
|
+
const $rowId = lambda([$parsed, $subscribedIds], (tuple) => {
|
|
228
|
+
const [p, subscribedIds] = tuple;
|
|
202
229
|
if (!p || p.overflow || p.rowIds.length === 0)
|
|
203
230
|
return null;
|
|
231
|
+
// When ids are provided, return first matching row ID
|
|
232
|
+
if (subscribedIds && subscribedIds.length > 0) {
|
|
233
|
+
return p.rowIds.find((rid) => subscribedIds.includes(rid)) ?? null;
|
|
234
|
+
}
|
|
235
|
+
// Full collection mode: return first row ID
|
|
204
236
|
return p.rowIds[0];
|
|
205
237
|
});
|
|
206
238
|
return resource.get({ id: $rowId });
|
|
@@ -226,5 +258,8 @@ export function createRealtimeSubscriptionsPlugin(options = {}) {
|
|
|
226
258
|
}, 'RealtimeSubscriptionsPlugin');
|
|
227
259
|
}
|
|
228
260
|
export { createRealtimeSubscriptionsPlugin as RealtimeSubscriptionsPlugin };
|
|
261
|
+
// Re-export CursorTracker and RealtimeManager for convenience
|
|
262
|
+
export { CursorTracker } from './cursor-tracker';
|
|
263
|
+
export { RealtimeManager } from './realtime-manager';
|
|
229
264
|
// Exported for testing
|
|
230
265
|
export { parseNotifyPayload, EventThrottle, DEFAULT_OVERFLOW_THRESHOLD };
|
|
@@ -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 };
|