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
|
@@ -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
|
@@ -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 };
|