graphile-realtime-test 0.2.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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
4
+ Copyright (c) 2025 Constructive <developers@constructive.io>
5
+ Copyright (c) 2020-present, Interweb, Inc.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # graphile-realtime-test
2
+
3
+ <p align="center" width="100%">
4
+ <img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5
+ </p>
6
+
7
+ <p align="center" width="100%">
8
+ <a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
9
+ <img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
10
+ </a>
11
+ <a href="https://github.com/constructive-io/constructive/blob/main/LICENSE">
12
+ <img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
13
+ </a>
14
+ <a href="https://www.npmjs.com/package/graphile-realtime-test">
15
+ <img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-realtime-test%2Fpackage.json"/>
16
+ </a>
17
+ </p>
18
+
19
+ Subscription testing utilities for `graphile-realtime-subscriptions`.
20
+
21
+ Provides smart tag injection, `grafast.subscribe()` helpers, and `pg_notify` simulation for integration testing realtime GraphQL subscriptions against a real PostgreSQL database.
22
+
23
+ ## Usage
24
+
25
+ ```typescript
26
+ import { createRealtimeTestContext, waitForEvent } from 'graphile-realtime-test';
27
+ import { seed } from 'pgsql-test';
28
+
29
+ const ctx = await createRealtimeTestContext(
30
+ {
31
+ schemas: ['my_schema'],
32
+ realtimeTables: ['items'],
33
+ useRoot: true,
34
+ authRole: 'postgres',
35
+ },
36
+ [seed.sqlfile(['./seed.sql'])]
37
+ );
38
+
39
+ // Start a subscription
40
+ const iterator = await ctx.subscribe(`
41
+ subscription {
42
+ onItemChanged {
43
+ event
44
+ rowId
45
+ overflow
46
+ }
47
+ }
48
+ `);
49
+
50
+ // Fire a NOTIFY
51
+ await ctx.notifyChange('items', 'INSERT', ['some-uuid']);
52
+
53
+ // Assert the event
54
+ const event = await waitForEvent(iterator);
55
+ expect(event.data.onItemChanged.event).toBe('INSERT');
56
+
57
+ // Clean up
58
+ await iterator.return?.();
59
+ await ctx.teardown();
60
+ ```
61
+
62
+ ## API
63
+
64
+ ### `createRealtimeTestContext(input, seedAdapters?)`
65
+
66
+ Creates a fully wired test context with schema, subscriptions, and NOTIFY helpers.
67
+
68
+ ### `makeRealtimeSmartTagsPlugin(tagsByTable)`
69
+
70
+ Creates a Graphile plugin that injects smart tags on table codecs during schema build.
71
+
72
+ ### `subscribe(opts)`
73
+
74
+ Calls `grafast.subscribe()` and returns the raw async iterator.
75
+
76
+ ### `waitForEvent(iterator, timeoutMs?)`
77
+
78
+ Waits for the next event from a subscription iterator with a timeout.
79
+
80
+ ### `collectEvents(iterator, count, timeoutMs?)`
81
+
82
+ Collects multiple events from a subscription iterator.
83
+
84
+ ### `notify(client, schema, table, payload)`
85
+
86
+ Fires a raw `pg_notify` on a realtime channel.
87
+
88
+ ### `notifyChange(client, schema, table, operation, rowIds)`
89
+
90
+ Fires a DML NOTIFY with the standard payload format.
91
+
92
+ ### `notifyInvalidate(client, schema, table)`
93
+
94
+ Fires an INVALIDATE (overflow) NOTIFY.
95
+
96
+ ### `buildPayload(operation, rowIds)`
97
+
98
+ Builds a standard DML payload string.
99
+
100
+ ### `buildInvalidatePayload()`
101
+
102
+ Returns the `"INVALIDATE"` payload string.
103
+
104
+ ---
105
+
106
+ ## Education and Tutorials
107
+
108
+ 1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
109
+ Get started with modular databases in minutes. Install prerequisites and deploy your first module.
110
+
111
+ 2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
112
+ Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
113
+
114
+ 3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
115
+ Master the workflow for adding, organizing, and managing database changes with pgpm.
116
+
117
+ 4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
118
+ Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
119
+
120
+ 5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
121
+ Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
122
+
123
+ 6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
124
+ Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
125
+
126
+ 7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
127
+ Common issues and solutions for pgpm, PostgreSQL, and testing.
128
+
129
+ ## Related Constructive Tooling
130
+
131
+ ### 📦 Package Management
132
+
133
+ * [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
134
+
135
+ ### 🧪 Testing
136
+
137
+ * [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
138
+ * [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
139
+ * [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
140
+ * [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
141
+ * [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
142
+
143
+ ### 🧠 Parsing & AST
144
+
145
+ * [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
146
+ * [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
147
+ * [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
148
+ * [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
149
+ * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
150
+ * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
151
+
152
+ ## Credits
153
+
154
+ **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
155
+
156
+ ## Disclaimer
157
+
158
+ AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
159
+
160
+ No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
package/context.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { GraphQLSchema, ExecutionResult } from 'graphql';
2
+ import type { GraphileConfig } from 'graphile-config';
3
+ import type { PgTestClient } from 'pgsql-test/test-client';
4
+ import type { GetConnectionOpts } from 'pgsql-test';
5
+ import type { SeedAdapter } from 'pgsql-test/seed/types';
6
+ import type { GetConnectionsInput } from 'graphile-test';
7
+ /**
8
+ * Input for creating a realtime test context.
9
+ */
10
+ export interface RealtimeTestInput extends GetConnectionsInput, GetConnectionOpts {
11
+ /**
12
+ * Map of table names to smart tags to inject.
13
+ * The `@realtime` tag is injected automatically for tables listed here.
14
+ *
15
+ * Example: `{ items: { realtime: true } }`
16
+ *
17
+ * If you only pass table names without tags, use `realtimeTables` instead.
18
+ */
19
+ smartTags?: Record<string, Record<string, unknown>>;
20
+ /**
21
+ * Shorthand: list of table names that should get `{ realtime: true }`.
22
+ * Merged with `smartTags` if both are provided.
23
+ */
24
+ realtimeTables?: string[];
25
+ /**
26
+ * Options for the realtime subscriptions plugin (e.g. overflowThreshold).
27
+ */
28
+ realtimeOptions?: {
29
+ overflowThreshold?: number;
30
+ };
31
+ }
32
+ /**
33
+ * The context returned by `createRealtimeTestContext()`.
34
+ */
35
+ export interface RealtimeTestContext {
36
+ /** Root pg test client (superuser) */
37
+ pg: PgTestClient;
38
+ /** Database-scoped test client */
39
+ db: PgTestClient;
40
+ /** The built GraphQL schema (includes subscription fields) */
41
+ schema: GraphQLSchema;
42
+ /** The resolved Graphile preset */
43
+ resolvedPreset: GraphileConfig.ResolvedPreset;
44
+ /**
45
+ * Start a GraphQL subscription and return the async iterator.
46
+ *
47
+ * The returned iterator yields `ExecutionResult` events. Call `.return()`
48
+ * on it when done to clean up.
49
+ */
50
+ subscribe(query: string, variables?: Record<string, unknown>, contextOverrides?: Record<string, unknown>): Promise<AsyncIterableIterator<ExecutionResult> | ExecutionResult>;
51
+ /**
52
+ * Fire a pg_notify on a realtime channel.
53
+ *
54
+ * @param table - Table name (unqualified)
55
+ * @param operation - DML operation
56
+ * @param rowIds - Affected row IDs
57
+ * @param schema - Schema name (default: the first schema from input)
58
+ */
59
+ notifyChange(table: string, operation: 'INSERT' | 'UPDATE' | 'DELETE', rowIds: string[], schema?: string): Promise<void>;
60
+ /**
61
+ * Fire an INVALIDATE (overflow) NOTIFY.
62
+ */
63
+ notifyInvalidate(table: string, schema?: string): Promise<void>;
64
+ /**
65
+ * Fire a raw pg_notify on a realtime channel.
66
+ */
67
+ notify(table: string, payload: string, schema?: string): Promise<void>;
68
+ /** Tear down the test context and close connections */
69
+ teardown(): Promise<void>;
70
+ }
71
+ /**
72
+ * Create a fully wired realtime test context.
73
+ *
74
+ * This sets up:
75
+ * - A PostgreSQL test database with the given seed
76
+ * - Smart tag injection via `makeRealtimeSmartTagsPlugin`
77
+ * - The `RealtimeSubscriptionsPlugin` for subscription field generation
78
+ * - A built GraphQL schema with subscription support
79
+ * - Helper functions for subscribing, notifying, and asserting
80
+ *
81
+ * @param input - Configuration including schemas, tables, and options
82
+ * @param seedAdapters - Optional SQL seed adapters (from `pgsql-test`)
83
+ * @returns A `RealtimeTestContext` with all the helpers
84
+ */
85
+ export declare function createRealtimeTestContext(input: RealtimeTestInput, seedAdapters?: SeedAdapter[]): Promise<RealtimeTestContext>;
package/context.js ADDED
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRealtimeTestContext = createRealtimeTestContext;
4
+ const pgsql_test_1 = require("pgsql-test");
5
+ const graphile_build_1 = require("graphile-build");
6
+ const graphile_build_2 = require("graphile-build");
7
+ const graphile_build_pg_1 = require("graphile-build-pg");
8
+ const pg_1 = require("postgraphile/adaptors/pg");
9
+ const graphile_realtime_subscriptions_1 = require("graphile-realtime-subscriptions");
10
+ const subscribe_js_1 = require("./subscribe.js");
11
+ const notify_js_1 = require("./notify.js");
12
+ const smart_tags_js_1 = require("./smart-tags.js");
13
+ /**
14
+ * Minimal preset matching graphile-test's MinimalPreset.
15
+ */
16
+ const MinimalPreset = {
17
+ extends: [graphile_build_2.defaultPreset, graphile_build_pg_1.defaultPreset],
18
+ disablePlugins: ['NodePlugin'],
19
+ };
20
+ /**
21
+ * Create a fully wired realtime test context.
22
+ *
23
+ * This sets up:
24
+ * - A PostgreSQL test database with the given seed
25
+ * - Smart tag injection via `makeRealtimeSmartTagsPlugin`
26
+ * - The `RealtimeSubscriptionsPlugin` for subscription field generation
27
+ * - A built GraphQL schema with subscription support
28
+ * - Helper functions for subscribing, notifying, and asserting
29
+ *
30
+ * @param input - Configuration including schemas, tables, and options
31
+ * @param seedAdapters - Optional SQL seed adapters (from `pgsql-test`)
32
+ * @returns A `RealtimeTestContext` with all the helpers
33
+ */
34
+ async function createRealtimeTestContext(input, seedAdapters) {
35
+ const { schemas, preset: userPreset, realtimeOptions } = input;
36
+ // Build smart tags map
37
+ const smartTagsMap = {
38
+ ...(input.smartTags ?? {}),
39
+ };
40
+ for (const table of input.realtimeTables ?? []) {
41
+ if (!smartTagsMap[table]) {
42
+ smartTagsMap[table] = {};
43
+ }
44
+ smartTagsMap[table].realtime = true;
45
+ }
46
+ // Get database connections
47
+ const conn = await (0, pgsql_test_1.getConnections)(input, seedAdapters);
48
+ const { pg, db, teardown: dbTeardown } = conn;
49
+ const pgPool = conn.manager.getPool(conn.pg.config);
50
+ // Build the schema with realtime plugins
51
+ const smartTagsPlugin = (0, smart_tags_js_1.makeRealtimeSmartTagsPlugin)(smartTagsMap);
52
+ const realtimePlugin = (0, graphile_realtime_subscriptions_1.createRealtimeSubscriptionsPlugin)(realtimeOptions);
53
+ // Create pgService — this also creates the PgSubscriber that
54
+ // listens for NOTIFY events on a dedicated PostgreSQL connection
55
+ const pgService = (0, pg_1.makePgService)({
56
+ pool: pgPool,
57
+ schemas,
58
+ });
59
+ const completePreset = {
60
+ extends: [
61
+ MinimalPreset,
62
+ ...(userPreset?.extends ?? []),
63
+ ],
64
+ ...(userPreset?.disablePlugins && { disablePlugins: userPreset.disablePlugins }),
65
+ plugins: [
66
+ ...(userPreset?.plugins ?? []),
67
+ smartTagsPlugin,
68
+ realtimePlugin,
69
+ ],
70
+ ...(userPreset?.schema && { schema: userPreset.schema }),
71
+ ...(userPreset?.grafast && { grafast: userPreset.grafast }),
72
+ pgServices: [pgService],
73
+ };
74
+ const result = await (0, graphile_build_1.makeSchema)(completePreset);
75
+ const schema = result.schema;
76
+ const resolvedPreset = result.resolvedPreset;
77
+ const defaultSchema = schemas[0] ?? 'public';
78
+ // Use root pg client for NOTIFY so it's visible outside the test transaction
79
+ const notifyClient = pg.client;
80
+ // Extract the pgSubscriber from the pgService — subscriptions need it
81
+ // in the grafast context to receive LISTEN/NOTIFY events
82
+ const pgSubscriber = pgService.pgSubscriber;
83
+ const pgSubscriberKey = pgService.pgSubscriberKey ?? 'pgSubscriber';
84
+ const teardown = async () => {
85
+ if (pgSubscriber && typeof pgSubscriber.release === 'function') {
86
+ await pgSubscriber.release();
87
+ }
88
+ if (pgService && typeof pgService.release === 'function') {
89
+ await pgService.release();
90
+ }
91
+ await dbTeardown();
92
+ };
93
+ return {
94
+ pg,
95
+ db,
96
+ schema,
97
+ resolvedPreset,
98
+ async subscribe(query, variables, contextOverrides) {
99
+ // Build the context that grafast.subscribe needs.
100
+ // The subscription plan accesses pgSubscriber via grafastContext().get('pgSubscriber')
101
+ const contextValue = {
102
+ [pgSubscriberKey]: pgSubscriber,
103
+ ...contextOverrides,
104
+ };
105
+ return (0, subscribe_js_1.subscribe)({
106
+ schema,
107
+ resolvedPreset,
108
+ query,
109
+ variables,
110
+ contextValue,
111
+ });
112
+ },
113
+ async notifyChange(table, operation, rowIds, tableSchema) {
114
+ await (0, notify_js_1.notifyChange)(notifyClient, tableSchema ?? defaultSchema, table, operation, rowIds);
115
+ },
116
+ async notifyInvalidate(table, tableSchema) {
117
+ await (0, notify_js_1.notifyInvalidate)(notifyClient, tableSchema ?? defaultSchema, table);
118
+ },
119
+ async notify(table, payload, tableSchema) {
120
+ await (0, notify_js_1.notify)(notifyClient, tableSchema ?? defaultSchema, table, payload);
121
+ },
122
+ teardown,
123
+ };
124
+ }
package/esm/context.js ADDED
@@ -0,0 +1,121 @@
1
+ import { getConnections as getPgConnections } from 'pgsql-test';
2
+ import { makeSchema } from 'graphile-build';
3
+ import { defaultPreset as graphileBuildDefaultPreset } from 'graphile-build';
4
+ import { defaultPreset as graphileBuildPgDefaultPreset } from 'graphile-build-pg';
5
+ import { makePgService } from 'postgraphile/adaptors/pg';
6
+ import { createRealtimeSubscriptionsPlugin } from 'graphile-realtime-subscriptions';
7
+ import { subscribe as subscribeHelper } from './subscribe.js';
8
+ import { notify, notifyChange, notifyInvalidate } from './notify.js';
9
+ import { makeRealtimeSmartTagsPlugin } from './smart-tags.js';
10
+ /**
11
+ * Minimal preset matching graphile-test's MinimalPreset.
12
+ */
13
+ const MinimalPreset = {
14
+ extends: [graphileBuildDefaultPreset, graphileBuildPgDefaultPreset],
15
+ disablePlugins: ['NodePlugin'],
16
+ };
17
+ /**
18
+ * Create a fully wired realtime test context.
19
+ *
20
+ * This sets up:
21
+ * - A PostgreSQL test database with the given seed
22
+ * - Smart tag injection via `makeRealtimeSmartTagsPlugin`
23
+ * - The `RealtimeSubscriptionsPlugin` for subscription field generation
24
+ * - A built GraphQL schema with subscription support
25
+ * - Helper functions for subscribing, notifying, and asserting
26
+ *
27
+ * @param input - Configuration including schemas, tables, and options
28
+ * @param seedAdapters - Optional SQL seed adapters (from `pgsql-test`)
29
+ * @returns A `RealtimeTestContext` with all the helpers
30
+ */
31
+ export async function createRealtimeTestContext(input, seedAdapters) {
32
+ const { schemas, preset: userPreset, realtimeOptions } = input;
33
+ // Build smart tags map
34
+ const smartTagsMap = {
35
+ ...(input.smartTags ?? {}),
36
+ };
37
+ for (const table of input.realtimeTables ?? []) {
38
+ if (!smartTagsMap[table]) {
39
+ smartTagsMap[table] = {};
40
+ }
41
+ smartTagsMap[table].realtime = true;
42
+ }
43
+ // Get database connections
44
+ const conn = await getPgConnections(input, seedAdapters);
45
+ const { pg, db, teardown: dbTeardown } = conn;
46
+ const pgPool = conn.manager.getPool(conn.pg.config);
47
+ // Build the schema with realtime plugins
48
+ const smartTagsPlugin = makeRealtimeSmartTagsPlugin(smartTagsMap);
49
+ const realtimePlugin = createRealtimeSubscriptionsPlugin(realtimeOptions);
50
+ // Create pgService — this also creates the PgSubscriber that
51
+ // listens for NOTIFY events on a dedicated PostgreSQL connection
52
+ const pgService = makePgService({
53
+ pool: pgPool,
54
+ schemas,
55
+ });
56
+ const completePreset = {
57
+ extends: [
58
+ MinimalPreset,
59
+ ...(userPreset?.extends ?? []),
60
+ ],
61
+ ...(userPreset?.disablePlugins && { disablePlugins: userPreset.disablePlugins }),
62
+ plugins: [
63
+ ...(userPreset?.plugins ?? []),
64
+ smartTagsPlugin,
65
+ realtimePlugin,
66
+ ],
67
+ ...(userPreset?.schema && { schema: userPreset.schema }),
68
+ ...(userPreset?.grafast && { grafast: userPreset.grafast }),
69
+ pgServices: [pgService],
70
+ };
71
+ const result = await makeSchema(completePreset);
72
+ const schema = result.schema;
73
+ const resolvedPreset = result.resolvedPreset;
74
+ const defaultSchema = schemas[0] ?? 'public';
75
+ // Use root pg client for NOTIFY so it's visible outside the test transaction
76
+ const notifyClient = pg.client;
77
+ // Extract the pgSubscriber from the pgService — subscriptions need it
78
+ // in the grafast context to receive LISTEN/NOTIFY events
79
+ const pgSubscriber = pgService.pgSubscriber;
80
+ const pgSubscriberKey = pgService.pgSubscriberKey ?? 'pgSubscriber';
81
+ const teardown = async () => {
82
+ if (pgSubscriber && typeof pgSubscriber.release === 'function') {
83
+ await pgSubscriber.release();
84
+ }
85
+ if (pgService && typeof pgService.release === 'function') {
86
+ await pgService.release();
87
+ }
88
+ await dbTeardown();
89
+ };
90
+ return {
91
+ pg,
92
+ db,
93
+ schema,
94
+ resolvedPreset,
95
+ async subscribe(query, variables, contextOverrides) {
96
+ // Build the context that grafast.subscribe needs.
97
+ // The subscription plan accesses pgSubscriber via grafastContext().get('pgSubscriber')
98
+ const contextValue = {
99
+ [pgSubscriberKey]: pgSubscriber,
100
+ ...contextOverrides,
101
+ };
102
+ return subscribeHelper({
103
+ schema,
104
+ resolvedPreset,
105
+ query,
106
+ variables,
107
+ contextValue,
108
+ });
109
+ },
110
+ async notifyChange(table, operation, rowIds, tableSchema) {
111
+ await notifyChange(notifyClient, tableSchema ?? defaultSchema, table, operation, rowIds);
112
+ },
113
+ async notifyInvalidate(table, tableSchema) {
114
+ await notifyInvalidate(notifyClient, tableSchema ?? defaultSchema, table);
115
+ },
116
+ async notify(table, payload, tableSchema) {
117
+ await notify(notifyClient, tableSchema ?? defaultSchema, table, payload);
118
+ },
119
+ teardown,
120
+ };
121
+ }
package/esm/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createRealtimeTestContext } from './context.js';
2
+ export { makeRealtimeSmartTagsPlugin } from './smart-tags.js';
3
+ export { subscribe, waitForEvent, collectEvents, } from './subscribe.js';
4
+ export { notify, notifyChange, notifyInvalidate, buildPayload, buildInvalidatePayload, } from './notify.js';
package/esm/notify.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Fire a pg_notify on the realtime channel for a given schema.table.
3
+ *
4
+ * The channel format matches what emit_change uses:
5
+ * `realtime:<schema>.<table>`
6
+ *
7
+ * @param client - A pg Client (or PoolClient) to run the NOTIFY on
8
+ * @param schema - The PostgreSQL schema name (e.g. 'public')
9
+ * @param table - The table name (e.g. 'items')
10
+ * @param payload - The NOTIFY payload string
11
+ */
12
+ export async function notify(client, schema, table, payload) {
13
+ const channel = `realtime:${schema}.${table}`;
14
+ await client.query(`SELECT pg_notify($1, $2)`, [channel, payload]);
15
+ }
16
+ /**
17
+ * Build a standard DML payload string in the format emit_change uses.
18
+ *
19
+ * @param operation - The DML operation: 'INSERT', 'UPDATE', or 'DELETE'
20
+ * @param rowIds - Array of affected row IDs (UUIDs)
21
+ * @returns Formatted payload string (e.g. "INSERT:uuid1,uuid2")
22
+ */
23
+ export function buildPayload(operation, rowIds) {
24
+ if (rowIds.length === 0) {
25
+ return operation;
26
+ }
27
+ return `${operation}:${rowIds.join(',')}`;
28
+ }
29
+ /**
30
+ * Build an INVALIDATE (overflow) payload.
31
+ */
32
+ export function buildInvalidatePayload() {
33
+ return 'INVALIDATE';
34
+ }
35
+ /**
36
+ * Convenience: fire a DML NOTIFY for a table.
37
+ *
38
+ * @param client - A pg Client to run the NOTIFY on
39
+ * @param schema - The PostgreSQL schema name
40
+ * @param table - The table name
41
+ * @param operation - The DML operation
42
+ * @param rowIds - Array of affected row IDs
43
+ */
44
+ export async function notifyChange(client, schema, table, operation, rowIds) {
45
+ await notify(client, schema, table, buildPayload(operation, rowIds));
46
+ }
47
+ /**
48
+ * Convenience: fire an INVALIDATE NOTIFY for a table.
49
+ */
50
+ export async function notifyInvalidate(client, schema, table) {
51
+ await notify(client, schema, table, buildInvalidatePayload());
52
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Creates a Graphile plugin that injects smart tags on table codecs during
3
+ * the schema build phase. This runs early (before RealtimeSubscriptionsPlugin)
4
+ * so that @realtime and other tags are visible when the subscription plugin
5
+ * discovers tables.
6
+ *
7
+ * @param tagsByTable - Map of table name to tags to inject.
8
+ * Example: `{ items: { realtime: true } }`
9
+ */
10
+ export function makeRealtimeSmartTagsPlugin(tagsByTable) {
11
+ return {
12
+ name: 'RealtimeTestSmartTagsPlugin',
13
+ version: '1.0.0',
14
+ schema: {
15
+ hooks: {
16
+ init: {
17
+ before: ['RealtimeSubscriptionsPlugin'],
18
+ callback(_, build) {
19
+ for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
20
+ const c = codec;
21
+ if (!c.attributes || !c.name)
22
+ continue;
23
+ const tags = tagsByTable[c.name];
24
+ if (!tags)
25
+ continue;
26
+ if (!c.extensions)
27
+ c.extensions = {};
28
+ if (!c.extensions.tags)
29
+ c.extensions.tags = {};
30
+ Object.assign(c.extensions.tags, tags);
31
+ }
32
+ return _;
33
+ },
34
+ },
35
+ },
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,73 @@
1
+ import { subscribe as grafastSubscribe } from 'grafast';
2
+ import { parse } from 'graphql';
3
+ /**
4
+ * Call grafast.subscribe() and return the raw async iterator.
5
+ *
6
+ * The caller is responsible for iterating the result and calling `.return()`
7
+ * when done.
8
+ *
9
+ * @returns An AsyncIterableIterator that yields ExecutionResult events,
10
+ * or an ExecutionResult if subscription setup failed.
11
+ */
12
+ export async function subscribe(opts) {
13
+ const document = typeof opts.query === 'string' ? parse(opts.query) : opts.query;
14
+ const result = await grafastSubscribe({
15
+ schema: opts.schema,
16
+ document,
17
+ variableValues: opts.variables ?? undefined,
18
+ contextValue: opts.contextValue ?? {},
19
+ resolvedPreset: opts.resolvedPreset,
20
+ });
21
+ return result;
22
+ }
23
+ /**
24
+ * Wait for the next event from a subscription iterator, with a timeout.
25
+ *
26
+ * @param iterator - The async iterator returned by `subscribe()`
27
+ * @param timeoutMs - Maximum time to wait (default: 5000ms)
28
+ * @returns The next subscription event
29
+ * @throws If the timeout is exceeded or the iterator completes without a value
30
+ */
31
+ export async function waitForEvent(iterator, timeoutMs = 5000) {
32
+ return new Promise((resolve, reject) => {
33
+ const timer = setTimeout(() => {
34
+ reject(new Error(`waitForEvent timed out after ${timeoutMs}ms`));
35
+ }, timeoutMs);
36
+ iterator
37
+ .next()
38
+ .then((result) => {
39
+ clearTimeout(timer);
40
+ if (result.done) {
41
+ reject(new Error('Subscription iterator completed without yielding a value'));
42
+ }
43
+ else {
44
+ resolve(result.value);
45
+ }
46
+ })
47
+ .catch((err) => {
48
+ clearTimeout(timer);
49
+ reject(err);
50
+ });
51
+ });
52
+ }
53
+ /**
54
+ * Collect multiple events from a subscription iterator.
55
+ *
56
+ * @param iterator - The async iterator returned by `subscribe()`
57
+ * @param count - Number of events to collect
58
+ * @param timeoutMs - Maximum time to wait for all events (default: 10000ms)
59
+ * @returns Array of collected events
60
+ */
61
+ export async function collectEvents(iterator, count, timeoutMs = 10000) {
62
+ const events = [];
63
+ const deadline = Date.now() + timeoutMs;
64
+ for (let i = 0; i < count; i++) {
65
+ const remaining = deadline - Date.now();
66
+ if (remaining <= 0) {
67
+ throw new Error(`collectEvents timed out after ${timeoutMs}ms (collected ${events.length}/${count})`);
68
+ }
69
+ const event = await waitForEvent(iterator, remaining);
70
+ events.push(event);
71
+ }
72
+ return events;
73
+ }
package/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createRealtimeTestContext } from './context.js';
2
+ export type { RealtimeTestContext, RealtimeTestInput } from './context.js';
3
+ export { makeRealtimeSmartTagsPlugin } from './smart-tags.js';
4
+ export { subscribe, waitForEvent, collectEvents, } from './subscribe.js';
5
+ export type { SubscriptionEvent, SubscribeOptions } from './subscribe.js';
6
+ export { notify, notifyChange, notifyInvalidate, buildPayload, buildInvalidatePayload, } from './notify.js';
package/index.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildInvalidatePayload = exports.buildPayload = exports.notifyInvalidate = exports.notifyChange = exports.notify = exports.collectEvents = exports.waitForEvent = exports.subscribe = exports.makeRealtimeSmartTagsPlugin = exports.createRealtimeTestContext = void 0;
4
+ var context_js_1 = require("./context.js");
5
+ Object.defineProperty(exports, "createRealtimeTestContext", { enumerable: true, get: function () { return context_js_1.createRealtimeTestContext; } });
6
+ var smart_tags_js_1 = require("./smart-tags.js");
7
+ Object.defineProperty(exports, "makeRealtimeSmartTagsPlugin", { enumerable: true, get: function () { return smart_tags_js_1.makeRealtimeSmartTagsPlugin; } });
8
+ var subscribe_js_1 = require("./subscribe.js");
9
+ Object.defineProperty(exports, "subscribe", { enumerable: true, get: function () { return subscribe_js_1.subscribe; } });
10
+ Object.defineProperty(exports, "waitForEvent", { enumerable: true, get: function () { return subscribe_js_1.waitForEvent; } });
11
+ Object.defineProperty(exports, "collectEvents", { enumerable: true, get: function () { return subscribe_js_1.collectEvents; } });
12
+ var notify_js_1 = require("./notify.js");
13
+ Object.defineProperty(exports, "notify", { enumerable: true, get: function () { return notify_js_1.notify; } });
14
+ Object.defineProperty(exports, "notifyChange", { enumerable: true, get: function () { return notify_js_1.notifyChange; } });
15
+ Object.defineProperty(exports, "notifyInvalidate", { enumerable: true, get: function () { return notify_js_1.notifyInvalidate; } });
16
+ Object.defineProperty(exports, "buildPayload", { enumerable: true, get: function () { return notify_js_1.buildPayload; } });
17
+ Object.defineProperty(exports, "buildInvalidatePayload", { enumerable: true, get: function () { return notify_js_1.buildInvalidatePayload; } });
package/notify.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Client } from 'pg';
2
+ /**
3
+ * Fire a pg_notify on the realtime channel for a given schema.table.
4
+ *
5
+ * The channel format matches what emit_change uses:
6
+ * `realtime:<schema>.<table>`
7
+ *
8
+ * @param client - A pg Client (or PoolClient) to run the NOTIFY on
9
+ * @param schema - The PostgreSQL schema name (e.g. 'public')
10
+ * @param table - The table name (e.g. 'items')
11
+ * @param payload - The NOTIFY payload string
12
+ */
13
+ export declare function notify(client: Client, schema: string, table: string, payload: string): Promise<void>;
14
+ /**
15
+ * Build a standard DML payload string in the format emit_change uses.
16
+ *
17
+ * @param operation - The DML operation: 'INSERT', 'UPDATE', or 'DELETE'
18
+ * @param rowIds - Array of affected row IDs (UUIDs)
19
+ * @returns Formatted payload string (e.g. "INSERT:uuid1,uuid2")
20
+ */
21
+ export declare function buildPayload(operation: 'INSERT' | 'UPDATE' | 'DELETE', rowIds: string[]): string;
22
+ /**
23
+ * Build an INVALIDATE (overflow) payload.
24
+ */
25
+ export declare function buildInvalidatePayload(): string;
26
+ /**
27
+ * Convenience: fire a DML NOTIFY for a table.
28
+ *
29
+ * @param client - A pg Client to run the NOTIFY on
30
+ * @param schema - The PostgreSQL schema name
31
+ * @param table - The table name
32
+ * @param operation - The DML operation
33
+ * @param rowIds - Array of affected row IDs
34
+ */
35
+ export declare function notifyChange(client: Client, schema: string, table: string, operation: 'INSERT' | 'UPDATE' | 'DELETE', rowIds: string[]): Promise<void>;
36
+ /**
37
+ * Convenience: fire an INVALIDATE NOTIFY for a table.
38
+ */
39
+ export declare function notifyInvalidate(client: Client, schema: string, table: string): Promise<void>;
package/notify.js ADDED
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.notify = notify;
4
+ exports.buildPayload = buildPayload;
5
+ exports.buildInvalidatePayload = buildInvalidatePayload;
6
+ exports.notifyChange = notifyChange;
7
+ exports.notifyInvalidate = notifyInvalidate;
8
+ /**
9
+ * Fire a pg_notify on the realtime channel for a given schema.table.
10
+ *
11
+ * The channel format matches what emit_change uses:
12
+ * `realtime:<schema>.<table>`
13
+ *
14
+ * @param client - A pg Client (or PoolClient) to run the NOTIFY on
15
+ * @param schema - The PostgreSQL schema name (e.g. 'public')
16
+ * @param table - The table name (e.g. 'items')
17
+ * @param payload - The NOTIFY payload string
18
+ */
19
+ async function notify(client, schema, table, payload) {
20
+ const channel = `realtime:${schema}.${table}`;
21
+ await client.query(`SELECT pg_notify($1, $2)`, [channel, payload]);
22
+ }
23
+ /**
24
+ * Build a standard DML payload string in the format emit_change uses.
25
+ *
26
+ * @param operation - The DML operation: 'INSERT', 'UPDATE', or 'DELETE'
27
+ * @param rowIds - Array of affected row IDs (UUIDs)
28
+ * @returns Formatted payload string (e.g. "INSERT:uuid1,uuid2")
29
+ */
30
+ function buildPayload(operation, rowIds) {
31
+ if (rowIds.length === 0) {
32
+ return operation;
33
+ }
34
+ return `${operation}:${rowIds.join(',')}`;
35
+ }
36
+ /**
37
+ * Build an INVALIDATE (overflow) payload.
38
+ */
39
+ function buildInvalidatePayload() {
40
+ return 'INVALIDATE';
41
+ }
42
+ /**
43
+ * Convenience: fire a DML NOTIFY for a table.
44
+ *
45
+ * @param client - A pg Client to run the NOTIFY on
46
+ * @param schema - The PostgreSQL schema name
47
+ * @param table - The table name
48
+ * @param operation - The DML operation
49
+ * @param rowIds - Array of affected row IDs
50
+ */
51
+ async function notifyChange(client, schema, table, operation, rowIds) {
52
+ await notify(client, schema, table, buildPayload(operation, rowIds));
53
+ }
54
+ /**
55
+ * Convenience: fire an INVALIDATE NOTIFY for a table.
56
+ */
57
+ async function notifyInvalidate(client, schema, table) {
58
+ await notify(client, schema, table, buildInvalidatePayload());
59
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "graphile-realtime-test",
3
+ "version": "0.2.0",
4
+ "description": "Subscription testing utilities for graphile-realtime-subscriptions — smart tag injection, grafast.subscribe() helpers, and pg_notify simulation",
5
+ "author": "Constructive <developers@constructive.io>",
6
+ "homepage": "https://github.com/constructive-io/constructive",
7
+ "license": "MIT",
8
+ "main": "index.js",
9
+ "module": "esm/index.js",
10
+ "types": "index.d.ts",
11
+ "scripts": {
12
+ "clean": "makage clean",
13
+ "prepack": "npm run build",
14
+ "build": "makage build",
15
+ "build:dev": "makage build --dev",
16
+ "lint": "eslint . --fix",
17
+ "test": "jest --passWithNoTests",
18
+ "test:watch": "jest --watch"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "directory": "dist"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/constructive-io/constructive"
27
+ },
28
+ "keywords": [
29
+ "testing",
30
+ "graphql",
31
+ "graphile",
32
+ "constructive",
33
+ "subscriptions",
34
+ "realtime",
35
+ "notify",
36
+ "test"
37
+ ],
38
+ "bugs": {
39
+ "url": "https://github.com/constructive-io/constructive/issues"
40
+ },
41
+ "dependencies": {
42
+ "graphile-realtime-subscriptions": "^0.5.2",
43
+ "graphile-test": "^4.14.2"
44
+ },
45
+ "peerDependencies": {
46
+ "grafast": "1.0.0",
47
+ "graphile-build": "5.0.0",
48
+ "graphile-build-pg": "5.0.0",
49
+ "graphile-config": "1.0.0",
50
+ "graphql": "16.13.0",
51
+ "pg": "^8.20.0",
52
+ "postgraphile": "5.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/pg": "^8.18.0",
56
+ "makage": "^0.3.0",
57
+ "pgsql-test": "^4.13.2"
58
+ },
59
+ "gitHead": "ea590e1b9e1ee38c267f8dbbb37aa3f83a5d3fb7"
60
+ }
@@ -0,0 +1,11 @@
1
+ import type { GraphileConfig } from 'graphile-config';
2
+ /**
3
+ * Creates a Graphile plugin that injects smart tags on table codecs during
4
+ * the schema build phase. This runs early (before RealtimeSubscriptionsPlugin)
5
+ * so that @realtime and other tags are visible when the subscription plugin
6
+ * discovers tables.
7
+ *
8
+ * @param tagsByTable - Map of table name to tags to inject.
9
+ * Example: `{ items: { realtime: true } }`
10
+ */
11
+ export declare function makeRealtimeSmartTagsPlugin(tagsByTable: Record<string, Record<string, unknown>>): GraphileConfig.Plugin;
package/smart-tags.js ADDED
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeRealtimeSmartTagsPlugin = makeRealtimeSmartTagsPlugin;
4
+ /**
5
+ * Creates a Graphile plugin that injects smart tags on table codecs during
6
+ * the schema build phase. This runs early (before RealtimeSubscriptionsPlugin)
7
+ * so that @realtime and other tags are visible when the subscription plugin
8
+ * discovers tables.
9
+ *
10
+ * @param tagsByTable - Map of table name to tags to inject.
11
+ * Example: `{ items: { realtime: true } }`
12
+ */
13
+ function makeRealtimeSmartTagsPlugin(tagsByTable) {
14
+ return {
15
+ name: 'RealtimeTestSmartTagsPlugin',
16
+ version: '1.0.0',
17
+ schema: {
18
+ hooks: {
19
+ init: {
20
+ before: ['RealtimeSubscriptionsPlugin'],
21
+ callback(_, build) {
22
+ for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
23
+ const c = codec;
24
+ if (!c.attributes || !c.name)
25
+ continue;
26
+ const tags = tagsByTable[c.name];
27
+ if (!tags)
28
+ continue;
29
+ if (!c.extensions)
30
+ c.extensions = {};
31
+ if (!c.extensions.tags)
32
+ c.extensions.tags = {};
33
+ Object.assign(c.extensions.tags, tags);
34
+ }
35
+ return _;
36
+ },
37
+ },
38
+ },
39
+ },
40
+ };
41
+ }
package/subscribe.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { GraphQLSchema, DocumentNode, ExecutionResult } from 'graphql';
2
+ import type { GraphileConfig } from 'graphile-config';
3
+ /**
4
+ * A single event yielded by a subscription iterator.
5
+ */
6
+ export interface SubscriptionEvent<TData = Record<string, unknown>> {
7
+ data?: TData | null;
8
+ errors?: readonly {
9
+ message: string;
10
+ }[];
11
+ }
12
+ /**
13
+ * Options for `subscribe()`.
14
+ */
15
+ export interface SubscribeOptions {
16
+ schema: GraphQLSchema;
17
+ resolvedPreset: GraphileConfig.ResolvedPreset;
18
+ query: string | DocumentNode;
19
+ variables?: Record<string, unknown>;
20
+ contextValue?: Record<string, unknown>;
21
+ }
22
+ /**
23
+ * Call grafast.subscribe() and return the raw async iterator.
24
+ *
25
+ * The caller is responsible for iterating the result and calling `.return()`
26
+ * when done.
27
+ *
28
+ * @returns An AsyncIterableIterator that yields ExecutionResult events,
29
+ * or an ExecutionResult if subscription setup failed.
30
+ */
31
+ export declare function subscribe(opts: SubscribeOptions): Promise<AsyncIterableIterator<ExecutionResult> | ExecutionResult>;
32
+ /**
33
+ * Wait for the next event from a subscription iterator, with a timeout.
34
+ *
35
+ * @param iterator - The async iterator returned by `subscribe()`
36
+ * @param timeoutMs - Maximum time to wait (default: 5000ms)
37
+ * @returns The next subscription event
38
+ * @throws If the timeout is exceeded or the iterator completes without a value
39
+ */
40
+ export declare function waitForEvent<TData = Record<string, unknown>>(iterator: AsyncIterableIterator<ExecutionResult>, timeoutMs?: number): Promise<SubscriptionEvent<TData>>;
41
+ /**
42
+ * Collect multiple events from a subscription iterator.
43
+ *
44
+ * @param iterator - The async iterator returned by `subscribe()`
45
+ * @param count - Number of events to collect
46
+ * @param timeoutMs - Maximum time to wait for all events (default: 10000ms)
47
+ * @returns Array of collected events
48
+ */
49
+ export declare function collectEvents<TData = Record<string, unknown>>(iterator: AsyncIterableIterator<ExecutionResult>, count: number, timeoutMs?: number): Promise<SubscriptionEvent<TData>[]>;
package/subscribe.js ADDED
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.subscribe = subscribe;
4
+ exports.waitForEvent = waitForEvent;
5
+ exports.collectEvents = collectEvents;
6
+ const grafast_1 = require("grafast");
7
+ const graphql_1 = require("graphql");
8
+ /**
9
+ * Call grafast.subscribe() and return the raw async iterator.
10
+ *
11
+ * The caller is responsible for iterating the result and calling `.return()`
12
+ * when done.
13
+ *
14
+ * @returns An AsyncIterableIterator that yields ExecutionResult events,
15
+ * or an ExecutionResult if subscription setup failed.
16
+ */
17
+ async function subscribe(opts) {
18
+ const document = typeof opts.query === 'string' ? (0, graphql_1.parse)(opts.query) : opts.query;
19
+ const result = await (0, grafast_1.subscribe)({
20
+ schema: opts.schema,
21
+ document,
22
+ variableValues: opts.variables ?? undefined,
23
+ contextValue: opts.contextValue ?? {},
24
+ resolvedPreset: opts.resolvedPreset,
25
+ });
26
+ return result;
27
+ }
28
+ /**
29
+ * Wait for the next event from a subscription iterator, with a timeout.
30
+ *
31
+ * @param iterator - The async iterator returned by `subscribe()`
32
+ * @param timeoutMs - Maximum time to wait (default: 5000ms)
33
+ * @returns The next subscription event
34
+ * @throws If the timeout is exceeded or the iterator completes without a value
35
+ */
36
+ async function waitForEvent(iterator, timeoutMs = 5000) {
37
+ return new Promise((resolve, reject) => {
38
+ const timer = setTimeout(() => {
39
+ reject(new Error(`waitForEvent timed out after ${timeoutMs}ms`));
40
+ }, timeoutMs);
41
+ iterator
42
+ .next()
43
+ .then((result) => {
44
+ clearTimeout(timer);
45
+ if (result.done) {
46
+ reject(new Error('Subscription iterator completed without yielding a value'));
47
+ }
48
+ else {
49
+ resolve(result.value);
50
+ }
51
+ })
52
+ .catch((err) => {
53
+ clearTimeout(timer);
54
+ reject(err);
55
+ });
56
+ });
57
+ }
58
+ /**
59
+ * Collect multiple events from a subscription iterator.
60
+ *
61
+ * @param iterator - The async iterator returned by `subscribe()`
62
+ * @param count - Number of events to collect
63
+ * @param timeoutMs - Maximum time to wait for all events (default: 10000ms)
64
+ * @returns Array of collected events
65
+ */
66
+ async function collectEvents(iterator, count, timeoutMs = 10000) {
67
+ const events = [];
68
+ const deadline = Date.now() + timeoutMs;
69
+ for (let i = 0; i < count; i++) {
70
+ const remaining = deadline - Date.now();
71
+ if (remaining <= 0) {
72
+ throw new Error(`collectEvents timed out after ${timeoutMs}ms (collected ${events.length}/${count})`);
73
+ }
74
+ const event = await waitForEvent(iterator, remaining);
75
+ events.push(event);
76
+ }
77
+ return events;
78
+ }