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.
@@ -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
@@ -18,3 +18,5 @@
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';
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 (Phase 3a):
9
- * - Single record: onXxxChanged(id: UUID!) — subscribe to changes on one row
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('realtime:{schema}.{table}', TG_OP)
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 subscription re-queries the source table with RLS enforced
17
- * 5. The client receives { event, row } where row reflects the current state
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
- * RLS enforcement is automatic — resource.get() queries through the
20
- * authenticated user's connection with their JWT role applied.
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
- export declare function createRealtimeSubscriptionsPlugin(_options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
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 };