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 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 (Phase 3a):
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('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
+ * 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
- export declare function createRealtimeSubscriptionsPlugin(_options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
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 (Phase 3a):
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('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
+ * 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 DELETE."""\n` +
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
- event: $payload,
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
- return $parent.get('event');
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 $id = $parent.get('subscribedId');
100
- return resource.get({ id: $id });
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(_options = {}) {
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.2.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": "c87dafec1ccb2c3cc62f2304584104db108a810b"
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 (Phase 3a):
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('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
+ * 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
- export declare function createRealtimeSubscriptionsPlugin(_options?: RealtimeSubscriptionsPluginOptions): GraphileConfig.Plugin;
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 (Phase 3a):
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('realtime:{schema}.{table}', TG_OP)
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 subscription re-queries the source table with RLS enforced
18
- * 5. The client receives { event, row } where row reflects the current state
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 DELETE."""\n` +
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
- event: $payload,
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
- return $parent.get('event');
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 $id = $parent.get('subscribedId');
104
- return resource.get({ id: $id });
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(_options = {}) {
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
  }