graphile-realtime-subscriptions 0.2.0 → 0.3.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/esm/plugin.d.ts +44 -5
- package/esm/plugin.js +124 -14
- package/esm/types.d.ts +9 -4
- package/package.json +2 -2
- package/plugin.d.ts +44 -5
- package/plugin.js +125 -13
- package/types.d.ts +9 -4
package/esm/plugin.d.ts
CHANGED
|
@@ -5,21 +5,60 @@
|
|
|
5
5
|
* subscription fields (onXxxChanged) that use PostgreSQL LISTEN/NOTIFY
|
|
6
6
|
* for real-time event delivery.
|
|
7
7
|
*
|
|
8
|
-
* Subscription modes
|
|
8
|
+
* Subscription modes:
|
|
9
9
|
* - Single record: onXxxChanged(id: UUID!) — subscribe to changes on one row
|
|
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
|
+
* Overflow protection:
|
|
24
|
+
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
25
|
+
* - Plugin-side: per-subscriber throttle (default 50 events/second/table)
|
|
26
|
+
* drops individual events and sends a single INVALIDATE when exceeded
|
|
18
27
|
*
|
|
19
28
|
* RLS enforcement is automatic — resource.get() queries through the
|
|
20
29
|
* authenticated user's connection with their JWT role applied.
|
|
21
30
|
*/
|
|
22
31
|
import type { GraphileConfig } from 'graphile-config';
|
|
23
32
|
import type { RealtimeSubscriptionsPluginOptions } from './types';
|
|
24
|
-
|
|
33
|
+
/** Default overflow threshold: events per second per table per subscriber */
|
|
34
|
+
declare const DEFAULT_OVERFLOW_THRESHOLD = 50;
|
|
35
|
+
interface ParsedPayload {
|
|
36
|
+
event: string;
|
|
37
|
+
rowIds: string[];
|
|
38
|
+
overflow: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse the NOTIFY payload from emit_change.
|
|
42
|
+
* Format: "TG_OP:id1,id2,..." or "INVALIDATE"
|
|
43
|
+
*/
|
|
44
|
+
declare function parseNotifyPayload(raw: string): ParsedPayload;
|
|
45
|
+
/**
|
|
46
|
+
* Per-subscriber, per-table event rate tracker.
|
|
47
|
+
* Counts events in a sliding 1-second window.
|
|
48
|
+
*/
|
|
49
|
+
declare class EventThrottle {
|
|
50
|
+
private readonly threshold;
|
|
51
|
+
private windowStart;
|
|
52
|
+
private eventCount;
|
|
53
|
+
private overflowSent;
|
|
54
|
+
constructor(threshold: number);
|
|
55
|
+
/**
|
|
56
|
+
* Record an event and return whether it should be delivered.
|
|
57
|
+
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
58
|
+
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
59
|
+
*/
|
|
60
|
+
check(): 'deliver' | 'overflow' | 'drop';
|
|
61
|
+
}
|
|
62
|
+
export declare function createRealtimeSubscriptionsPlugin(options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
|
|
25
63
|
export { createRealtimeSubscriptionsPlugin as RealtimeSubscriptionsPlugin };
|
|
64
|
+
export { parseNotifyPayload, EventThrottle, DEFAULT_OVERFLOW_THRESHOLD };
|
package/esm/plugin.js
CHANGED
|
@@ -5,24 +5,87 @@
|
|
|
5
5
|
* subscription fields (onXxxChanged) that use PostgreSQL LISTEN/NOTIFY
|
|
6
6
|
* for real-time event delivery.
|
|
7
7
|
*
|
|
8
|
-
* Subscription modes
|
|
8
|
+
* Subscription modes:
|
|
9
9
|
* - Single record: onXxxChanged(id: UUID!) — subscribe to changes on one row
|
|
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
|
+
* Overflow protection:
|
|
24
|
+
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
25
|
+
* - Plugin-side: per-subscriber throttle (default 50 events/second/table)
|
|
26
|
+
* drops individual events and sends a single INVALIDATE when exceeded
|
|
18
27
|
*
|
|
19
28
|
* RLS enforcement is automatic — resource.get() queries through the
|
|
20
29
|
* authenticated user's connection with their JWT role applied.
|
|
21
30
|
*/
|
|
22
|
-
import { context as grafastContext, listen, object, constant } from 'grafast';
|
|
31
|
+
import { context as grafastContext, listen, object, constant, lambda } from 'grafast';
|
|
23
32
|
import { extendSchema } from 'graphile-utils';
|
|
24
33
|
import { Logger } from '@pgpmjs/logger';
|
|
25
34
|
const log = new Logger('graphile-realtime-subscriptions');
|
|
35
|
+
/** Default overflow threshold: events per second per table per subscriber */
|
|
36
|
+
const DEFAULT_OVERFLOW_THRESHOLD = 50;
|
|
37
|
+
/**
|
|
38
|
+
* Parse the NOTIFY payload from emit_change.
|
|
39
|
+
* Format: "TG_OP:id1,id2,..." or "INVALIDATE"
|
|
40
|
+
*/
|
|
41
|
+
function parseNotifyPayload(raw) {
|
|
42
|
+
if (raw === 'INVALIDATE') {
|
|
43
|
+
return { event: 'INVALIDATE', rowIds: [], overflow: true };
|
|
44
|
+
}
|
|
45
|
+
const colonIdx = raw.indexOf(':');
|
|
46
|
+
if (colonIdx === -1) {
|
|
47
|
+
return { event: raw || 'UNKNOWN', rowIds: [], overflow: false };
|
|
48
|
+
}
|
|
49
|
+
const event = raw.substring(0, colonIdx);
|
|
50
|
+
const idsPart = raw.substring(colonIdx + 1);
|
|
51
|
+
const rowIds = idsPart.length > 0 ? idsPart.split(',') : [];
|
|
52
|
+
return { event, rowIds, overflow: false };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Per-subscriber, per-table event rate tracker.
|
|
56
|
+
* Counts events in a sliding 1-second window.
|
|
57
|
+
*/
|
|
58
|
+
class EventThrottle {
|
|
59
|
+
threshold;
|
|
60
|
+
windowStart = 0;
|
|
61
|
+
eventCount = 0;
|
|
62
|
+
overflowSent = false;
|
|
63
|
+
constructor(threshold) {
|
|
64
|
+
this.threshold = threshold;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Record an event and return whether it should be delivered.
|
|
68
|
+
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
69
|
+
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
70
|
+
*/
|
|
71
|
+
check() {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
if (now - this.windowStart >= 1000) {
|
|
74
|
+
this.windowStart = now;
|
|
75
|
+
this.eventCount = 0;
|
|
76
|
+
this.overflowSent = false;
|
|
77
|
+
}
|
|
78
|
+
this.eventCount++;
|
|
79
|
+
if (this.eventCount <= this.threshold) {
|
|
80
|
+
return 'deliver';
|
|
81
|
+
}
|
|
82
|
+
if (!this.overflowSent) {
|
|
83
|
+
this.overflowSent = true;
|
|
84
|
+
return 'overflow';
|
|
85
|
+
}
|
|
86
|
+
return 'drop';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
26
89
|
function discoverRealtimeTables(build) {
|
|
27
90
|
const { pgRegistry } = build.input;
|
|
28
91
|
const resources = pgRegistry.pgResources;
|
|
@@ -63,26 +126,46 @@ function buildTypeDefs(tables) {
|
|
|
63
126
|
const payloadTypes = tables
|
|
64
127
|
.map(({ payloadTypeName, typeName, rowFieldName }) => `"""Payload delivered when a ${typeName} row changes."""\n` +
|
|
65
128
|
`type ${payloadTypeName} {\n` +
|
|
66
|
-
` """The DML operation: INSERT, UPDATE, or
|
|
129
|
+
` """The DML operation: INSERT, UPDATE, DELETE, or INVALIDATE."""\n` +
|
|
67
130
|
` event: String!\n` +
|
|
68
|
-
` """The current state of the row (null for DELETE or if RLS denies access)."""\n` +
|
|
131
|
+
` """The current state of the row (null for DELETE, INVALIDATE, or if RLS denies access)."""\n` +
|
|
69
132
|
` ${rowFieldName}: ${typeName}\n` +
|
|
133
|
+
` """The ID of the changed row (null for INVALIDATE)."""\n` +
|
|
134
|
+
` rowId: UUID\n` +
|
|
135
|
+
` """True when too many changes occurred and the client should refetch."""\n` +
|
|
136
|
+
` overflow: Boolean!\n` +
|
|
70
137
|
`}`)
|
|
71
138
|
.join('\n\n');
|
|
72
139
|
return `extend type Subscription {\n${subscriptionFields}\n}\n\n${payloadTypes}`;
|
|
73
140
|
}
|
|
74
|
-
function buildPlans(tables) {
|
|
141
|
+
function buildPlans(tables, overflowThreshold) {
|
|
75
142
|
const subscriptionPlans = {};
|
|
76
143
|
const allPlans = {};
|
|
77
144
|
for (const { resource, fieldName, payloadTypeName, rowFieldName, notifyChannel } of tables) {
|
|
145
|
+
const throttle = new EventThrottle(overflowThreshold);
|
|
78
146
|
subscriptionPlans[fieldName] = {
|
|
79
147
|
subscribePlan(_$root, args) {
|
|
80
148
|
const $pgSubscriber = grafastContext().get('pgSubscriber');
|
|
81
149
|
const $topic = constant(notifyChannel);
|
|
82
150
|
const $id = args.get('id');
|
|
83
151
|
return listen($pgSubscriber, $topic, ($payload) => {
|
|
152
|
+
const $parsed = lambda($payload, (raw) => {
|
|
153
|
+
const parsed = parseNotifyPayload(String(raw));
|
|
154
|
+
const action = parsed.overflow ? 'deliver' : throttle.check();
|
|
155
|
+
if (action === 'drop') {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (action === 'overflow') {
|
|
159
|
+
return {
|
|
160
|
+
event: 'INVALIDATE',
|
|
161
|
+
rowIds: [],
|
|
162
|
+
overflow: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return parsed;
|
|
166
|
+
});
|
|
84
167
|
return object({
|
|
85
|
-
|
|
168
|
+
parsed: $parsed,
|
|
86
169
|
subscribedId: $id,
|
|
87
170
|
});
|
|
88
171
|
});
|
|
@@ -93,18 +176,42 @@ function buildPlans(tables) {
|
|
|
93
176
|
};
|
|
94
177
|
allPlans[payloadTypeName] = {
|
|
95
178
|
event($parent) {
|
|
96
|
-
|
|
179
|
+
const $parsed = $parent.get('parsed');
|
|
180
|
+
return lambda($parsed, (p) => p?.event ?? 'UNKNOWN');
|
|
181
|
+
},
|
|
182
|
+
rowId($parent) {
|
|
183
|
+
const $parsed = $parent.get('parsed');
|
|
184
|
+
return lambda($parsed, (p) => {
|
|
185
|
+
const parsed = p;
|
|
186
|
+
if (!parsed || parsed.overflow || parsed.rowIds.length === 0)
|
|
187
|
+
return null;
|
|
188
|
+
return parsed.rowIds[0];
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
overflow($parent) {
|
|
192
|
+
const $parsed = $parent.get('parsed');
|
|
193
|
+
return lambda($parsed, (p) => p?.overflow ?? false);
|
|
97
194
|
},
|
|
98
195
|
[rowFieldName]($parent) {
|
|
99
|
-
const $
|
|
100
|
-
|
|
196
|
+
const $parsed = $parent.get('parsed');
|
|
197
|
+
const $subscribedId = $parent.get('subscribedId');
|
|
198
|
+
const $rowId = lambda([$parsed, $subscribedId], (pair) => {
|
|
199
|
+
const [p, subscribedId] = pair;
|
|
200
|
+
if (subscribedId)
|
|
201
|
+
return subscribedId;
|
|
202
|
+
if (!p || p.overflow || p.rowIds.length === 0)
|
|
203
|
+
return null;
|
|
204
|
+
return p.rowIds[0];
|
|
205
|
+
});
|
|
206
|
+
return resource.get({ id: $rowId });
|
|
101
207
|
},
|
|
102
208
|
};
|
|
103
209
|
}
|
|
104
210
|
allPlans['Subscription'] = subscriptionPlans;
|
|
105
211
|
return allPlans;
|
|
106
212
|
}
|
|
107
|
-
export function createRealtimeSubscriptionsPlugin(
|
|
213
|
+
export function createRealtimeSubscriptionsPlugin(options = {}) {
|
|
214
|
+
const overflowThreshold = options.overflowThreshold ?? DEFAULT_OVERFLOW_THRESHOLD;
|
|
108
215
|
return extendSchema((build) => {
|
|
109
216
|
const tables = discoverRealtimeTables(build);
|
|
110
217
|
if (tables.length === 0) {
|
|
@@ -112,9 +219,12 @@ export function createRealtimeSubscriptionsPlugin(_options = {}) {
|
|
|
112
219
|
return { typeDefs: '', plans: {} };
|
|
113
220
|
}
|
|
114
221
|
log.info(`Generating subscription fields for ${tables.length} realtime table(s)`);
|
|
222
|
+
log.info(`Overflow threshold: ${overflowThreshold} events/second/table`);
|
|
115
223
|
const typeDefs = buildTypeDefs(tables);
|
|
116
|
-
const plans = buildPlans(tables);
|
|
224
|
+
const plans = buildPlans(tables, overflowThreshold);
|
|
117
225
|
return { typeDefs, plans };
|
|
118
226
|
}, 'RealtimeSubscriptionsPlugin');
|
|
119
227
|
}
|
|
120
228
|
export { createRealtimeSubscriptionsPlugin as RealtimeSubscriptionsPlugin };
|
|
229
|
+
// Exported for testing
|
|
230
|
+
export { parseNotifyPayload, EventThrottle, DEFAULT_OVERFLOW_THRESHOLD };
|
package/esm/types.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Configuration options for the Realtime Subscriptions Plugin.
|
|
3
|
-
*
|
|
4
|
-
* Phase 3a supports single-record and full-collection subscription modes
|
|
5
|
-
* with no additional configuration required. Future phases will add
|
|
6
|
-
* options for poll intervals, batch sizes, and throttling.
|
|
7
3
|
*/
|
|
8
4
|
export interface RealtimeSubscriptionsPluginOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Maximum number of events per table per second before switching to
|
|
7
|
+
* overflow (INVALIDATE) mode. When exceeded, the subscription sends
|
|
8
|
+
* a single { event: 'INVALIDATE', overflow: true } instead of
|
|
9
|
+
* individual row events, signaling the client to refetch.
|
|
10
|
+
*
|
|
11
|
+
* Default: 50
|
|
12
|
+
*/
|
|
13
|
+
overflowThreshold?: number;
|
|
9
14
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-realtime-subscriptions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"@types/node": "^22.19.11",
|
|
56
56
|
"makage": "^0.3.0"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "4a575e3e29827e96979cb6aac2c88eb7a07f7fd0"
|
|
59
59
|
}
|
package/plugin.d.ts
CHANGED
|
@@ -5,21 +5,60 @@
|
|
|
5
5
|
* subscription fields (onXxxChanged) that use PostgreSQL LISTEN/NOTIFY
|
|
6
6
|
* for real-time event delivery.
|
|
7
7
|
*
|
|
8
|
-
* Subscription modes
|
|
8
|
+
* Subscription modes:
|
|
9
9
|
* - Single record: onXxxChanged(id: UUID!) — subscribe to changes on one row
|
|
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
|
+
* Overflow protection:
|
|
24
|
+
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
25
|
+
* - Plugin-side: per-subscriber throttle (default 50 events/second/table)
|
|
26
|
+
* drops individual events and sends a single INVALIDATE when exceeded
|
|
18
27
|
*
|
|
19
28
|
* RLS enforcement is automatic — resource.get() queries through the
|
|
20
29
|
* authenticated user's connection with their JWT role applied.
|
|
21
30
|
*/
|
|
22
31
|
import type { GraphileConfig } from 'graphile-config';
|
|
23
32
|
import type { RealtimeSubscriptionsPluginOptions } from './types';
|
|
24
|
-
|
|
33
|
+
/** Default overflow threshold: events per second per table per subscriber */
|
|
34
|
+
declare const DEFAULT_OVERFLOW_THRESHOLD = 50;
|
|
35
|
+
interface ParsedPayload {
|
|
36
|
+
event: string;
|
|
37
|
+
rowIds: string[];
|
|
38
|
+
overflow: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse the NOTIFY payload from emit_change.
|
|
42
|
+
* Format: "TG_OP:id1,id2,..." or "INVALIDATE"
|
|
43
|
+
*/
|
|
44
|
+
declare function parseNotifyPayload(raw: string): ParsedPayload;
|
|
45
|
+
/**
|
|
46
|
+
* Per-subscriber, per-table event rate tracker.
|
|
47
|
+
* Counts events in a sliding 1-second window.
|
|
48
|
+
*/
|
|
49
|
+
declare class EventThrottle {
|
|
50
|
+
private readonly threshold;
|
|
51
|
+
private windowStart;
|
|
52
|
+
private eventCount;
|
|
53
|
+
private overflowSent;
|
|
54
|
+
constructor(threshold: number);
|
|
55
|
+
/**
|
|
56
|
+
* Record an event and return whether it should be delivered.
|
|
57
|
+
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
58
|
+
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
59
|
+
*/
|
|
60
|
+
check(): 'deliver' | 'overflow' | 'drop';
|
|
61
|
+
}
|
|
62
|
+
export declare function createRealtimeSubscriptionsPlugin(options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
|
|
25
63
|
export { createRealtimeSubscriptionsPlugin as RealtimeSubscriptionsPlugin };
|
|
64
|
+
export { parseNotifyPayload, EventThrottle, DEFAULT_OVERFLOW_THRESHOLD };
|
package/plugin.js
CHANGED
|
@@ -6,27 +6,94 @@
|
|
|
6
6
|
* subscription fields (onXxxChanged) that use PostgreSQL LISTEN/NOTIFY
|
|
7
7
|
* for real-time event delivery.
|
|
8
8
|
*
|
|
9
|
-
* Subscription modes
|
|
9
|
+
* Subscription modes:
|
|
10
10
|
* - Single record: onXxxChanged(id: UUID!) — subscribe to changes on one row
|
|
11
11
|
* - Full collection: onXxxChanged (no args) — subscribe to any change on the table
|
|
12
12
|
*
|
|
13
|
+
* NOTIFY payload format (from emit_change trigger):
|
|
14
|
+
* - Normal: "INSERT:uuid1,uuid2,..." or "UPDATE:uuid1" or "DELETE:uuid1"
|
|
15
|
+
* - Overflow: "INVALIDATE" (when a single statement affects > 50 rows)
|
|
16
|
+
*
|
|
13
17
|
* Event flow:
|
|
14
18
|
* 1. A row is inserted/updated/deleted
|
|
15
|
-
* 2. The emit_change trigger fires pg_notify
|
|
19
|
+
* 2. The emit_change trigger fires pg_notify with TG_OP:row_ids or INVALIDATE
|
|
16
20
|
* 3. PostGraphile's pgSubscriber receives the NOTIFY
|
|
17
|
-
* 4. The
|
|
18
|
-
* 5. The client receives { event, row
|
|
21
|
+
* 4. The plugin parses the payload and fetches the specific changed row(s)
|
|
22
|
+
* 5. The client receives { event, row, rowId, overflow }
|
|
23
|
+
*
|
|
24
|
+
* Overflow protection:
|
|
25
|
+
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
26
|
+
* - Plugin-side: per-subscriber throttle (default 50 events/second/table)
|
|
27
|
+
* drops individual events and sends a single INVALIDATE when exceeded
|
|
19
28
|
*
|
|
20
29
|
* RLS enforcement is automatic — resource.get() queries through the
|
|
21
30
|
* authenticated user's connection with their JWT role applied.
|
|
22
31
|
*/
|
|
23
32
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.DEFAULT_OVERFLOW_THRESHOLD = exports.EventThrottle = void 0;
|
|
24
34
|
exports.createRealtimeSubscriptionsPlugin = createRealtimeSubscriptionsPlugin;
|
|
25
35
|
exports.RealtimeSubscriptionsPlugin = createRealtimeSubscriptionsPlugin;
|
|
36
|
+
exports.parseNotifyPayload = parseNotifyPayload;
|
|
26
37
|
const grafast_1 = require("grafast");
|
|
27
38
|
const graphile_utils_1 = require("graphile-utils");
|
|
28
39
|
const logger_1 = require("@pgpmjs/logger");
|
|
29
40
|
const log = new logger_1.Logger('graphile-realtime-subscriptions');
|
|
41
|
+
/** Default overflow threshold: events per second per table per subscriber */
|
|
42
|
+
const DEFAULT_OVERFLOW_THRESHOLD = 50;
|
|
43
|
+
exports.DEFAULT_OVERFLOW_THRESHOLD = DEFAULT_OVERFLOW_THRESHOLD;
|
|
44
|
+
/**
|
|
45
|
+
* Parse the NOTIFY payload from emit_change.
|
|
46
|
+
* Format: "TG_OP:id1,id2,..." or "INVALIDATE"
|
|
47
|
+
*/
|
|
48
|
+
function parseNotifyPayload(raw) {
|
|
49
|
+
if (raw === 'INVALIDATE') {
|
|
50
|
+
return { event: 'INVALIDATE', rowIds: [], overflow: true };
|
|
51
|
+
}
|
|
52
|
+
const colonIdx = raw.indexOf(':');
|
|
53
|
+
if (colonIdx === -1) {
|
|
54
|
+
return { event: raw || 'UNKNOWN', rowIds: [], overflow: false };
|
|
55
|
+
}
|
|
56
|
+
const event = raw.substring(0, colonIdx);
|
|
57
|
+
const idsPart = raw.substring(colonIdx + 1);
|
|
58
|
+
const rowIds = idsPart.length > 0 ? idsPart.split(',') : [];
|
|
59
|
+
return { event, rowIds, overflow: false };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Per-subscriber, per-table event rate tracker.
|
|
63
|
+
* Counts events in a sliding 1-second window.
|
|
64
|
+
*/
|
|
65
|
+
class EventThrottle {
|
|
66
|
+
threshold;
|
|
67
|
+
windowStart = 0;
|
|
68
|
+
eventCount = 0;
|
|
69
|
+
overflowSent = false;
|
|
70
|
+
constructor(threshold) {
|
|
71
|
+
this.threshold = threshold;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Record an event and return whether it should be delivered.
|
|
75
|
+
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
76
|
+
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
77
|
+
*/
|
|
78
|
+
check() {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (now - this.windowStart >= 1000) {
|
|
81
|
+
this.windowStart = now;
|
|
82
|
+
this.eventCount = 0;
|
|
83
|
+
this.overflowSent = false;
|
|
84
|
+
}
|
|
85
|
+
this.eventCount++;
|
|
86
|
+
if (this.eventCount <= this.threshold) {
|
|
87
|
+
return 'deliver';
|
|
88
|
+
}
|
|
89
|
+
if (!this.overflowSent) {
|
|
90
|
+
this.overflowSent = true;
|
|
91
|
+
return 'overflow';
|
|
92
|
+
}
|
|
93
|
+
return 'drop';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.EventThrottle = EventThrottle;
|
|
30
97
|
function discoverRealtimeTables(build) {
|
|
31
98
|
const { pgRegistry } = build.input;
|
|
32
99
|
const resources = pgRegistry.pgResources;
|
|
@@ -67,26 +134,46 @@ function buildTypeDefs(tables) {
|
|
|
67
134
|
const payloadTypes = tables
|
|
68
135
|
.map(({ payloadTypeName, typeName, rowFieldName }) => `"""Payload delivered when a ${typeName} row changes."""\n` +
|
|
69
136
|
`type ${payloadTypeName} {\n` +
|
|
70
|
-
` """The DML operation: INSERT, UPDATE, or
|
|
137
|
+
` """The DML operation: INSERT, UPDATE, DELETE, or INVALIDATE."""\n` +
|
|
71
138
|
` event: String!\n` +
|
|
72
|
-
` """The current state of the row (null for DELETE or if RLS denies access)."""\n` +
|
|
139
|
+
` """The current state of the row (null for DELETE, INVALIDATE, or if RLS denies access)."""\n` +
|
|
73
140
|
` ${rowFieldName}: ${typeName}\n` +
|
|
141
|
+
` """The ID of the changed row (null for INVALIDATE)."""\n` +
|
|
142
|
+
` rowId: UUID\n` +
|
|
143
|
+
` """True when too many changes occurred and the client should refetch."""\n` +
|
|
144
|
+
` overflow: Boolean!\n` +
|
|
74
145
|
`}`)
|
|
75
146
|
.join('\n\n');
|
|
76
147
|
return `extend type Subscription {\n${subscriptionFields}\n}\n\n${payloadTypes}`;
|
|
77
148
|
}
|
|
78
|
-
function buildPlans(tables) {
|
|
149
|
+
function buildPlans(tables, overflowThreshold) {
|
|
79
150
|
const subscriptionPlans = {};
|
|
80
151
|
const allPlans = {};
|
|
81
152
|
for (const { resource, fieldName, payloadTypeName, rowFieldName, notifyChannel } of tables) {
|
|
153
|
+
const throttle = new EventThrottle(overflowThreshold);
|
|
82
154
|
subscriptionPlans[fieldName] = {
|
|
83
155
|
subscribePlan(_$root, args) {
|
|
84
156
|
const $pgSubscriber = (0, grafast_1.context)().get('pgSubscriber');
|
|
85
157
|
const $topic = (0, grafast_1.constant)(notifyChannel);
|
|
86
158
|
const $id = args.get('id');
|
|
87
159
|
return (0, grafast_1.listen)($pgSubscriber, $topic, ($payload) => {
|
|
160
|
+
const $parsed = (0, grafast_1.lambda)($payload, (raw) => {
|
|
161
|
+
const parsed = parseNotifyPayload(String(raw));
|
|
162
|
+
const action = parsed.overflow ? 'deliver' : throttle.check();
|
|
163
|
+
if (action === 'drop') {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (action === 'overflow') {
|
|
167
|
+
return {
|
|
168
|
+
event: 'INVALIDATE',
|
|
169
|
+
rowIds: [],
|
|
170
|
+
overflow: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return parsed;
|
|
174
|
+
});
|
|
88
175
|
return (0, grafast_1.object)({
|
|
89
|
-
|
|
176
|
+
parsed: $parsed,
|
|
90
177
|
subscribedId: $id,
|
|
91
178
|
});
|
|
92
179
|
});
|
|
@@ -97,18 +184,42 @@ function buildPlans(tables) {
|
|
|
97
184
|
};
|
|
98
185
|
allPlans[payloadTypeName] = {
|
|
99
186
|
event($parent) {
|
|
100
|
-
|
|
187
|
+
const $parsed = $parent.get('parsed');
|
|
188
|
+
return (0, grafast_1.lambda)($parsed, (p) => p?.event ?? 'UNKNOWN');
|
|
189
|
+
},
|
|
190
|
+
rowId($parent) {
|
|
191
|
+
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)
|
|
195
|
+
return null;
|
|
196
|
+
return parsed.rowIds[0];
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
overflow($parent) {
|
|
200
|
+
const $parsed = $parent.get('parsed');
|
|
201
|
+
return (0, grafast_1.lambda)($parsed, (p) => p?.overflow ?? false);
|
|
101
202
|
},
|
|
102
203
|
[rowFieldName]($parent) {
|
|
103
|
-
const $
|
|
104
|
-
|
|
204
|
+
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;
|
|
210
|
+
if (!p || p.overflow || p.rowIds.length === 0)
|
|
211
|
+
return null;
|
|
212
|
+
return p.rowIds[0];
|
|
213
|
+
});
|
|
214
|
+
return resource.get({ id: $rowId });
|
|
105
215
|
},
|
|
106
216
|
};
|
|
107
217
|
}
|
|
108
218
|
allPlans['Subscription'] = subscriptionPlans;
|
|
109
219
|
return allPlans;
|
|
110
220
|
}
|
|
111
|
-
function createRealtimeSubscriptionsPlugin(
|
|
221
|
+
function createRealtimeSubscriptionsPlugin(options = {}) {
|
|
222
|
+
const overflowThreshold = options.overflowThreshold ?? DEFAULT_OVERFLOW_THRESHOLD;
|
|
112
223
|
return (0, graphile_utils_1.extendSchema)((build) => {
|
|
113
224
|
const tables = discoverRealtimeTables(build);
|
|
114
225
|
if (tables.length === 0) {
|
|
@@ -116,8 +227,9 @@ function createRealtimeSubscriptionsPlugin(_options = {}) {
|
|
|
116
227
|
return { typeDefs: '', plans: {} };
|
|
117
228
|
}
|
|
118
229
|
log.info(`Generating subscription fields for ${tables.length} realtime table(s)`);
|
|
230
|
+
log.info(`Overflow threshold: ${overflowThreshold} events/second/table`);
|
|
119
231
|
const typeDefs = buildTypeDefs(tables);
|
|
120
|
-
const plans = buildPlans(tables);
|
|
232
|
+
const plans = buildPlans(tables, overflowThreshold);
|
|
121
233
|
return { typeDefs, plans };
|
|
122
234
|
}, 'RealtimeSubscriptionsPlugin');
|
|
123
235
|
}
|
package/types.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Configuration options for the Realtime Subscriptions Plugin.
|
|
3
|
-
*
|
|
4
|
-
* Phase 3a supports single-record and full-collection subscription modes
|
|
5
|
-
* with no additional configuration required. Future phases will add
|
|
6
|
-
* options for poll intervals, batch sizes, and throttling.
|
|
7
3
|
*/
|
|
8
4
|
export interface RealtimeSubscriptionsPluginOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Maximum number of events per table per second before switching to
|
|
7
|
+
* overflow (INVALIDATE) mode. When exceeded, the subscription sends
|
|
8
|
+
* a single { event: 'INVALIDATE', overflow: true } instead of
|
|
9
|
+
* individual row events, signaling the client to refetch.
|
|
10
|
+
*
|
|
11
|
+
* Default: 50
|
|
12
|
+
*/
|
|
13
|
+
overflowThreshold?: number;
|
|
9
14
|
}
|