graphile-realtime-subscriptions 1.10.0 → 1.10.2
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/event-gate.d.ts +70 -0
- package/esm/event-gate.js +137 -0
- package/esm/plugin.d.ts +8 -31
- package/esm/plugin.js +40 -114
- package/event-gate.d.ts +70 -0
- package/event-gate.js +144 -0
- package/package.json +3 -3
- package/plugin.d.ts +8 -31
- package/plugin.js +44 -115
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate between PostgreSQL's NOTIFY stream and a GraphQL subscription.
|
|
3
|
+
*
|
|
4
|
+
* Everything that can decide "this event should not reach the client" lives
|
|
5
|
+
* here, upstream of grafast, because grafast's `listen` maps each event to a
|
|
6
|
+
* step and offers no way to suppress one. Deciding downstream forced the
|
|
7
|
+
* plugin to represent "dropped" as a null payload, which the field resolvers
|
|
8
|
+
* then had to coalesce (`p?.event ?? 'UNKNOWN'`) — so a filtered event still
|
|
9
|
+
* reached the client, carrying an invented event name. Filtering here means
|
|
10
|
+
* the stream only ever yields events that should exist, and every resolver
|
|
11
|
+
* downstream is total.
|
|
12
|
+
*
|
|
13
|
+
* The gate also owns parsing and the overflow throttle, so a payload the
|
|
14
|
+
* trigger and the plugin disagree about fails loudly instead of arriving as
|
|
15
|
+
* an `UNKNOWN`-shaped record.
|
|
16
|
+
*/
|
|
17
|
+
import type { GrafastSubscriber } from 'grafast';
|
|
18
|
+
export interface ParsedPayload {
|
|
19
|
+
event: string;
|
|
20
|
+
/**
|
|
21
|
+
* Row ids being reported. Already narrowed to the subscription's `ids` when
|
|
22
|
+
* it supplied any, so no consumer needs to re-intersect. Empty for
|
|
23
|
+
* INVALIDATE.
|
|
24
|
+
*/
|
|
25
|
+
rowIds: string[];
|
|
26
|
+
overflow: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare class MalformedNotifyPayloadError extends Error {
|
|
29
|
+
constructor(raw: string, reason: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse the NOTIFY payload from `emit_change`.
|
|
33
|
+
* Format: `"TG_OP:id1,id2,..."` or `"INVALIDATE"`.
|
|
34
|
+
*
|
|
35
|
+
* Throws on anything else: a payload this cannot read means the trigger and
|
|
36
|
+
* this plugin have diverged, and inventing an event name for it hides a
|
|
37
|
+
* deployment fault behind data the client will act on.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseNotifyPayload(raw: string): ParsedPayload;
|
|
40
|
+
/**
|
|
41
|
+
* Per-subscription event rate tracker. Counts events in a sliding 1-second
|
|
42
|
+
* window.
|
|
43
|
+
*/
|
|
44
|
+
export declare class EventThrottle {
|
|
45
|
+
private readonly threshold;
|
|
46
|
+
private windowStart;
|
|
47
|
+
private eventCount;
|
|
48
|
+
private overflowSent;
|
|
49
|
+
constructor(threshold: number);
|
|
50
|
+
/**
|
|
51
|
+
* Record an event and return whether it should be delivered.
|
|
52
|
+
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
53
|
+
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
54
|
+
*/
|
|
55
|
+
check(): 'deliver' | 'overflow' | 'drop';
|
|
56
|
+
}
|
|
57
|
+
export interface EventGateOptions {
|
|
58
|
+
/** Sparse-set subscription: deliver only events touching these row ids. */
|
|
59
|
+
ids?: readonly string[] | null;
|
|
60
|
+
/** Events per second before the gate collapses the stream to one INVALIDATE. */
|
|
61
|
+
threshold: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Wrap a `GrafastSubscriber` so its stream yields parsed, gated payloads.
|
|
65
|
+
*
|
|
66
|
+
* Built per subscription (the throttle and `ids` are per-subscriber state), so
|
|
67
|
+
* one noisy client can no longer throttle every other subscriber to the same
|
|
68
|
+
* table — which a single build-time throttle instance did.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createGatedSubscriber(inner: GrafastSubscriber<Record<string, string>>, { ids, threshold }: EventGateOptions): GrafastSubscriber<Record<string, ParsedPayload>>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate between PostgreSQL's NOTIFY stream and a GraphQL subscription.
|
|
3
|
+
*
|
|
4
|
+
* Everything that can decide "this event should not reach the client" lives
|
|
5
|
+
* here, upstream of grafast, because grafast's `listen` maps each event to a
|
|
6
|
+
* step and offers no way to suppress one. Deciding downstream forced the
|
|
7
|
+
* plugin to represent "dropped" as a null payload, which the field resolvers
|
|
8
|
+
* then had to coalesce (`p?.event ?? 'UNKNOWN'`) — so a filtered event still
|
|
9
|
+
* reached the client, carrying an invented event name. Filtering here means
|
|
10
|
+
* the stream only ever yields events that should exist, and every resolver
|
|
11
|
+
* downstream is total.
|
|
12
|
+
*
|
|
13
|
+
* The gate also owns parsing and the overflow throttle, so a payload the
|
|
14
|
+
* trigger and the plugin disagree about fails loudly instead of arriving as
|
|
15
|
+
* an `UNKNOWN`-shaped record.
|
|
16
|
+
*/
|
|
17
|
+
/** Operations `emit_change` is allowed to report. */
|
|
18
|
+
const KNOWN_OPERATIONS = new Set(['INSERT', 'UPDATE', 'DELETE']);
|
|
19
|
+
export class MalformedNotifyPayloadError extends Error {
|
|
20
|
+
constructor(raw, reason) {
|
|
21
|
+
super(`Malformed realtime NOTIFY payload ${JSON.stringify(raw)}: ${reason}. ` +
|
|
22
|
+
'Expected "INVALIDATE" or "<INSERT|UPDATE|DELETE>:<uuid>[,<uuid>...]" ' +
|
|
23
|
+
'as emitted by the emit_change trigger.');
|
|
24
|
+
this.name = 'MalformedNotifyPayloadError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse the NOTIFY payload from `emit_change`.
|
|
29
|
+
* Format: `"TG_OP:id1,id2,..."` or `"INVALIDATE"`.
|
|
30
|
+
*
|
|
31
|
+
* Throws on anything else: a payload this cannot read means the trigger and
|
|
32
|
+
* this plugin have diverged, and inventing an event name for it hides a
|
|
33
|
+
* deployment fault behind data the client will act on.
|
|
34
|
+
*/
|
|
35
|
+
export function parseNotifyPayload(raw) {
|
|
36
|
+
if (raw === 'INVALIDATE') {
|
|
37
|
+
return { event: 'INVALIDATE', rowIds: [], overflow: true };
|
|
38
|
+
}
|
|
39
|
+
const colonIdx = raw.indexOf(':');
|
|
40
|
+
if (colonIdx === -1) {
|
|
41
|
+
throw new MalformedNotifyPayloadError(raw, 'no ":" separating the operation from the row ids');
|
|
42
|
+
}
|
|
43
|
+
const event = raw.substring(0, colonIdx);
|
|
44
|
+
if (!KNOWN_OPERATIONS.has(event)) {
|
|
45
|
+
throw new MalformedNotifyPayloadError(raw, `unknown operation ${JSON.stringify(event)}`);
|
|
46
|
+
}
|
|
47
|
+
const idsPart = raw.substring(colonIdx + 1);
|
|
48
|
+
const rowIds = idsPart.length > 0 ? idsPart.split(',') : [];
|
|
49
|
+
if (rowIds.length === 0) {
|
|
50
|
+
throw new MalformedNotifyPayloadError(raw, `${event} carries no row ids`);
|
|
51
|
+
}
|
|
52
|
+
return { event, rowIds, overflow: false };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Per-subscription event rate tracker. Counts events in a sliding 1-second
|
|
56
|
+
* window.
|
|
57
|
+
*/
|
|
58
|
+
export 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
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Apply the gate to one raw NOTIFY payload.
|
|
91
|
+
*
|
|
92
|
+
* Returns the payload to deliver, or `null` meaning *do not emit* — a decision
|
|
93
|
+
* only this layer is allowed to make, because only this layer can act on it.
|
|
94
|
+
*/
|
|
95
|
+
function gate(raw, ids, throttle) {
|
|
96
|
+
const parsed = parseNotifyPayload(raw);
|
|
97
|
+
// An INVALIDATE is the overflow signal itself; throttling it would drop the
|
|
98
|
+
// one event telling the client to refetch.
|
|
99
|
+
if (parsed.overflow)
|
|
100
|
+
return parsed;
|
|
101
|
+
const action = throttle.check();
|
|
102
|
+
if (action === 'drop')
|
|
103
|
+
return null;
|
|
104
|
+
if (action === 'overflow') {
|
|
105
|
+
return { event: 'INVALIDATE', rowIds: [], overflow: true };
|
|
106
|
+
}
|
|
107
|
+
if (!ids || ids.length === 0)
|
|
108
|
+
return parsed;
|
|
109
|
+
const matched = parsed.rowIds.filter(rowId => ids.includes(rowId));
|
|
110
|
+
if (matched.length === 0)
|
|
111
|
+
return null;
|
|
112
|
+
return { ...parsed, rowIds: matched };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Wrap a `GrafastSubscriber` so its stream yields parsed, gated payloads.
|
|
116
|
+
*
|
|
117
|
+
* Built per subscription (the throttle and `ids` are per-subscriber state), so
|
|
118
|
+
* one noisy client can no longer throttle every other subscriber to the same
|
|
119
|
+
* table — which a single build-time throttle instance did.
|
|
120
|
+
*/
|
|
121
|
+
export function createGatedSubscriber(inner, { ids, threshold }) {
|
|
122
|
+
const subscribedIds = ids && ids.length > 0 ? ids : null;
|
|
123
|
+
const throttle = new EventThrottle(threshold);
|
|
124
|
+
return {
|
|
125
|
+
async *subscribe(topic) {
|
|
126
|
+
const source = await inner.subscribe(topic);
|
|
127
|
+
for await (const raw of source) {
|
|
128
|
+
const payload = gate(String(raw), subscribedIds, throttle);
|
|
129
|
+
if (payload !== null)
|
|
130
|
+
yield payload;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Deliberately no release(): `inner` is the shared pgSubscriber from the
|
|
134
|
+
// request context and is not ours to tear down. Ending iteration returns
|
|
135
|
+
// the underlying iterator, which is the whole of our cleanup.
|
|
136
|
+
};
|
|
137
|
+
}
|
package/esm/plugin.d.ts
CHANGED
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* 1. A row is inserted/updated/deleted
|
|
18
18
|
* 2. The emit_change trigger fires pg_notify with TG_OP:row_ids or INVALIDATE
|
|
19
19
|
* 3. PostGraphile's pgSubscriber receives the NOTIFY
|
|
20
|
-
* 4. The
|
|
21
|
-
*
|
|
20
|
+
* 4. The gate (see event-gate.ts) parses, throttles and filters it — a
|
|
21
|
+
* payload this subscription should not see never becomes an event
|
|
22
|
+
* 5. The plugin fetches the changed row(s)
|
|
23
|
+
* 6. The client receives { event, row, rowId, overflow }
|
|
22
24
|
*
|
|
23
25
|
* Cursor tracking (at-least-once delivery):
|
|
24
26
|
* The CursorTracker class provides a complementary polling-based delivery
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
*
|
|
32
34
|
* Overflow protection:
|
|
33
35
|
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
34
|
-
* - Plugin-side: per-
|
|
36
|
+
* - Plugin-side: per-subscription throttle (default 50 events/second/table)
|
|
35
37
|
* drops individual events and sends a single INVALIDATE when exceeded
|
|
36
38
|
*
|
|
37
39
|
* Security / RLS enforcement:
|
|
@@ -49,36 +51,11 @@ import type { GraphileConfig } from 'graphile-config';
|
|
|
49
51
|
import type { RealtimeSubscriptionsPluginOptions } from './types';
|
|
50
52
|
/** Default overflow threshold: events per second per table per subscriber */
|
|
51
53
|
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
54
|
export declare function createRealtimeSubscriptionsPlugin(options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
|
|
80
55
|
export { createRealtimeSubscriptionsPlugin as RealtimeSubscriptionsPlugin };
|
|
81
56
|
export { CursorTracker } from './cursor-tracker';
|
|
57
|
+
export type { ParsedPayload } from './event-gate';
|
|
58
|
+
export { createGatedSubscriber, EventThrottle, MalformedNotifyPayloadError, parseNotifyPayload, } from './event-gate';
|
|
82
59
|
export { RealtimeManager } from './realtime-manager';
|
|
83
60
|
export type { ChangeLogEntry, CursorTrackerOptions, Queryable, RealtimeManagerOptions } from './types';
|
|
84
|
-
export { DEFAULT_OVERFLOW_THRESHOLD
|
|
61
|
+
export { DEFAULT_OVERFLOW_THRESHOLD };
|
package/esm/plugin.js
CHANGED
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* 1. A row is inserted/updated/deleted
|
|
18
18
|
* 2. The emit_change trigger fires pg_notify with TG_OP:row_ids or INVALIDATE
|
|
19
19
|
* 3. PostGraphile's pgSubscriber receives the NOTIFY
|
|
20
|
-
* 4. The
|
|
21
|
-
*
|
|
20
|
+
* 4. The gate (see event-gate.ts) parses, throttles and filters it — a
|
|
21
|
+
* payload this subscription should not see never becomes an event
|
|
22
|
+
* 5. The plugin fetches the changed row(s)
|
|
23
|
+
* 6. The client receives { event, row, rowId, overflow }
|
|
22
24
|
*
|
|
23
25
|
* Cursor tracking (at-least-once delivery):
|
|
24
26
|
* The CursorTracker class provides a complementary polling-based delivery
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
*
|
|
32
34
|
* Overflow protection:
|
|
33
35
|
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
34
|
-
* - Plugin-side: per-
|
|
36
|
+
* - Plugin-side: per-subscription throttle (default 50 events/second/table)
|
|
35
37
|
* drops individual events and sends a single INVALIDATE when exceeded
|
|
36
38
|
*
|
|
37
39
|
* Security / RLS enforcement:
|
|
@@ -48,61 +50,10 @@
|
|
|
48
50
|
import { Logger } from '@pgpmjs/logger';
|
|
49
51
|
import { constant, context as grafastContext, lambda, listen, object } from 'grafast';
|
|
50
52
|
import { extendSchema } from 'graphile-utils';
|
|
53
|
+
import { createGatedSubscriber } from './event-gate';
|
|
51
54
|
const log = new Logger('graphile-realtime-subscriptions');
|
|
52
55
|
/** Default overflow threshold: events per second per table per subscriber */
|
|
53
56
|
const DEFAULT_OVERFLOW_THRESHOLD = 50;
|
|
54
|
-
/**
|
|
55
|
-
* Parse the NOTIFY payload from emit_change.
|
|
56
|
-
* Format: "TG_OP:id1,id2,..." or "INVALIDATE"
|
|
57
|
-
*/
|
|
58
|
-
function parseNotifyPayload(raw) {
|
|
59
|
-
if (raw === 'INVALIDATE') {
|
|
60
|
-
return { event: 'INVALIDATE', rowIds: [], overflow: true };
|
|
61
|
-
}
|
|
62
|
-
const colonIdx = raw.indexOf(':');
|
|
63
|
-
if (colonIdx === -1) {
|
|
64
|
-
return { event: raw || 'UNKNOWN', rowIds: [], overflow: false };
|
|
65
|
-
}
|
|
66
|
-
const event = raw.substring(0, colonIdx);
|
|
67
|
-
const idsPart = raw.substring(colonIdx + 1);
|
|
68
|
-
const rowIds = idsPart.length > 0 ? idsPart.split(',') : [];
|
|
69
|
-
return { event, rowIds, overflow: false };
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Per-subscriber, per-table event rate tracker.
|
|
73
|
-
* Counts events in a sliding 1-second window.
|
|
74
|
-
*/
|
|
75
|
-
class EventThrottle {
|
|
76
|
-
threshold;
|
|
77
|
-
windowStart = 0;
|
|
78
|
-
eventCount = 0;
|
|
79
|
-
overflowSent = false;
|
|
80
|
-
constructor(threshold) {
|
|
81
|
-
this.threshold = threshold;
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Record an event and return whether it should be delivered.
|
|
85
|
-
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
86
|
-
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
87
|
-
*/
|
|
88
|
-
check() {
|
|
89
|
-
const now = Date.now();
|
|
90
|
-
if (now - this.windowStart >= 1000) {
|
|
91
|
-
this.windowStart = now;
|
|
92
|
-
this.eventCount = 0;
|
|
93
|
-
this.overflowSent = false;
|
|
94
|
-
}
|
|
95
|
-
this.eventCount++;
|
|
96
|
-
if (this.eventCount <= this.threshold) {
|
|
97
|
-
return 'deliver';
|
|
98
|
-
}
|
|
99
|
-
if (!this.overflowSent) {
|
|
100
|
-
this.overflowSent = true;
|
|
101
|
-
return 'overflow';
|
|
102
|
-
}
|
|
103
|
-
return 'drop';
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
57
|
function discoverRealtimeTables(build) {
|
|
107
58
|
const { pgRegistry } = build.input;
|
|
108
59
|
const resources = pgRegistry.pgResources;
|
|
@@ -155,44 +106,44 @@ function buildTypeDefs(tables) {
|
|
|
155
106
|
.join('\n\n');
|
|
156
107
|
return `extend type Subscription {\n${subscriptionFields}\n}\n\n${payloadTypes}`;
|
|
157
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The gate never emits a null payload, so one here means the plan graph was
|
|
111
|
+
* rewired wrongly — fail rather than invent an event for the client.
|
|
112
|
+
*/
|
|
113
|
+
function requirePayload(payload) {
|
|
114
|
+
if (payload === null || payload === undefined) {
|
|
115
|
+
throw new Error('Realtime subscription payload is missing: the gated subscriber only ever ' +
|
|
116
|
+
'yields parsed payloads, so this event bypassed createGatedSubscriber.');
|
|
117
|
+
}
|
|
118
|
+
return payload;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The row this event reports. `rowIds` is already narrowed to the
|
|
122
|
+
* subscription's `ids`, so the first entry is the one to surface; INVALIDATE
|
|
123
|
+
* carries none, which is a genuine absence rather than a suppressed error.
|
|
124
|
+
*/
|
|
125
|
+
function reportedRowId(payload) {
|
|
126
|
+
if (payload.overflow)
|
|
127
|
+
return null;
|
|
128
|
+
return payload.rowIds[0] ?? null;
|
|
129
|
+
}
|
|
158
130
|
function buildPlans(tables, overflowThreshold) {
|
|
159
131
|
const subscriptionPlans = {};
|
|
160
132
|
const allPlans = {};
|
|
161
133
|
for (const { resource, fieldName, payloadTypeName, rowFieldName, notifyChannel } of tables) {
|
|
162
|
-
const throttle = new EventThrottle(overflowThreshold);
|
|
163
134
|
subscriptionPlans[fieldName] = {
|
|
164
135
|
subscribePlan(_$root, args) {
|
|
165
136
|
const $pgSubscriber = grafastContext().get('pgSubscriber');
|
|
166
137
|
const $topic = constant(notifyChannel);
|
|
167
138
|
const $ids = args.getRaw('ids');
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
if (action === 'overflow') {
|
|
177
|
-
return {
|
|
178
|
-
event: 'INVALIDATE',
|
|
179
|
-
rowIds: [],
|
|
180
|
-
overflow: true,
|
|
181
|
-
};
|
|
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
|
-
}
|
|
189
|
-
return parsed;
|
|
190
|
-
});
|
|
191
|
-
return object({
|
|
192
|
-
parsed: $parsed,
|
|
193
|
-
subscribedIds: $ids,
|
|
194
|
-
});
|
|
139
|
+
// Parsing, throttling and sparse-set filtering all happen in the gate,
|
|
140
|
+
// built once per subscription, so the stream below yields only events
|
|
141
|
+
// that should reach this client — every step after it is total.
|
|
142
|
+
const $subscriber = lambda([$pgSubscriber, $ids], (pair) => {
|
|
143
|
+
const [pgSubscriber, ids] = pair;
|
|
144
|
+
return createGatedSubscriber(pgSubscriber, { ids, threshold: overflowThreshold });
|
|
195
145
|
});
|
|
146
|
+
return listen($subscriber, $topic, ($payload) => object({ parsed: $payload }));
|
|
196
147
|
},
|
|
197
148
|
plan($event) {
|
|
198
149
|
return $event;
|
|
@@ -200,41 +151,16 @@ function buildPlans(tables, overflowThreshold) {
|
|
|
200
151
|
};
|
|
201
152
|
allPlans[payloadTypeName] = {
|
|
202
153
|
event($parent) {
|
|
203
|
-
|
|
204
|
-
return lambda($parsed, (p) => p?.event ?? 'UNKNOWN');
|
|
154
|
+
return lambda($parent.get('parsed'), (p) => requirePayload(p).event);
|
|
205
155
|
},
|
|
206
156
|
rowId($parent) {
|
|
207
|
-
|
|
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)
|
|
212
|
-
return null;
|
|
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];
|
|
218
|
-
});
|
|
157
|
+
return lambda($parent.get('parsed'), (p) => reportedRowId(requirePayload(p)));
|
|
219
158
|
},
|
|
220
159
|
overflow($parent) {
|
|
221
|
-
|
|
222
|
-
return lambda($parsed, (p) => p?.overflow ?? false);
|
|
160
|
+
return lambda($parent.get('parsed'), (p) => requirePayload(p).overflow);
|
|
223
161
|
},
|
|
224
162
|
[rowFieldName]($parent) {
|
|
225
|
-
const $
|
|
226
|
-
const $subscribedIds = $parent.get('subscribedIds');
|
|
227
|
-
const $rowId = lambda([$parsed, $subscribedIds], (tuple) => {
|
|
228
|
-
const [p, subscribedIds] = tuple;
|
|
229
|
-
if (!p || p.overflow || p.rowIds.length === 0)
|
|
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
|
|
236
|
-
return p.rowIds[0];
|
|
237
|
-
});
|
|
163
|
+
const $rowId = lambda($parent.get('parsed'), (p) => reportedRowId(requirePayload(p)));
|
|
238
164
|
return resource.get({ id: $rowId });
|
|
239
165
|
},
|
|
240
166
|
};
|
|
@@ -260,6 +186,6 @@ export function createRealtimeSubscriptionsPlugin(options = {}) {
|
|
|
260
186
|
export { createRealtimeSubscriptionsPlugin as RealtimeSubscriptionsPlugin };
|
|
261
187
|
// Re-export CursorTracker and RealtimeManager for convenience
|
|
262
188
|
export { CursorTracker } from './cursor-tracker';
|
|
189
|
+
export { createGatedSubscriber, EventThrottle, MalformedNotifyPayloadError, parseNotifyPayload, } from './event-gate';
|
|
263
190
|
export { RealtimeManager } from './realtime-manager';
|
|
264
|
-
|
|
265
|
-
export { DEFAULT_OVERFLOW_THRESHOLD, EventThrottle, parseNotifyPayload };
|
|
191
|
+
export { DEFAULT_OVERFLOW_THRESHOLD };
|
package/event-gate.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate between PostgreSQL's NOTIFY stream and a GraphQL subscription.
|
|
3
|
+
*
|
|
4
|
+
* Everything that can decide "this event should not reach the client" lives
|
|
5
|
+
* here, upstream of grafast, because grafast's `listen` maps each event to a
|
|
6
|
+
* step and offers no way to suppress one. Deciding downstream forced the
|
|
7
|
+
* plugin to represent "dropped" as a null payload, which the field resolvers
|
|
8
|
+
* then had to coalesce (`p?.event ?? 'UNKNOWN'`) — so a filtered event still
|
|
9
|
+
* reached the client, carrying an invented event name. Filtering here means
|
|
10
|
+
* the stream only ever yields events that should exist, and every resolver
|
|
11
|
+
* downstream is total.
|
|
12
|
+
*
|
|
13
|
+
* The gate also owns parsing and the overflow throttle, so a payload the
|
|
14
|
+
* trigger and the plugin disagree about fails loudly instead of arriving as
|
|
15
|
+
* an `UNKNOWN`-shaped record.
|
|
16
|
+
*/
|
|
17
|
+
import type { GrafastSubscriber } from 'grafast';
|
|
18
|
+
export interface ParsedPayload {
|
|
19
|
+
event: string;
|
|
20
|
+
/**
|
|
21
|
+
* Row ids being reported. Already narrowed to the subscription's `ids` when
|
|
22
|
+
* it supplied any, so no consumer needs to re-intersect. Empty for
|
|
23
|
+
* INVALIDATE.
|
|
24
|
+
*/
|
|
25
|
+
rowIds: string[];
|
|
26
|
+
overflow: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare class MalformedNotifyPayloadError extends Error {
|
|
29
|
+
constructor(raw: string, reason: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse the NOTIFY payload from `emit_change`.
|
|
33
|
+
* Format: `"TG_OP:id1,id2,..."` or `"INVALIDATE"`.
|
|
34
|
+
*
|
|
35
|
+
* Throws on anything else: a payload this cannot read means the trigger and
|
|
36
|
+
* this plugin have diverged, and inventing an event name for it hides a
|
|
37
|
+
* deployment fault behind data the client will act on.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseNotifyPayload(raw: string): ParsedPayload;
|
|
40
|
+
/**
|
|
41
|
+
* Per-subscription event rate tracker. Counts events in a sliding 1-second
|
|
42
|
+
* window.
|
|
43
|
+
*/
|
|
44
|
+
export declare class EventThrottle {
|
|
45
|
+
private readonly threshold;
|
|
46
|
+
private windowStart;
|
|
47
|
+
private eventCount;
|
|
48
|
+
private overflowSent;
|
|
49
|
+
constructor(threshold: number);
|
|
50
|
+
/**
|
|
51
|
+
* Record an event and return whether it should be delivered.
|
|
52
|
+
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
53
|
+
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
54
|
+
*/
|
|
55
|
+
check(): 'deliver' | 'overflow' | 'drop';
|
|
56
|
+
}
|
|
57
|
+
export interface EventGateOptions {
|
|
58
|
+
/** Sparse-set subscription: deliver only events touching these row ids. */
|
|
59
|
+
ids?: readonly string[] | null;
|
|
60
|
+
/** Events per second before the gate collapses the stream to one INVALIDATE. */
|
|
61
|
+
threshold: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Wrap a `GrafastSubscriber` so its stream yields parsed, gated payloads.
|
|
65
|
+
*
|
|
66
|
+
* Built per subscription (the throttle and `ids` are per-subscriber state), so
|
|
67
|
+
* one noisy client can no longer throttle every other subscriber to the same
|
|
68
|
+
* table — which a single build-time throttle instance did.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createGatedSubscriber(inner: GrafastSubscriber<Record<string, string>>, { ids, threshold }: EventGateOptions): GrafastSubscriber<Record<string, ParsedPayload>>;
|
package/event-gate.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The gate between PostgreSQL's NOTIFY stream and a GraphQL subscription.
|
|
4
|
+
*
|
|
5
|
+
* Everything that can decide "this event should not reach the client" lives
|
|
6
|
+
* here, upstream of grafast, because grafast's `listen` maps each event to a
|
|
7
|
+
* step and offers no way to suppress one. Deciding downstream forced the
|
|
8
|
+
* plugin to represent "dropped" as a null payload, which the field resolvers
|
|
9
|
+
* then had to coalesce (`p?.event ?? 'UNKNOWN'`) — so a filtered event still
|
|
10
|
+
* reached the client, carrying an invented event name. Filtering here means
|
|
11
|
+
* the stream only ever yields events that should exist, and every resolver
|
|
12
|
+
* downstream is total.
|
|
13
|
+
*
|
|
14
|
+
* The gate also owns parsing and the overflow throttle, so a payload the
|
|
15
|
+
* trigger and the plugin disagree about fails loudly instead of arriving as
|
|
16
|
+
* an `UNKNOWN`-shaped record.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.EventThrottle = exports.MalformedNotifyPayloadError = void 0;
|
|
20
|
+
exports.parseNotifyPayload = parseNotifyPayload;
|
|
21
|
+
exports.createGatedSubscriber = createGatedSubscriber;
|
|
22
|
+
/** Operations `emit_change` is allowed to report. */
|
|
23
|
+
const KNOWN_OPERATIONS = new Set(['INSERT', 'UPDATE', 'DELETE']);
|
|
24
|
+
class MalformedNotifyPayloadError extends Error {
|
|
25
|
+
constructor(raw, reason) {
|
|
26
|
+
super(`Malformed realtime NOTIFY payload ${JSON.stringify(raw)}: ${reason}. ` +
|
|
27
|
+
'Expected "INVALIDATE" or "<INSERT|UPDATE|DELETE>:<uuid>[,<uuid>...]" ' +
|
|
28
|
+
'as emitted by the emit_change trigger.');
|
|
29
|
+
this.name = 'MalformedNotifyPayloadError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.MalformedNotifyPayloadError = MalformedNotifyPayloadError;
|
|
33
|
+
/**
|
|
34
|
+
* Parse the NOTIFY payload from `emit_change`.
|
|
35
|
+
* Format: `"TG_OP:id1,id2,..."` or `"INVALIDATE"`.
|
|
36
|
+
*
|
|
37
|
+
* Throws on anything else: a payload this cannot read means the trigger and
|
|
38
|
+
* this plugin have diverged, and inventing an event name for it hides a
|
|
39
|
+
* deployment fault behind data the client will act on.
|
|
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
|
+
throw new MalformedNotifyPayloadError(raw, 'no ":" separating the operation from the row ids');
|
|
48
|
+
}
|
|
49
|
+
const event = raw.substring(0, colonIdx);
|
|
50
|
+
if (!KNOWN_OPERATIONS.has(event)) {
|
|
51
|
+
throw new MalformedNotifyPayloadError(raw, `unknown operation ${JSON.stringify(event)}`);
|
|
52
|
+
}
|
|
53
|
+
const idsPart = raw.substring(colonIdx + 1);
|
|
54
|
+
const rowIds = idsPart.length > 0 ? idsPart.split(',') : [];
|
|
55
|
+
if (rowIds.length === 0) {
|
|
56
|
+
throw new MalformedNotifyPayloadError(raw, `${event} carries no row ids`);
|
|
57
|
+
}
|
|
58
|
+
return { event, rowIds, overflow: false };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Per-subscription event rate tracker. Counts events in a sliding 1-second
|
|
62
|
+
* window.
|
|
63
|
+
*/
|
|
64
|
+
class EventThrottle {
|
|
65
|
+
threshold;
|
|
66
|
+
windowStart = 0;
|
|
67
|
+
eventCount = 0;
|
|
68
|
+
overflowSent = false;
|
|
69
|
+
constructor(threshold) {
|
|
70
|
+
this.threshold = threshold;
|
|
71
|
+
}
|
|
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() {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
if (now - this.windowStart >= 1000) {
|
|
80
|
+
this.windowStart = now;
|
|
81
|
+
this.eventCount = 0;
|
|
82
|
+
this.overflowSent = false;
|
|
83
|
+
}
|
|
84
|
+
this.eventCount++;
|
|
85
|
+
if (this.eventCount <= this.threshold) {
|
|
86
|
+
return 'deliver';
|
|
87
|
+
}
|
|
88
|
+
if (!this.overflowSent) {
|
|
89
|
+
this.overflowSent = true;
|
|
90
|
+
return 'overflow';
|
|
91
|
+
}
|
|
92
|
+
return 'drop';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.EventThrottle = EventThrottle;
|
|
96
|
+
/**
|
|
97
|
+
* Apply the gate to one raw NOTIFY payload.
|
|
98
|
+
*
|
|
99
|
+
* Returns the payload to deliver, or `null` meaning *do not emit* — a decision
|
|
100
|
+
* only this layer is allowed to make, because only this layer can act on it.
|
|
101
|
+
*/
|
|
102
|
+
function gate(raw, ids, throttle) {
|
|
103
|
+
const parsed = parseNotifyPayload(raw);
|
|
104
|
+
// An INVALIDATE is the overflow signal itself; throttling it would drop the
|
|
105
|
+
// one event telling the client to refetch.
|
|
106
|
+
if (parsed.overflow)
|
|
107
|
+
return parsed;
|
|
108
|
+
const action = throttle.check();
|
|
109
|
+
if (action === 'drop')
|
|
110
|
+
return null;
|
|
111
|
+
if (action === 'overflow') {
|
|
112
|
+
return { event: 'INVALIDATE', rowIds: [], overflow: true };
|
|
113
|
+
}
|
|
114
|
+
if (!ids || ids.length === 0)
|
|
115
|
+
return parsed;
|
|
116
|
+
const matched = parsed.rowIds.filter(rowId => ids.includes(rowId));
|
|
117
|
+
if (matched.length === 0)
|
|
118
|
+
return null;
|
|
119
|
+
return { ...parsed, rowIds: matched };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Wrap a `GrafastSubscriber` so its stream yields parsed, gated payloads.
|
|
123
|
+
*
|
|
124
|
+
* Built per subscription (the throttle and `ids` are per-subscriber state), so
|
|
125
|
+
* one noisy client can no longer throttle every other subscriber to the same
|
|
126
|
+
* table — which a single build-time throttle instance did.
|
|
127
|
+
*/
|
|
128
|
+
function createGatedSubscriber(inner, { ids, threshold }) {
|
|
129
|
+
const subscribedIds = ids && ids.length > 0 ? ids : null;
|
|
130
|
+
const throttle = new EventThrottle(threshold);
|
|
131
|
+
return {
|
|
132
|
+
async *subscribe(topic) {
|
|
133
|
+
const source = await inner.subscribe(topic);
|
|
134
|
+
for await (const raw of source) {
|
|
135
|
+
const payload = gate(String(raw), subscribedIds, throttle);
|
|
136
|
+
if (payload !== null)
|
|
137
|
+
yield payload;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Deliberately no release(): `inner` is the shared pgSubscriber from the
|
|
141
|
+
// request context and is not ours to tear down. Ending iteration returns
|
|
142
|
+
// the underlying iterator, which is the whole of our cleanup.
|
|
143
|
+
};
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-realtime-subscriptions",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.2",
|
|
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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@pgpmjs/logger": "^2.24.0",
|
|
44
|
-
"@pgsql/quotes": "^18.2.
|
|
44
|
+
"@pgsql/quotes": "^18.2.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"grafast": "1.0.2",
|
|
@@ -56,5 +56,5 @@
|
|
|
56
56
|
"@types/node": "^22.19.11",
|
|
57
57
|
"makage": "^0.3.0"
|
|
58
58
|
},
|
|
59
|
-
"gitHead": "
|
|
59
|
+
"gitHead": "43121004a8bedd2c5fcf7bf33208079d55bad01f"
|
|
60
60
|
}
|
package/plugin.d.ts
CHANGED
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* 1. A row is inserted/updated/deleted
|
|
18
18
|
* 2. The emit_change trigger fires pg_notify with TG_OP:row_ids or INVALIDATE
|
|
19
19
|
* 3. PostGraphile's pgSubscriber receives the NOTIFY
|
|
20
|
-
* 4. The
|
|
21
|
-
*
|
|
20
|
+
* 4. The gate (see event-gate.ts) parses, throttles and filters it — a
|
|
21
|
+
* payload this subscription should not see never becomes an event
|
|
22
|
+
* 5. The plugin fetches the changed row(s)
|
|
23
|
+
* 6. The client receives { event, row, rowId, overflow }
|
|
22
24
|
*
|
|
23
25
|
* Cursor tracking (at-least-once delivery):
|
|
24
26
|
* The CursorTracker class provides a complementary polling-based delivery
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
*
|
|
32
34
|
* Overflow protection:
|
|
33
35
|
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
34
|
-
* - Plugin-side: per-
|
|
36
|
+
* - Plugin-side: per-subscription throttle (default 50 events/second/table)
|
|
35
37
|
* drops individual events and sends a single INVALIDATE when exceeded
|
|
36
38
|
*
|
|
37
39
|
* Security / RLS enforcement:
|
|
@@ -49,36 +51,11 @@ import type { GraphileConfig } from 'graphile-config';
|
|
|
49
51
|
import type { RealtimeSubscriptionsPluginOptions } from './types';
|
|
50
52
|
/** Default overflow threshold: events per second per table per subscriber */
|
|
51
53
|
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
54
|
export declare function createRealtimeSubscriptionsPlugin(options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
|
|
80
55
|
export { createRealtimeSubscriptionsPlugin as RealtimeSubscriptionsPlugin };
|
|
81
56
|
export { CursorTracker } from './cursor-tracker';
|
|
57
|
+
export type { ParsedPayload } from './event-gate';
|
|
58
|
+
export { createGatedSubscriber, EventThrottle, MalformedNotifyPayloadError, parseNotifyPayload, } from './event-gate';
|
|
82
59
|
export { RealtimeManager } from './realtime-manager';
|
|
83
60
|
export type { ChangeLogEntry, CursorTrackerOptions, Queryable, RealtimeManagerOptions } from './types';
|
|
84
|
-
export { DEFAULT_OVERFLOW_THRESHOLD
|
|
61
|
+
export { DEFAULT_OVERFLOW_THRESHOLD };
|
package/plugin.js
CHANGED
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
* 1. A row is inserted/updated/deleted
|
|
19
19
|
* 2. The emit_change trigger fires pg_notify with TG_OP:row_ids or INVALIDATE
|
|
20
20
|
* 3. PostGraphile's pgSubscriber receives the NOTIFY
|
|
21
|
-
* 4. The
|
|
22
|
-
*
|
|
21
|
+
* 4. The gate (see event-gate.ts) parses, throttles and filters it — a
|
|
22
|
+
* payload this subscription should not see never becomes an event
|
|
23
|
+
* 5. The plugin fetches the changed row(s)
|
|
24
|
+
* 6. The client receives { event, row, rowId, overflow }
|
|
23
25
|
*
|
|
24
26
|
* Cursor tracking (at-least-once delivery):
|
|
25
27
|
* The CursorTracker class provides a complementary polling-based delivery
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
*
|
|
33
35
|
* Overflow protection:
|
|
34
36
|
* - Database-side: statements affecting > 50 rows send INVALIDATE
|
|
35
|
-
* - Plugin-side: per-
|
|
37
|
+
* - Plugin-side: per-subscription throttle (default 50 events/second/table)
|
|
36
38
|
* drops individual events and sends a single INVALIDATE when exceeded
|
|
37
39
|
*
|
|
38
40
|
* Security / RLS enforcement:
|
|
@@ -47,70 +49,17 @@
|
|
|
47
49
|
* preventing cross-tenant event leaks.
|
|
48
50
|
*/
|
|
49
51
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
-
exports.
|
|
52
|
+
exports.DEFAULT_OVERFLOW_THRESHOLD = exports.RealtimeManager = exports.parseNotifyPayload = exports.MalformedNotifyPayloadError = exports.EventThrottle = exports.createGatedSubscriber = exports.CursorTracker = void 0;
|
|
51
53
|
exports.createRealtimeSubscriptionsPlugin = createRealtimeSubscriptionsPlugin;
|
|
52
54
|
exports.RealtimeSubscriptionsPlugin = createRealtimeSubscriptionsPlugin;
|
|
53
|
-
exports.parseNotifyPayload = parseNotifyPayload;
|
|
54
55
|
const logger_1 = require("@pgpmjs/logger");
|
|
55
56
|
const grafast_1 = require("grafast");
|
|
56
57
|
const graphile_utils_1 = require("graphile-utils");
|
|
58
|
+
const event_gate_1 = require("./event-gate");
|
|
57
59
|
const log = new logger_1.Logger('graphile-realtime-subscriptions');
|
|
58
60
|
/** Default overflow threshold: events per second per table per subscriber */
|
|
59
61
|
const DEFAULT_OVERFLOW_THRESHOLD = 50;
|
|
60
62
|
exports.DEFAULT_OVERFLOW_THRESHOLD = DEFAULT_OVERFLOW_THRESHOLD;
|
|
61
|
-
/**
|
|
62
|
-
* Parse the NOTIFY payload from emit_change.
|
|
63
|
-
* Format: "TG_OP:id1,id2,..." or "INVALIDATE"
|
|
64
|
-
*/
|
|
65
|
-
function parseNotifyPayload(raw) {
|
|
66
|
-
if (raw === 'INVALIDATE') {
|
|
67
|
-
return { event: 'INVALIDATE', rowIds: [], overflow: true };
|
|
68
|
-
}
|
|
69
|
-
const colonIdx = raw.indexOf(':');
|
|
70
|
-
if (colonIdx === -1) {
|
|
71
|
-
return { event: raw || 'UNKNOWN', rowIds: [], overflow: false };
|
|
72
|
-
}
|
|
73
|
-
const event = raw.substring(0, colonIdx);
|
|
74
|
-
const idsPart = raw.substring(colonIdx + 1);
|
|
75
|
-
const rowIds = idsPart.length > 0 ? idsPart.split(',') : [];
|
|
76
|
-
return { event, rowIds, overflow: false };
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Per-subscriber, per-table event rate tracker.
|
|
80
|
-
* Counts events in a sliding 1-second window.
|
|
81
|
-
*/
|
|
82
|
-
class EventThrottle {
|
|
83
|
-
threshold;
|
|
84
|
-
windowStart = 0;
|
|
85
|
-
eventCount = 0;
|
|
86
|
-
overflowSent = false;
|
|
87
|
-
constructor(threshold) {
|
|
88
|
-
this.threshold = threshold;
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Record an event and return whether it should be delivered.
|
|
92
|
-
* Returns 'deliver' for normal events, 'overflow' when the threshold
|
|
93
|
-
* is first exceeded, or 'drop' for subsequent events in the same window.
|
|
94
|
-
*/
|
|
95
|
-
check() {
|
|
96
|
-
const now = Date.now();
|
|
97
|
-
if (now - this.windowStart >= 1000) {
|
|
98
|
-
this.windowStart = now;
|
|
99
|
-
this.eventCount = 0;
|
|
100
|
-
this.overflowSent = false;
|
|
101
|
-
}
|
|
102
|
-
this.eventCount++;
|
|
103
|
-
if (this.eventCount <= this.threshold) {
|
|
104
|
-
return 'deliver';
|
|
105
|
-
}
|
|
106
|
-
if (!this.overflowSent) {
|
|
107
|
-
this.overflowSent = true;
|
|
108
|
-
return 'overflow';
|
|
109
|
-
}
|
|
110
|
-
return 'drop';
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
exports.EventThrottle = EventThrottle;
|
|
114
63
|
function discoverRealtimeTables(build) {
|
|
115
64
|
const { pgRegistry } = build.input;
|
|
116
65
|
const resources = pgRegistry.pgResources;
|
|
@@ -163,44 +112,44 @@ function buildTypeDefs(tables) {
|
|
|
163
112
|
.join('\n\n');
|
|
164
113
|
return `extend type Subscription {\n${subscriptionFields}\n}\n\n${payloadTypes}`;
|
|
165
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The gate never emits a null payload, so one here means the plan graph was
|
|
117
|
+
* rewired wrongly — fail rather than invent an event for the client.
|
|
118
|
+
*/
|
|
119
|
+
function requirePayload(payload) {
|
|
120
|
+
if (payload === null || payload === undefined) {
|
|
121
|
+
throw new Error('Realtime subscription payload is missing: the gated subscriber only ever ' +
|
|
122
|
+
'yields parsed payloads, so this event bypassed createGatedSubscriber.');
|
|
123
|
+
}
|
|
124
|
+
return payload;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The row this event reports. `rowIds` is already narrowed to the
|
|
128
|
+
* subscription's `ids`, so the first entry is the one to surface; INVALIDATE
|
|
129
|
+
* carries none, which is a genuine absence rather than a suppressed error.
|
|
130
|
+
*/
|
|
131
|
+
function reportedRowId(payload) {
|
|
132
|
+
if (payload.overflow)
|
|
133
|
+
return null;
|
|
134
|
+
return payload.rowIds[0] ?? null;
|
|
135
|
+
}
|
|
166
136
|
function buildPlans(tables, overflowThreshold) {
|
|
167
137
|
const subscriptionPlans = {};
|
|
168
138
|
const allPlans = {};
|
|
169
139
|
for (const { resource, fieldName, payloadTypeName, rowFieldName, notifyChannel } of tables) {
|
|
170
|
-
const throttle = new EventThrottle(overflowThreshold);
|
|
171
140
|
subscriptionPlans[fieldName] = {
|
|
172
141
|
subscribePlan(_$root, args) {
|
|
173
142
|
const $pgSubscriber = (0, grafast_1.context)().get('pgSubscriber');
|
|
174
143
|
const $topic = (0, grafast_1.constant)(notifyChannel);
|
|
175
144
|
const $ids = args.getRaw('ids');
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
if (action === 'overflow') {
|
|
185
|
-
return {
|
|
186
|
-
event: 'INVALIDATE',
|
|
187
|
-
rowIds: [],
|
|
188
|
-
overflow: true,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
// Sparse set filtering: only deliver events for subscribed row IDs
|
|
192
|
-
if (subscribedIds && subscribedIds.length > 0) {
|
|
193
|
-
const hasMatch = parsed.rowIds.some((rid) => subscribedIds.includes(rid));
|
|
194
|
-
if (!hasMatch)
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
return parsed;
|
|
198
|
-
});
|
|
199
|
-
return (0, grafast_1.object)({
|
|
200
|
-
parsed: $parsed,
|
|
201
|
-
subscribedIds: $ids,
|
|
202
|
-
});
|
|
145
|
+
// Parsing, throttling and sparse-set filtering all happen in the gate,
|
|
146
|
+
// built once per subscription, so the stream below yields only events
|
|
147
|
+
// that should reach this client — every step after it is total.
|
|
148
|
+
const $subscriber = (0, grafast_1.lambda)([$pgSubscriber, $ids], (pair) => {
|
|
149
|
+
const [pgSubscriber, ids] = pair;
|
|
150
|
+
return (0, event_gate_1.createGatedSubscriber)(pgSubscriber, { ids, threshold: overflowThreshold });
|
|
203
151
|
});
|
|
152
|
+
return (0, grafast_1.listen)($subscriber, $topic, ($payload) => (0, grafast_1.object)({ parsed: $payload }));
|
|
204
153
|
},
|
|
205
154
|
plan($event) {
|
|
206
155
|
return $event;
|
|
@@ -208,41 +157,16 @@ function buildPlans(tables, overflowThreshold) {
|
|
|
208
157
|
};
|
|
209
158
|
allPlans[payloadTypeName] = {
|
|
210
159
|
event($parent) {
|
|
211
|
-
|
|
212
|
-
return (0, grafast_1.lambda)($parsed, (p) => p?.event ?? 'UNKNOWN');
|
|
160
|
+
return (0, grafast_1.lambda)($parent.get('parsed'), (p) => requirePayload(p).event);
|
|
213
161
|
},
|
|
214
162
|
rowId($parent) {
|
|
215
|
-
|
|
216
|
-
const $subscribedIds = $parent.get('subscribedIds');
|
|
217
|
-
return (0, grafast_1.lambda)([$parsed, $subscribedIds], (pair) => {
|
|
218
|
-
const [p, subscribedIds] = pair;
|
|
219
|
-
if (!p || p.overflow || p.rowIds.length === 0)
|
|
220
|
-
return null;
|
|
221
|
-
// When ids are provided, return the first matching row ID
|
|
222
|
-
if (subscribedIds && subscribedIds.length > 0) {
|
|
223
|
-
return p.rowIds.find((rid) => subscribedIds.includes(rid)) ?? null;
|
|
224
|
-
}
|
|
225
|
-
return p.rowIds[0];
|
|
226
|
-
});
|
|
163
|
+
return (0, grafast_1.lambda)($parent.get('parsed'), (p) => reportedRowId(requirePayload(p)));
|
|
227
164
|
},
|
|
228
165
|
overflow($parent) {
|
|
229
|
-
|
|
230
|
-
return (0, grafast_1.lambda)($parsed, (p) => p?.overflow ?? false);
|
|
166
|
+
return (0, grafast_1.lambda)($parent.get('parsed'), (p) => requirePayload(p).overflow);
|
|
231
167
|
},
|
|
232
168
|
[rowFieldName]($parent) {
|
|
233
|
-
const $
|
|
234
|
-
const $subscribedIds = $parent.get('subscribedIds');
|
|
235
|
-
const $rowId = (0, grafast_1.lambda)([$parsed, $subscribedIds], (tuple) => {
|
|
236
|
-
const [p, subscribedIds] = tuple;
|
|
237
|
-
if (!p || p.overflow || p.rowIds.length === 0)
|
|
238
|
-
return null;
|
|
239
|
-
// When ids are provided, return first matching row ID
|
|
240
|
-
if (subscribedIds && subscribedIds.length > 0) {
|
|
241
|
-
return p.rowIds.find((rid) => subscribedIds.includes(rid)) ?? null;
|
|
242
|
-
}
|
|
243
|
-
// Full collection mode: return first row ID
|
|
244
|
-
return p.rowIds[0];
|
|
245
|
-
});
|
|
169
|
+
const $rowId = (0, grafast_1.lambda)($parent.get('parsed'), (p) => reportedRowId(requirePayload(p)));
|
|
246
170
|
return resource.get({ id: $rowId });
|
|
247
171
|
},
|
|
248
172
|
};
|
|
@@ -268,5 +192,10 @@ function createRealtimeSubscriptionsPlugin(options = {}) {
|
|
|
268
192
|
// Re-export CursorTracker and RealtimeManager for convenience
|
|
269
193
|
var cursor_tracker_1 = require("./cursor-tracker");
|
|
270
194
|
Object.defineProperty(exports, "CursorTracker", { enumerable: true, get: function () { return cursor_tracker_1.CursorTracker; } });
|
|
195
|
+
var event_gate_2 = require("./event-gate");
|
|
196
|
+
Object.defineProperty(exports, "createGatedSubscriber", { enumerable: true, get: function () { return event_gate_2.createGatedSubscriber; } });
|
|
197
|
+
Object.defineProperty(exports, "EventThrottle", { enumerable: true, get: function () { return event_gate_2.EventThrottle; } });
|
|
198
|
+
Object.defineProperty(exports, "MalformedNotifyPayloadError", { enumerable: true, get: function () { return event_gate_2.MalformedNotifyPayloadError; } });
|
|
199
|
+
Object.defineProperty(exports, "parseNotifyPayload", { enumerable: true, get: function () { return event_gate_2.parseNotifyPayload; } });
|
|
271
200
|
var realtime_manager_1 = require("./realtime-manager");
|
|
272
201
|
Object.defineProperty(exports, "RealtimeManager", { enumerable: true, get: function () { return realtime_manager_1.RealtimeManager; } });
|