diff --git a/CHANGELOG.md b/CHANGELOG.md index 662cb6f..400edd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- `SSEAdapter` interface and `AdapterMapping` type for building database/event source adapters (`reactive-swr/server`) (#WI-244) +- `channel.watch(adapter)` method to connect adapters to SSE channels with idempotent cleanup (#WI-246) +- `createPrismaAdapter(prisma, mapping)` adapter for automatic SSE emission from Prisma `$use()` middleware (#WI-247) +- `createMongoAdapter(collection, mapping)` adapter for MongoDB Change Streams with resume token support (#WI-248) +- `createPgAdapter(client, mapping)` adapter for PostgreSQL LISTEN/NOTIFY with SQL identifier quoting (#WI-249) +- `createEmitterAdapter(emitter, mapping)` adapter for bridging any `on`/`off`-compatible event emitter (#WI-250) +- Schema `resources` field in `defineSchema()` that auto-expands CRUD event triplets (`.created`, `.updated`, `.deleted`) (#WI-245) +- `ResourceDefinition` and `ResourceOperationDefinition` types for schema resource definitions (#WI-245) +- Subpath exports for individual adapters: `reactive-swr/server/adapters/prisma`, `reactive-swr/server/adapters/mongodb`, `reactive-swr/server/adapters/pg`, `reactive-swr/server/adapters/emitter` (#WI-251) +- Barrel export file for all adapters at `src/server/adapters/index.ts` (#WI-251) + +### Fixed +- `SchemaResult` type now includes resource-expanded event keys (`.created`/`.updated`/`.deleted`) for proper TypeScript inference +- `defineSchema()` logs a `console.warn` when an explicit event overrides a resource-generated event +- MongoDB adapter `start()` is now properly async (awaits stream setup instead of fire-and-forget) +- MongoDB reconnect counter only resets on fresh `start()`, not on every event emission +- PostgreSQL adapter sets `started = true` after LISTEN queries succeed, not before +- PostgreSQL adapter always quotes identifiers to handle reserved keywords like `select` +- PostgreSQL adapter warns when client lacks `off()`/`removeListener()` for cleanup +- Prisma adapter propagates `$use()` registration errors and resets state on failure +- EventEmitter adapter defers `started = true` until all listeners are registered; `off()` calls wrapped in try/catch +- EventEmitter adapter resets `started = false` in `stop()` to allow restart +- `channel.watch()` clears stopped state on re-watch so the same adapter can be reused +- Channel error messages now include operation context (e.g., "Cannot connect: channel is closed") + +### Changed +- `defineSchema()` now accepts an optional `resources` key alongside explicit event definitions; explicit definitions take precedence over generated resource events (#WI-245) +- `channel.close()` now stops all watched adapters in addition to closing client connections (#WI-246) +- Build script updated to compile individual adapter entry points for tree-shakeable imports (#WI-251) +- `tsconfig.emit.json` updated to include adapter source files for declaration generation (#WI-251) + ## [0.1.0] - 2026-02-22 ### Added diff --git a/README.md b/README.md index 1653bc4..303c095 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,32 @@ Each event definition supports: | `filter` | `(payload) => boolean` | Optional client-side filter | | `transform` | `(payload) => payload` | Optional client-side transform | +#### Schema Resources + +For CRUD-heavy applications, define resources instead of individual events. Each resource automatically expands into `.created`, `.updated`, and `.deleted` event definitions: + +```typescript +const schema = defineSchema({ + resources: { + orders: { + created: { key: '/api/orders', update: 'refetch' }, + updated: { key: (p: { id: string }) => `/api/orders/${p.id}`, update: 'set' }, + deleted: { key: '/api/orders', update: 'refetch' }, + }, + users: { + updated: { key: (p: { id: string }) => `/api/users/${p.id}` }, + }, + }, + // Explicit events can coexist with resources + 'notification.sent': { + key: '/api/notifications', + update: 'refetch', + }, +}) +``` + +This generates `orders.created`, `orders.updated`, `orders.deleted`, `users.created`, `users.updated`, `users.deleted`, and `notification.sent` as event types. Omitted operations (e.g., `users.created`) still get a default definition using the resource name as the key and `'set'` as the update strategy. Explicit event definitions always take precedence over generated resource events. + #### `createChannel()` (Server) `createChannel()` provides a complete server-side SSE endpoint. It handles wire formatting, heartbeats, connection tracking, and cleanup. Import it from `reactive-swr/server`. @@ -203,6 +229,120 @@ export async function POST(request: Request) { channel.close() // Closes all connections, stops heartbeats ``` +#### Database Adapters + +Adapters bridge database change notifications to `channel.emit()`, completing the reactive pipeline: **data change -> adapter -> channel.emit() -> SSE -> SWR cache update**. Each adapter accepts a pre-configured client instance -- reactiveSWR never imports a database driver directly. + +Connect an adapter with `channel.watch()`: + +```typescript +// Sync adapters (Prisma, EventEmitter) return a cleanup function directly: +const cleanup = channel.watch(adapter) + +// Async adapters (MongoDB, PostgreSQL) return a Promise: +const cleanup = await channel.watch(adapter) + +// Later: cleanup() to stop the adapter +``` + +`channel.watch()` returns a cleanup function (or `Promise` for async adapters) that calls `adapter.stop()`. When `channel.close()` is called, all watched adapters are stopped automatically. + +**Prisma adapter** -- intercepts create/update/delete via `$use()` middleware: + +```typescript +import { createPrismaAdapter } from 'reactive-swr/server/adapters/prisma' + +const adapter = createPrismaAdapter(prisma, { + Order: { + created: 'orders.created', + updated: 'orders.updated', + deleted: 'orders.deleted', + }, + User: { + updated: 'users.updated', + }, +}) + +channel.watch(adapter) // sync — Prisma uses $use() middleware, no async setup +``` + +Note: the Prisma adapter uses `$use()` middleware, which works with Prisma v4-v6. Prisma v7 removed `$use()` in favor of `$extends()` — a migration to Client Extensions is tracked in [#4](https://github.com/queso/reactiveSWR/issues/4). Register the adapter last to ensure it runs after other middleware. + +**MongoDB adapter** -- listens to Change Streams with resume token support: + +```typescript +import { createMongoAdapter } from 'reactive-swr/server/adapters/mongodb' + +const adapter = createMongoAdapter(db.collection('orders'), { + insert: 'orders.created', + update: 'orders.updated', + replace: 'orders.updated', + delete: 'orders.deleted', +}) + +await channel.watch(adapter) // async — opens Change Stream +``` + +The adapter handles `invalidate` events by reopening the stream and persists resume tokens so reconnections pick up where they left off. + +**PostgreSQL adapter** -- maps LISTEN/NOTIFY channels to SSE events: + +```typescript +import { createPgAdapter } from 'reactive-swr/server/adapters/pg' + +const adapter = createPgAdapter(pgClient, { + order_changes: 'orders.updated', + user_changes: 'users.updated', +}) + +await channel.watch(adapter) // async — issues LISTEN queries +``` + +NOTIFY payloads are parsed as JSON automatically. Note the PostgreSQL NOTIFY payload limit of 8000 bytes. + +**EventEmitter adapter** -- bridges any `on`/`off`-compatible event source: + +```typescript +import { createEmitterAdapter } from 'reactive-swr/server/adapters/emitter' + +const adapter = createEmitterAdapter(myEventBus, { + 'order:changed': 'orders.updated', + 'user:changed': 'users.updated', +}) + +channel.watch(adapter) +``` + +Works with Node.js `EventEmitter`, Redis pub/sub clients, or any object with `on(event, listener)` and `off(event, listener)` methods. + +All adapters are tree-shakeable -- importing one does not pull in the others. They can also be imported from the barrel export at `reactive-swr/server`: + +```typescript +import { + createPrismaAdapter, + createMongoAdapter, + createPgAdapter, + createEmitterAdapter, +} from 'reactive-swr/server' +``` + +For third-party adapter authors, the `SSEAdapter` interface is exported from `reactive-swr/server`: + +```typescript +import type { SSEAdapter } from 'reactive-swr/server' + +function createMyAdapter(client: MyClient): SSEAdapter { + return { + start(emit) { + // Begin watching for changes, call emit(eventType, payload) when they occur + }, + stop() { + // Clean up resources + }, + } +} +``` + #### SSEProvider `schema` Prop Pass a schema to `SSEProvider` instead of manually writing `events` mappings: diff --git a/docs/API.md b/docs/API.md index 976632a..fcf3303 100644 --- a/docs/API.md +++ b/docs/API.md @@ -14,6 +14,12 @@ Complete API documentation for reactiveSWR. - [createSSEParser](#createssparser) - [Server Entry Point (`reactive-swr/server`)](#server-entry-point-reactive-swrserver) - [createChannel](#createchannel) + - [Adapters](#adapters) + - [SSEAdapter Interface](#sseadapter-interface) + - [createPrismaAdapter](#createprismaadapter) + - [createMongoAdapter](#createmongoadapter) + - [createPgAdapter](#createpgadapter) + - [createEmitterAdapter](#createemitteradapter) - [Testing Entry Point (`reactive-swr/testing`)](#testing-entry-point-reactive-swrtesting) - [mockSSE](#mocksse) - [Types](#types) @@ -27,6 +33,8 @@ Complete API documentation for reactiveSWR. - [SSERequestOptions](#sserequestoptions) - [SSEProviderProps](#sseproviderprops) - [SchemaDefinition and SchemaResult](#schemadefinition-and-schemaresult) + - [ResourceDefinition and ResourceOperationDefinition](#resourcedefinition-and-resourceoperationdefinition) + - [AdapterMapping](#adaptermapping) - [SSEEvent and SSEParser](#sseevent-and-sseparser) - [UseSSEStreamOptions and UseSSEStreamResult](#usessestreamoptions-and-usessestreamresult) @@ -253,6 +261,24 @@ const schema = defineSchema({ The returned schema object is frozen with `Object.freeze()`. Each entry defaults `update` to `'set'` when not specified. +**With resources:** + +```typescript +const schema = defineSchema({ + resources: { + orders: { + created: { key: '/api/orders', update: 'refetch' }, + updated: { key: (p: { id: string }) => `/api/orders/${p.id}`, update: 'set' }, + deleted: { key: '/api/orders', update: 'refetch' }, + }, + }, + // Explicit events alongside resources + 'notification.sent': { key: '/api/notifications' }, +}) +``` + +Each resource key (e.g., `orders`) expands into `orders.created`, `orders.updated`, and `orders.deleted` events. Per-operation definitions are optional -- omitted operations get a default event with the resource name as the key and `'set'` as the update strategy. Explicit event definitions always take precedence over generated resource events. + **Using with SSEProvider:** ```tsx @@ -368,7 +394,8 @@ function createChannel( | `respond(request: Request): { response, emitter }` | Web standard. Returns a `Response` and a scoped `ScopedEmitter` for request-scoped streaming. | | `respond(req, res): ScopedEmitter` | Node.js. Writes SSE headers and returns a scoped `ScopedEmitter`. | | `emit(type, payload): void` | Broadcast an event to all clients connected via `connect()`. | -| `close(): void` | Close all connections and stop the heartbeat timer. | +| `watch(adapter): cleanup` | Connect an `SSEAdapter` to the channel. Returns a cleanup function (or `Promise` if the adapter's `start()` is async). | +| `close(): void` | Close all connections, stop all watched adapters, and stop the heartbeat timer. | | `isClosed(): boolean` | Returns `true` if the channel has been closed. | **ScopedEmitter:** @@ -439,7 +466,264 @@ channel.close() - The heartbeat timer starts when the first client connects and stops when the last client disconnects - Dead clients (closed streams, ended responses) are automatically pruned during broadcast and heartbeat cycles - `connect()` sends an initial `: connected\n\n` comment when a client connects -- Throws `Error('Channel is closed')` if `connect()`, `respond()`, or `emit()` are called after `close()` +- Throws `Error('Cannot connect: channel is closed')` or `Error('Cannot respond: channel is closed')` if called after `close()` + +--- + +### Adapters + +Database and event source adapters that bridge change notifications to `channel.emit()`. Each adapter implements the `SSEAdapter` interface and can be connected to a channel via `channel.watch()`. + +All adapters are tree-shakeable and can be imported individually or from the barrel export: + +```typescript +// Individual imports (recommended for tree-shaking) +import { createPrismaAdapter } from 'reactive-swr/server/adapters/prisma' +import { createMongoAdapter } from 'reactive-swr/server/adapters/mongodb' +import { createPgAdapter } from 'reactive-swr/server/adapters/pg' +import { createEmitterAdapter } from 'reactive-swr/server/adapters/emitter' + +// Barrel import +import { + createPrismaAdapter, + createMongoAdapter, + createPgAdapter, + createEmitterAdapter, + type SSEAdapter, + type AdapterMapping, +} from 'reactive-swr/server' +``` + +--- + +#### SSEAdapter Interface + +The standard contract all adapters implement. Exported from `reactive-swr/server` for third-party adapter authors. + +```typescript +interface SSEAdapter { + start(emit: (eventType: string, payload: unknown) => void): void | Promise + stop(): void | Promise +} +``` + +| Method | Description | +|--------|-------------| +| `start(emit)` | Begin watching for changes. Call `emit(eventType, payload)` when a change occurs. May be sync or async. | +| `stop()` | Stop watching and clean up resources. May be sync or async. | + +**Notes:** + +- Adapters are stateless with respect to the channel -- the channel provides the `emit` callback +- Each adapter is responsible for its own reconnection logic (e.g., MongoDB resume tokens) +- `emit()` errors thrown inside the adapter are caught and do not propagate + +--- + +#### createPrismaAdapter + +Create an adapter that intercepts Prisma `create`, `update`, and `delete` operations via `$use()` middleware and emits SSE events after each operation completes. + +```typescript +function createPrismaAdapter( + prisma: PrismaClient, + mapping: PrismaAdapterMapping +): SSEAdapter +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `prisma` | `PrismaClient` | A Prisma client instance with `$use()` support | +| `mapping` | `PrismaAdapterMapping` | Maps Prisma model names to event types per operation | + +**PrismaAdapterMapping:** + +```typescript +type PrismaAdapterMapping = { + [modelName: string]: { + created?: string + updated?: string + deleted?: string + } +} +``` + +**Usage:** + +```typescript +import { createPrismaAdapter } from 'reactive-swr/server/adapters/prisma' + +const adapter = createPrismaAdapter(prisma, { + Order: { + created: 'orders.created', + updated: 'orders.updated', + deleted: 'orders.deleted', + }, +}) + +const cleanup = channel.watch(adapter) +``` + +**Supported Prisma actions:** `create`, `createMany`, `update`, `updateMany`, `delete`, `deleteMany`. + +**Notes:** + +- Does not import `@prisma/client` -- accepts the client instance as a parameter +- Events are emitted after the operation completes (post-middleware), not before +- The adapter's middleware should be registered last to run after other middleware +- `start()` is idempotent -- calling it multiple times has no effect +- `stop()` disables emission but does not remove the registered middleware (Prisma `$use()` does not support removal) + +--- + +#### createMongoAdapter + +Create an adapter that watches a MongoDB collection via Change Streams and emits SSE events for document changes. + +```typescript +function createMongoAdapter( + collection: MongoCollection, + mapping: MongoAdapterMapping +): SSEAdapter +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `collection` | `MongoCollection` | A MongoDB collection with `watch()` support | +| `mapping` | `MongoAdapterMapping` | Maps Change Stream operation types to event types | + +**MongoAdapterMapping:** + +```typescript +type MongoAdapterMapping = { + [operationType: string]: string // e.g., 'insert' -> 'orders.created' +} +``` + +**Usage:** + +```typescript +import { createMongoAdapter } from 'reactive-swr/server/adapters/mongodb' + +const adapter = createMongoAdapter(db.collection('orders'), { + insert: 'orders.created', + update: 'orders.updated', + replace: 'orders.updated', + delete: 'orders.deleted', +}) + +const cleanup = await channel.watch(adapter) +``` + +**Notes:** + +- Does not import `mongodb` -- accepts the collection instance as a parameter +- Persists resume tokens so reconnections pick up where they left off +- Handles `invalidate` events by reopening the stream (up to 5 reconnection attempts) +- Payload is `fullDocument` for insert/update/replace, `documentKey` for delete +- `start()` is async -- it opens the Change Stream and begins iteration + +--- + +#### createPgAdapter + +Create an adapter that listens on PostgreSQL LISTEN/NOTIFY channels and emits SSE events when NOTIFY messages arrive. + +```typescript +function createPgAdapter( + client: PgClient, + mapping: PgAdapterMapping +): SSEAdapter +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `client` | `PgClient` | A `pg` client instance with `query()` and `on()` methods | +| `mapping` | `PgAdapterMapping` | Maps PostgreSQL channel names to event types | + +**PgAdapterMapping:** + +```typescript +type PgAdapterMapping = { + [channelName: string]: string // e.g., 'order_changes' -> 'orders.updated' +} +``` + +**Usage:** + +```typescript +import { createPgAdapter } from 'reactive-swr/server/adapters/pg' + +const adapter = createPgAdapter(pgClient, { + order_changes: 'orders.updated', + user_changes: 'users.updated', +}) + +const cleanup = await channel.watch(adapter) +``` + +**Notes:** + +- Does not import `pg` -- accepts the client instance as a parameter +- `start()` is async -- it issues `LISTEN` queries for each mapped channel +- `stop()` is async -- it issues `UNLISTEN` queries and removes the notification listener +- NOTIFY payloads are parsed as JSON; malformed payloads emit `undefined` +- SQL identifiers are properly quoted to prevent injection +- Supports both `client.off()` and `client.removeListener()` for cleanup +- Be aware of the PostgreSQL NOTIFY payload limit of 8000 bytes + +--- + +#### createEmitterAdapter + +Create an adapter that bridges any object with `on(event, listener)` and `off(event, listener)` methods to SSE events. + +```typescript +function createEmitterAdapter( + emitter: OnOffEmitter, + mapping: EmitterAdapterMapping +): SSEAdapter +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `emitter` | `OnOffEmitter` | Any object with `on(event, listener)` and `off(event, listener)` methods | +| `mapping` | `EmitterAdapterMapping` | Maps emitter event names to schema event types | + +**EmitterAdapterMapping:** + +```typescript +type EmitterAdapterMapping = { + [emitterEvent: string]: string // e.g., 'order:changed' -> 'orders.updated' +} +``` + +**Usage:** + +```typescript +import { createEmitterAdapter } from 'reactive-swr/server/adapters/emitter' + +const adapter = createEmitterAdapter(myEventBus, { + 'order:changed': 'orders.updated', + 'user:changed': 'users.updated', +}) + +const cleanup = channel.watch(adapter) +``` + +**Notes:** + +- Does not require Node.js `EventEmitter` -- works with any `on`/`off`-compatible interface +- The first argument of the emitter event is passed as the payload to `emit()` +- `stop()` calls `off()` for each registered handler --- @@ -560,6 +844,8 @@ import type { EventMapping, ParsedEvent, ReconnectConfig, + ResourceDefinition, + ResourceOperationDefinition, SchemaDefinition, SchemaEventDefinition, SchemaResult, @@ -574,6 +860,12 @@ import type { } from 'reactive-swr' ``` +Server types: + +```typescript +import type { SSEAdapter, AdapterMapping } from 'reactive-swr/server' +``` + Testing types: ```typescript @@ -798,7 +1090,10 @@ Types for the `defineSchema()` function. ```typescript // Input shape accepted by defineSchema() -type SchemaDefinition = Record +// Accepts explicit event definitions and an optional `resources` key +type SchemaDefinition = { + resources?: Record +} & Record | undefined> // A single event definition entry within a schema interface SchemaEventDefinition { @@ -809,14 +1104,59 @@ interface SchemaEventDefinition { } // Frozen schema object returned by defineSchema() -type SchemaResult = Readonly<{ - [K in keyof T]: Required> & - Omit & { update: NonNullable | 'set' } -}> +// Includes both explicit event definitions and resource-expanded events +type SchemaResult> = Readonly< + { + [K in keyof T as T[K] extends SchemaEventDefinition ? K : never]: + T[K] extends SchemaEventDefinition + ? Required> & + Omit & { update: NonNullable | 'set' } + : never + } & (T extends { resources: infer R extends Record } + ? ResourceSchemaEntries // Adds .created/.updated/.deleted keys + : Record) +> ``` --- +### ResourceDefinition and ResourceOperationDefinition + +Types for the `resources` field in `defineSchema()`. + +```typescript +// Definition for a resource -- each operation is optional +interface ResourceDefinition { + created?: ResourceOperationDefinition + updated?: ResourceOperationDefinition + deleted?: ResourceOperationDefinition +} + +// Definition for a single resource operation +interface ResourceOperationDefinition { + key?: string | string[] | ((payload: TPayload) => string | string[]) + update?: UpdateStrategy + filter?: (payload: TPayload) => boolean + transform?: (payload: TPayload) => TPayload +} +``` + +--- + +### AdapterMapping + +Type-safe mapping from source event names to schema event type keys. Constrains mapped values to keys that exist in the schema (excluding internal keys like `resources`). + +```typescript +type AdapterMapping> = { + [sourceEvent: string]: EventKeysOf +} +``` + +Exported from `reactive-swr/server` for use by third-party adapter authors who want type-safe mappings against a schema. + +--- + ### SSEEvent and SSEParser Types for the `createSSEParser()` function. diff --git a/package.json b/package.json index 01f6035..38c5e3e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,22 @@ "./server": { "import": "./dist/server/index.js", "types": "./dist/server/index.d.ts" + }, + "./server/adapters/prisma": { + "import": "./dist/server/adapters/prisma.js", + "types": "./dist/server/adapters/prisma.d.ts" + }, + "./server/adapters/mongodb": { + "import": "./dist/server/adapters/mongodb.js", + "types": "./dist/server/adapters/mongodb.d.ts" + }, + "./server/adapters/pg": { + "import": "./dist/server/adapters/pg.js", + "types": "./dist/server/adapters/pg.d.ts" + }, + "./server/adapters/emitter": { + "import": "./dist/server/adapters/emitter.js", + "types": "./dist/server/adapters/emitter.d.ts" } }, "sideEffects": false, @@ -36,7 +52,7 @@ }, "scripts": { "dev": "bun run --watch src/index.ts", - "build": "bun build src/index.ts src/testing/index.ts src/server/index.ts --outdir dist --target browser --external react --external react-dom --external swr && tsc --project tsconfig.emit.json", + "build": "bun build src/index.ts src/testing/index.ts src/server/index.ts src/server/adapters/prisma.ts src/server/adapters/mongodb.ts src/server/adapters/pg.ts src/server/adapters/emitter.ts --outdir dist --target browser --external react --external react-dom --external swr && tsc --project tsconfig.emit.json", "prepare": "bun run build", "test": "bun test", "test:e2e": "bunx playwright test", diff --git a/prd/0003-schema-adapters.md b/prd/0003-schema-adapters.md index 1e22dee..24f3310 100644 --- a/prd/0003-schema-adapters.md +++ b/prd/0003-schema-adapters.md @@ -37,11 +37,17 @@ Database systems already provide change notification mechanisms — MongoDB Chan ## Scope +### Implementation Priority + +1. **`SSEAdapter` interface**, **`channel.watch(adapter)`**, and **Enhanced schema `resources`** — core infrastructure required by all adapters. +2. **Prisma adapter** — first adapter, matching current app usage. +3. **MongoDB adapter**, **PostgreSQL adapter**, **Generic EventEmitter adapter** — subsequent adapters, order TBD based on demand. + ### In Scope - **`SSEAdapter` interface** — standard contract for adapters: `start()`, `stop()`, and a callback hook for emitting events. - **`channel.watch(adapter)`** — method on the channel object (from PRD-0002) that connects an adapter and routes its events through `channel.emit()`. -- **Prisma adapter** — wraps Prisma middleware `$use()` to intercept create/update/delete operations and emit corresponding events. +- **Prisma adapter** *(priority 1)* — wraps Prisma middleware `$use()` to intercept create/update/delete operations and emit corresponding events. - **MongoDB adapter** — wraps a MongoDB `Collection.watch()` Change Stream and maps `insert`, `update`, `replace`, `delete` operations to schema events. - **PostgreSQL adapter** — wraps `pg` client `LISTEN`/`NOTIFY` and maps notification payloads to schema events. - **Generic EventEmitter adapter** — wraps any Node.js `EventEmitter` (or compatible interface) and maps named events to schema events. @@ -137,8 +143,8 @@ Database systems already provide change notification mechanisms — MongoDB Chan | TypeScript generics complexity with resources + adapters | Medium | Poor DX | Keep the type-level expansion simple; test with real-world schema sizes | | Scope creep into query subscriptions | Low | Conflates cache invalidation with live queries | Explicitly out of scope; document the difference | -### Open Questions +### Resolved Questions -1. **Adapter package structure:** Should adapters live in `reactive-swr/server/adapters/prisma` (subpath exports) or in separate packages (`@reactive-swr/adapter-prisma`)? Subpath exports are simpler to start; separate packages allow independent versioning. -2. **Resource CRUD customization:** Should `resources` support custom operation names beyond `created/updated/deleted`? E.g., `orders: { operations: ['placed', 'shipped', 'cancelled'] }`. Leaning toward keeping it simple with the standard three and letting explicit `events` handle custom cases. -3. **Adapter priority ordering:** When multiple adapters emit the same event type, should there be a priority system or does last-write-win? Recommendation: no priority — events are independent and broadcast in order received. +1. **Adapter package structure:** Subpath exports (`reactive-swr/server/adapters/prisma`, etc.). Simpler than separate packages; can split later if independent versioning becomes necessary. +2. **Resource CRUD customization:** Standard `created`/`updated`/`deleted` only. Custom operation names are handled through explicit `events` definitions. +3. **Adapter priority ordering:** No priority system. Events from multiple adapters fire in arrival order. If two adapters emit the same event type, both emissions go through — this is on the developer to avoid. diff --git a/src/__tests__/adapter-emitter.test.ts b/src/__tests__/adapter-emitter.test.ts new file mode 100644 index 0000000..b629518 --- /dev/null +++ b/src/__tests__/adapter-emitter.test.ts @@ -0,0 +1,532 @@ +import { describe, expect, it, mock } from 'bun:test' +import type { SSEAdapter } from '../server/adapters/types.ts' + +/** + * Tests for createEmitterAdapter() — wraps any object with on/off methods + * to emit SSE events from named events. + * + * These tests verify that: + * 1. createEmitterAdapter(emitter, mapping) is exported from src/server/adapters/emitter.ts + * 2. The adapter implements the SSEAdapter interface (start/stop) + * 3. start() calls emitter.on(eventName, handler) for each mapped event + * 4. Handler calls emit() with the mapped schema event type and the emitted data as payload + * 5. stop() calls emitter.off(eventName, handler) for each registered listener + * 6. Works with any on/off-compatible object (not just Node.js EventEmitter) + * 7. Works with both synchronous and asynchronous event handlers + * 8. Named export only (tree-shakeable) + * + * Tests FAIL initially because src/server/adapters/emitter.ts has not been created yet. + */ + +// --------------------------------------------------------------------------- +// Mock emitter helpers +// --------------------------------------------------------------------------- + +type EventListener = (...args: unknown[]) => void | Promise + +/** + * Minimal on/off compatible event emitter mock. + * Does not extend EventEmitter — intentionally minimal interface. + */ +function makeMockEmitter() { + const listeners: Map = new Map() + + const onMock = mock((event: string, listener: EventListener) => { + const list = listeners.get(event) ?? [] + list.push(listener) + listeners.set(event, list) + }) + + const offMock = mock((event: string, listener: EventListener) => { + const list = listeners.get(event) ?? [] + listeners.set( + event, + list.filter((l) => l !== listener), + ) + }) + + const emitter = { on: onMock, off: offMock } + + /** Fire an event, calling all registered listeners */ + function fire(event: string, ...args: unknown[]): void { + const list = listeners.get(event) ?? [] + for (const listener of list) { + listener(...args) + } + } + + return { emitter, onMock, offMock, fire, listeners } +} + +type EmitterAdapterMapping = { + [emitterEvent: string]: string // emitter event name → schema event type +} + +async function importAdapter() { + const mod = (await import('../server/adapters/emitter.ts')) as Record< + string, + // biome-ignore lint/suspicious/noExplicitAny: dynamic import typing + any + > + return mod.createEmitterAdapter as ( + emitter: { + on: (event: string, listener: EventListener) => void + off: (event: string, listener: EventListener) => void + }, + mapping: EmitterAdapterMapping, + ) => SSEAdapter +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createEmitterAdapter()', () => { + describe('export and interface', () => { + it('should be exported from src/server/adapters/emitter.ts', async () => { + const mod = await import('../server/adapters/emitter.ts') + expect( + (mod as Record).createEmitterAdapter, + ).toBeDefined() + expect(typeof (mod as Record).createEmitterAdapter).toBe( + 'function', + ) + }) + + it('should return an object implementing the SSEAdapter interface', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, { + 'user-saved': 'user.updated', + }) + + expect(typeof adapter.start).toBe('function') + expect(typeof adapter.stop).toBe('function') + }) + + it('should be a named export (not default)', async () => { + const mod = (await import('../server/adapters/emitter.ts')) as Record< + string, + unknown + > + expect(mod.createEmitterAdapter).toBeDefined() + expect(mod.default).toBeUndefined() + }) + }) + + describe('start() — registers on() listeners for each mapped event', () => { + it('start() should call emitter.on() for each mapped event name', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, onMock } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, { + 'user-saved': 'user.updated', + 'order-placed': 'order.created', + }) + adapter.start(() => {}) + + expect(onMock).toHaveBeenCalledTimes(2) + + const registeredEvents = ( + onMock as ReturnType + ).mock.calls.map((c) => c[0]) + expect(registeredEvents).toContain('user-saved') + expect(registeredEvents).toContain('order-placed') + }) + + it('start() should pass a function as the listener to emitter.on()', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, onMock } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, { + 'data-changed': 'data.updated', + }) + adapter.start(() => {}) + + const listener = (onMock as ReturnType).mock.calls[0]?.[1] + expect(typeof listener).toBe('function') + }) + + it('start() with empty mapping should not call emitter.on()', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, onMock } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, {}) + adapter.start(() => {}) + + expect(onMock).not.toHaveBeenCalled() + }) + }) + + describe('event emission — handler invokes emit with correct type and payload', () => { + it('should emit the mapped schema event type when an emitter event fires', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createEmitterAdapter(emitter, { + 'user-saved': 'user.updated', + }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + fire('user-saved', { id: 1, name: 'Alice' }) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.updated') + }) + + it('should pass the emitted data as the payload to emit()', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createEmitterAdapter(emitter, { + 'order-placed': 'order.created', + }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + const orderData = { orderId: '99', total: 49.99, items: ['widget'] } + fire('order-placed', orderData) + + expect(emitted[0]?.payload).toEqual(orderData) + }) + + it('should route multiple emitter events to their respective schema events', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createEmitterAdapter(emitter, { + 'user-created': 'user.created', + 'user-updated': 'user.updated', + 'user-deleted': 'user.deleted', + }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + fire('user-created', { id: 1 }) + fire('user-updated', { id: 1, name: 'Bob' }) + fire('user-deleted', { id: 1 }) + + expect(emitted).toHaveLength(3) + expect(emitted[0]?.type).toBe('user.created') + expect(emitted[1]?.type).toBe('user.updated') + expect(emitted[2]?.type).toBe('user.deleted') + }) + + it('should not emit for emitter events not in the mapping', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createEmitterAdapter(emitter, { + 'user-saved': 'user.updated', + }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + // Fire an unmapped event — no listener was registered for it + fire('unrelated-event', { data: 'ignored' }) + + expect(emitted).toHaveLength(0) + }) + + it('should emit once per fired event (no double-registration)', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createEmitterAdapter(emitter, { + 'item-added': 'item.created', + }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + fire('item-added', { id: 10 }) + fire('item-added', { id: 11 }) + + expect(emitted).toHaveLength(2) + expect((emitted[0]?.payload as Record).id).toBe(10) + expect((emitted[1]?.payload as Record).id).toBe(11) + }) + + it('should allow multiple emitter events to map to the same schema event type', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createEmitterAdapter(emitter, { + 'product-created': 'product.changed', + 'product-updated': 'product.changed', + }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + fire('product-created', { id: 1 }) + fire('product-updated', { id: 1, price: 9.99 }) + + expect(emitted).toHaveLength(2) + expect(emitted[0]?.type).toBe('product.changed') + expect(emitted[1]?.type).toBe('product.changed') + }) + + it('should pass the first argument from the event as the payload', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const emitted: Array<{ payload: unknown }> = [] + const adapter = createEmitterAdapter(emitter, { msg: 'message.received' }) + adapter.start((_type, payload) => emitted.push({ payload })) + + // String payload + fire('msg', 'hello world') + expect(emitted[0]?.payload).toBe('hello world') + + // Null payload + fire('msg', null) + expect(emitted[1]?.payload).toBeNull() + + // Number payload + fire('msg', 42) + expect(emitted[2]?.payload).toBe(42) + }) + }) + + describe('stop() — removes off() listeners for each mapped event', () => { + it('stop() should call emitter.off() for each mapped event name', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, offMock } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, { + 'user-saved': 'user.updated', + 'order-placed': 'order.created', + }) + adapter.start(() => {}) + adapter.stop() + + expect(offMock).toHaveBeenCalledTimes(2) + + const unregisteredEvents = ( + offMock as ReturnType + ).mock.calls.map((c) => c[0]) + expect(unregisteredEvents).toContain('user-saved') + expect(unregisteredEvents).toContain('order-placed') + }) + + it('stop() should pass the same listener reference to off() that was passed to on()', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, onMock, offMock } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, { + 'data-changed': 'data.updated', + }) + adapter.start(() => {}) + adapter.stop() + + const registeredListener = (onMock as ReturnType).mock + .calls[0]?.[1] + const unregisteredListener = (offMock as ReturnType).mock + .calls[0]?.[1] + + // Must be the exact same function reference + expect(unregisteredListener).toBe(registeredListener) + }) + + it('after stop(), events from the emitter should no longer trigger emit()', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createEmitterAdapter(emitter, { + 'user-saved': 'user.updated', + }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + fire('user-saved', { id: 1 }) + expect(emitted).toHaveLength(1) + + adapter.stop() + + fire('user-saved', { id: 2 }) + expect(emitted).toHaveLength(1) // no new events + }) + + it('stop() should return void or Promise', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, { x: 'x.event' }) + adapter.start(() => {}) + + const result = adapter.stop() + if (result !== undefined) { + expect(result).toBeInstanceOf(Promise) + await result + } + }) + + it('stop() should not throw if called without start()', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, { x: 'x.event' }) + + expect(() => adapter.stop()).not.toThrow() + }) + + it('stop() should not throw if called twice', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, { x: 'x.event' }) + adapter.start(() => {}) + adapter.stop() + + expect(() => adapter.stop()).not.toThrow() + }) + + it('stop() with empty mapping should not call emitter.off()', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, offMock } = makeMockEmitter() + + const adapter = createEmitterAdapter(emitter, {}) + adapter.start(() => {}) + adapter.stop() + + expect(offMock).not.toHaveBeenCalled() + }) + }) + + describe('works with any on/off compatible object (not just Node.js EventEmitter)', () => { + it('should work with a plain object that implements on/off', async () => { + const createEmitterAdapter = await importAdapter() + + // Completely custom emitter — no EventEmitter inheritance + const handlers: Map = new Map() + const customEmitter = { + on(event: string, listener: EventListener) { + const list = handlers.get(event) ?? [] + list.push(listener) + handlers.set(event, list) + }, + off(event: string, listener: EventListener) { + const list = handlers.get(event) ?? [] + handlers.set( + event, + list.filter((l) => l !== listener), + ) + }, + } + + const emitted: Array<{ type: string }> = [] + const adapter = createEmitterAdapter(customEmitter, { + change: 'data.changed', + }) + adapter.start((type) => emitted.push({ type })) + + // Fire by calling all registered handlers manually + const list = handlers.get('change') ?? [] + for (const handler of list) handler({ value: 42 }) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('data.changed') + + adapter.stop() + + // After stop(), handler should be removed + const listAfter = handlers.get('change') ?? [] + expect(listAfter).toHaveLength(0) + }) + + it('should work with a Redis-style pub/sub client mock', async () => { + const createEmitterAdapter = await importAdapter() + + // Redis-style: on/off with event + channel + listener + // Simplified mock that only supports flat event names + const subscriptions: Map = new Map() + const redisMock = { + on(event: string, listener: EventListener) { + const list = subscriptions.get(event) ?? [] + list.push(listener) + subscriptions.set(event, list) + }, + off(event: string, listener: EventListener) { + const list = subscriptions.get(event) ?? [] + subscriptions.set( + event, + list.filter((l) => l !== listener), + ) + }, + } + + const emitted: string[] = [] + const adapter = createEmitterAdapter(redisMock, { + 'cache:invalidated': 'cache.invalidated', + }) + adapter.start((type) => emitted.push(type)) + + // Simulate a Redis message + const handlers = subscriptions.get('cache:invalidated') ?? [] + for (const h of handlers) h({ key: 'user:1' }) + + expect(emitted).toHaveLength(1) + expect(emitted[0]).toBe('cache.invalidated') + + adapter.stop() + }) + }) + + describe('async event handlers', () => { + it('should not throw when an async listener fires', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + // Emit callback that returns a Promise + const emitFn = mock(async (_type: string, _payload: unknown) => { + await Promise.resolve() + }) + + const adapter = createEmitterAdapter(emitter, { + 'slow-event': 'task.completed', + }) + adapter.start( + emitFn as unknown as (type: string, payload: unknown) => void, + ) + + expect(() => fire('slow-event', { taskId: 'abc' })).not.toThrow() + + // Allow the async emit to settle + await new Promise((r) => setTimeout(r, 10)) + expect(emitFn).toHaveBeenCalledTimes(1) + }) + + it('should pass the payload correctly even for async emit handlers', async () => { + const createEmitterAdapter = await importAdapter() + const { emitter, fire } = makeMockEmitter() + + const captured: unknown[] = [] + const asyncEmit = async (_type: string, payload: unknown) => { + await Promise.resolve() + captured.push(payload) + } + + const adapter = createEmitterAdapter(emitter, { + 'job-done': 'job.completed', + }) + adapter.start( + asyncEmit as unknown as (type: string, payload: unknown) => void, + ) + + fire('job-done', { jobId: '123', result: 'success' }) + + await new Promise((r) => setTimeout(r, 10)) + + expect(captured).toHaveLength(1) + expect(captured[0]).toEqual({ jobId: '123', result: 'success' }) + }) + }) + + describe('does NOT require Node.js EventEmitter', () => { + it('module should load without requiring a Node.js-specific EventEmitter import', async () => { + const mod = await import('../server/adapters/emitter.ts') + expect(mod).toBeDefined() + }) + }) +}) diff --git a/src/__tests__/adapter-exports.test.ts b/src/__tests__/adapter-exports.test.ts new file mode 100644 index 0000000..9ad1f52 --- /dev/null +++ b/src/__tests__/adapter-exports.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { SSEAdapter } from '../server/adapters/index.ts' + +// Compile-time assertion: if the barrel stops re-exporting SSEAdapter, tsc fails here +type _AssertSSEAdapterType = SSEAdapter + +/** + * Adapter barrel export and package.json subpath configuration tests. + * + * Verifies that: + * 1. src/server/adapters/index.ts barrel exports all adapter factories + types + * 2. src/server/index.ts re-exports adapter factories from the barrel + * 3. Each adapter is independently importable from its own module path + * 4. The root client entry point does NOT leak adapter code + * 5. package.json has subpath exports for individual adapters + * + * Tests will FAIL until the barrel file and package.json subpaths are wired up. + */ + +const ROOT = join(import.meta.dir, '..', '..') + +const ADAPTER_FACTORY_NAMES = [ + 'createPrismaAdapter', + 'createMongoAdapter', + 'createPgAdapter', + 'createEmitterAdapter', +] as const + +describe('Adapter barrel exports (src/server/adapters/index.ts)', () => { + it('should export all four createXxxAdapter factory functions', async () => { + const barrel = await import('../server/adapters/index.ts') + const exportKeys = Object.keys(barrel) + + for (const name of ADAPTER_FACTORY_NAMES) { + expect(exportKeys).toContain(name) + expect(typeof barrel[name as keyof typeof barrel]).toBe('function') + } + }) + + it('should re-export SSEAdapter type (verified via type-level import)', async () => { + // SSEAdapter is a type-only export -- it won't appear in Object.keys at + // runtime, but importing it must not throw. If the barrel is missing the + // re-export, TypeScript would error and Bun would fail to resolve. + const barrel = await import('../server/adapters/index.ts') + // The barrel module should resolve without error; runtime object exists + expect(barrel).toBeDefined() + }) +}) + +describe('Server entry re-exports (src/server/index.ts)', () => { + it('should re-export all adapter factory functions from the barrel', async () => { + const serverExports = await import('../server/index.ts') + const exportKeys = Object.keys(serverExports) + + for (const name of ADAPTER_FACTORY_NAMES) { + expect(exportKeys).toContain(name) + expect(typeof serverExports[name as keyof typeof serverExports]).toBe( + 'function', + ) + } + }) + + it('should still export createChannel from the server entry', async () => { + const serverExports = await import('../server/index.ts') + + expect(serverExports.createChannel).toBeDefined() + expect(typeof serverExports.createChannel).toBe('function') + }) +}) + +describe('Individual adapter module imports', () => { + it('should import createPrismaAdapter from its own module', async () => { + const mod = await import('../server/adapters/prisma.ts') + expect(typeof mod.createPrismaAdapter).toBe('function') + }) + + it('should import createMongoAdapter from its own module', async () => { + const mod = await import('../server/adapters/mongodb.ts') + expect(typeof mod.createMongoAdapter).toBe('function') + }) + + it('should import createPgAdapter from its own module', async () => { + const mod = await import('../server/adapters/pg.ts') + expect(typeof mod.createPgAdapter).toBe('function') + }) + + it('should import createEmitterAdapter from its own module', async () => { + const mod = await import('../server/adapters/emitter.ts') + expect(typeof mod.createEmitterAdapter).toBe('function') + }) +}) + +describe('Client entry point exclusion', () => { + it('should NOT export adapter factory functions from the root entry point', async () => { + const clientExports = await import('../index.ts') + const exportKeys = Object.keys(clientExports) + + for (const name of ADAPTER_FACTORY_NAMES) { + expect(exportKeys).not.toContain(name) + } + }) +}) + +describe('package.json subpath exports', () => { + function readPackageExports(): Record { + const raw = readFileSync(join(ROOT, 'package.json'), 'utf-8') + const pkg = JSON.parse(raw) as { exports?: Record } + return pkg.exports ?? {} + } + + it('should have subpath exports for each adapter', () => { + const exports = readPackageExports() + const expectedSubpaths = [ + './server/adapters/prisma', + './server/adapters/mongodb', + './server/adapters/pg', + './server/adapters/emitter', + ] + + for (const subpath of expectedSubpaths) { + expect(exports[subpath]).toBeDefined() + } + }) + + it('each adapter subpath should have import and types entries', () => { + const exports = readPackageExports() + const adapterNames = ['prisma', 'mongodb', 'pg', 'emitter'] + + for (const name of adapterNames) { + const subpath = exports[`./server/adapters/${name}`] as + | Record + | undefined + + expect(subpath).toBeDefined() + expect(subpath?.import).toMatch( + new RegExp(`dist/server/adapters/${name}`), + ) + expect(subpath?.types).toMatch(new RegExp(`dist/server/adapters/${name}`)) + } + }) + + it('should preserve existing ./server subpath export', () => { + const exports = readPackageExports() + const serverExport = exports['./server'] as + | Record + | undefined + + expect(serverExport).toBeDefined() + expect(serverExport?.import).toBe('./dist/server/index.js') + expect(serverExport?.types).toBe('./dist/server/index.d.ts') + }) +}) diff --git a/src/__tests__/adapter-mongodb.test.ts b/src/__tests__/adapter-mongodb.test.ts new file mode 100644 index 0000000..bead06b --- /dev/null +++ b/src/__tests__/adapter-mongodb.test.ts @@ -0,0 +1,597 @@ +import { describe, expect, it, mock } from 'bun:test' +import type { SSEAdapter } from '../server/adapters/types.ts' + +/** + * Tests for createMongoAdapter() — wraps MongoDB Change Streams to + * emit SSE events on data mutations. + * + * These tests verify that: + * 1. createMongoAdapter(collection, mapping) is exported from src/server/adapters/mongodb.ts + * 2. The adapter implements the SSEAdapter interface (start/stop) + * 3. start() calls collection.watch() to open a Change Stream + * 4. Change Stream events (insert/update/replace/delete) are mapped to schema events + * 5. stop() closes the Change Stream cursor + * 6. Resume tokens are persisted in memory from each change event's _id field + * 7. Invalidate events cause the stream to reopen + * 8. The adapter does NOT import mongodb directly + * 9. Named export only (tree-shakeable) + * + * Tests FAIL initially because src/server/adapters/mongodb.ts has not been created yet. + */ + +// --------------------------------------------------------------------------- +// Mock MongoDB Change Stream cursor helpers +// --------------------------------------------------------------------------- + +interface ChangeEvent { + _id: { _data: string } // resume token + operationType: string // insert | update | replace | delete | invalidate + fullDocument?: Record + documentKey?: { _id: unknown } + updateDescription?: { updatedFields: Record } + ns?: { db: string; coll: string } +} + +/** + * Build a mock Change Stream cursor that replays a preset list of events + * when iterated as an async iterable. + */ +function makeChangeStream(events: ChangeEvent[]) { + let index = 0 + let closed = false + const closeMock = mock(async () => { + closed = true + }) + + const cursor = { + [Symbol.asyncIterator]() { + return { + async next(): Promise<{ + value: ChangeEvent | undefined + done: boolean + }> { + if (closed || index >= events.length) { + return { value: undefined, done: true } + } + return { value: events[index++]!, done: false } + }, + } + }, + close: closeMock, + closed: false, + } + + return { cursor, closeMock } +} + +/** + * Build a mock MongoDB Collection that captures watch() calls. + */ +function makeMockCollection( + changeStream: ReturnType['cursor'], +) { + const watchMock = mock((_options?: unknown) => changeStream) + + const collection = { watch: watchMock } + return { collection, watchMock } +} + +type MongoAdapterMapping = { + [operationType: string]: string // e.g. { insert: 'user.created', update: 'user.updated' } +} + +async function importAdapter() { + const mod = (await import('../server/adapters/mongodb.ts')) as Record< + string, + // biome-ignore lint/suspicious/noExplicitAny: dynamic import typing + any + > + return mod.createMongoAdapter as ( + // biome-ignore lint/suspicious/noExplicitAny: mock collection has minimal interface + collection: { watch: (options?: any) => any }, + mapping: MongoAdapterMapping, + ) => SSEAdapter +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createMongoAdapter()', () => { + describe('export and interface', () => { + it('should be exported from src/server/adapters/mongodb.ts', async () => { + const mod = await import('../server/adapters/mongodb.ts') + expect((mod as Record).createMongoAdapter).toBeDefined() + expect(typeof (mod as Record).createMongoAdapter).toBe( + 'function', + ) + }) + + it('should return an object implementing the SSEAdapter interface', async () => { + const createMongoAdapter = await importAdapter() + const { cursor } = makeChangeStream([]) + const { collection } = makeMockCollection(cursor) + + const adapter = createMongoAdapter(collection, { insert: 'user.created' }) + + expect(typeof adapter.start).toBe('function') + expect(typeof adapter.stop).toBe('function') + }) + + it('should be a named export (not default)', async () => { + const mod = (await import('../server/adapters/mongodb.ts')) as Record< + string, + unknown + > + expect(mod.createMongoAdapter).toBeDefined() + expect(mod.default).toBeUndefined() + }) + }) + + describe('start() — opens a Change Stream', () => { + it('start() should call collection.watch()', async () => { + const createMongoAdapter = await importAdapter() + const { cursor } = makeChangeStream([]) + const { collection, watchMock } = makeMockCollection(cursor) + + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + adapter.start(() => {}) + + // Allow micro-tasks to run so async iteration starts + await new Promise((r) => setTimeout(r, 10)) + + expect(watchMock).toHaveBeenCalledTimes(1) + }) + + it('start() should return a Promise (async iteration)', async () => { + const createMongoAdapter = await importAdapter() + const { cursor } = makeChangeStream([]) + const { collection } = makeMockCollection(cursor) + + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + const result = adapter.start(() => {}) + + // start() kicks off async iteration — it may return void or Promise + if (result !== undefined) { + expect(result).toBeInstanceOf(Promise) + } + }) + }) + + describe('event mapping — operationType to schema event', () => { + it('should emit a mapped event for an insert change event', async () => { + const createMongoAdapter = await importAdapter() + + const changeEvent: ChangeEvent = { + _id: { _data: 'token-1' }, + operationType: 'insert', + fullDocument: { _id: 'abc', name: 'Alice' }, + ns: { db: 'mydb', coll: 'users' }, + } + + const { cursor } = makeChangeStream([changeEvent]) + const { collection } = makeMockCollection(cursor) + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createMongoAdapter(collection, { insert: 'user.created' }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + // Wait for async iteration to process the event + await new Promise((r) => setTimeout(r, 20)) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.created') + }) + + it('should emit a mapped event for an update change event', async () => { + const createMongoAdapter = await importAdapter() + + const changeEvent: ChangeEvent = { + _id: { _data: 'token-2' }, + operationType: 'update', + fullDocument: { _id: 'abc', name: 'Bob' }, + documentKey: { _id: 'abc' }, + ns: { db: 'mydb', coll: 'users' }, + } + + const { cursor } = makeChangeStream([changeEvent]) + const { collection } = makeMockCollection(cursor) + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createMongoAdapter(collection, { update: 'user.updated' }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + await new Promise((r) => setTimeout(r, 20)) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.updated') + }) + + it('should emit a mapped event for a replace change event', async () => { + const createMongoAdapter = await importAdapter() + + const changeEvent: ChangeEvent = { + _id: { _data: 'token-3' }, + operationType: 'replace', + fullDocument: { _id: 'abc', name: 'Charlie', role: 'admin' }, + ns: { db: 'mydb', coll: 'users' }, + } + + const { cursor } = makeChangeStream([changeEvent]) + const { collection } = makeMockCollection(cursor) + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createMongoAdapter(collection, { + replace: 'user.updated', + }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + await new Promise((r) => setTimeout(r, 20)) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.updated') + }) + + it('should emit a mapped event for a delete change event', async () => { + const createMongoAdapter = await importAdapter() + + const changeEvent: ChangeEvent = { + _id: { _data: 'token-4' }, + operationType: 'delete', + documentKey: { _id: 'abc' }, + ns: { db: 'mydb', coll: 'users' }, + } + + const { cursor } = makeChangeStream([changeEvent]) + const { collection } = makeMockCollection(cursor) + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createMongoAdapter(collection, { delete: 'user.deleted' }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + await new Promise((r) => setTimeout(r, 20)) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.deleted') + }) + + it('event payload should be the full change document (or documentKey for deletes)', async () => { + const createMongoAdapter = await importAdapter() + + const fullDoc = { _id: 'abc', name: 'Alice', email: 'alice@example.com' } + const changeEvent: ChangeEvent = { + _id: { _data: 'token-5' }, + operationType: 'insert', + fullDocument: fullDoc, + ns: { db: 'mydb', coll: 'users' }, + } + + const { cursor } = makeChangeStream([changeEvent]) + const { collection } = makeMockCollection(cursor) + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createMongoAdapter(collection, { insert: 'user.created' }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + await new Promise((r) => setTimeout(r, 20)) + + // Payload should contain the document data + expect(emitted[0]?.payload).toBeDefined() + const payload = emitted[0]?.payload as Record + // At minimum the payload contains the fullDocument or key info + expect(payload).toMatchObject(fullDoc) + }) + + it('should not emit for unmapped operation types', async () => { + const createMongoAdapter = await importAdapter() + + const changeEvent: ChangeEvent = { + _id: { _data: 'token-6' }, + operationType: 'drop', // not in mapping + ns: { db: 'mydb', coll: 'users' }, + } + + const { cursor } = makeChangeStream([changeEvent]) + const { collection } = makeMockCollection(cursor) + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createMongoAdapter(collection, { insert: 'user.created' }) + adapter.start((type, payload) => emitted.push({ type, payload })) + + await new Promise((r) => setTimeout(r, 20)) + + expect(emitted).toHaveLength(0) + }) + + it('should map multiple operation types to different schema events', async () => { + const createMongoAdapter = await importAdapter() + + const events: ChangeEvent[] = [ + { + _id: { _data: 'tok-1' }, + operationType: 'insert', + fullDocument: { _id: '1' }, + ns: { db: 'db', coll: 'orders' }, + }, + { + _id: { _data: 'tok-2' }, + operationType: 'update', + fullDocument: { _id: '1', status: 'shipped' }, + ns: { db: 'db', coll: 'orders' }, + }, + { + _id: { _data: 'tok-3' }, + operationType: 'delete', + documentKey: { _id: '1' }, + ns: { db: 'db', coll: 'orders' }, + }, + ] + + const { cursor } = makeChangeStream(events) + const { collection } = makeMockCollection(cursor) + + const emitted: Array<{ type: string }> = [] + const adapter = createMongoAdapter(collection, { + insert: 'order.created', + update: 'order.updated', + delete: 'order.deleted', + }) + adapter.start((type) => emitted.push({ type })) + + await new Promise((r) => setTimeout(r, 30)) + + expect(emitted).toHaveLength(3) + expect(emitted[0]?.type).toBe('order.created') + expect(emitted[1]?.type).toBe('order.updated') + expect(emitted[2]?.type).toBe('order.deleted') + }) + }) + + describe('resume tokens — in-memory persistence', () => { + it('should pass resumeAfter token to collection.watch() on reconnect after invalidate', async () => { + const createMongoAdapter = await importAdapter() + + const resumeToken = { _data: 'resume-token-xyz' } + + // First stream: one event then invalidate + const firstEvents: ChangeEvent[] = [ + { + _id: resumeToken, + operationType: 'insert', + fullDocument: { _id: '1' }, + ns: { db: 'db', coll: 'c' }, + }, + { _id: { _data: 'inv-tok' }, operationType: 'invalidate' }, + ] + // Second stream: empty (adapter just reconnected) + const secondEvents: ChangeEvent[] = [] + + const { cursor: cursor1 } = makeChangeStream(firstEvents) + const { cursor: cursor2 } = makeChangeStream(secondEvents) + + let watchCallCount = 0 + let secondWatchOptions: unknown + const collection = { + watch: mock((options?: unknown) => { + watchCallCount++ + if (watchCallCount === 2) secondWatchOptions = options + return watchCallCount === 1 ? cursor1 : cursor2 + }), + } + + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + adapter.start(() => {}) + + // Wait for both streams to be processed + await new Promise((r) => setTimeout(r, 50)) + + // Should have opened the stream twice (initial + after invalidate) + expect(watchCallCount).toBeGreaterThanOrEqual(2) + // Second open should include resumeAfter with the last seen token + expect(secondWatchOptions).toBeDefined() + const opts = secondWatchOptions as Record + expect(opts.resumeAfter).toBeDefined() + }) + + it('should update the resume token after each processed event', async () => { + const createMongoAdapter = await importAdapter() + + const events: ChangeEvent[] = [ + { + _id: { _data: 'tok-a' }, + operationType: 'insert', + fullDocument: { _id: '1' }, + ns: { db: 'db', coll: 'c' }, + }, + { + _id: { _data: 'tok-b' }, + operationType: 'insert', + fullDocument: { _id: '2' }, + ns: { db: 'db', coll: 'c' }, + }, + { _id: { _data: 'tok-c' }, operationType: 'invalidate' }, + ] + const secondEvents: ChangeEvent[] = [] + + let secondWatchOptions: unknown + let callCount = 0 + const { cursor: cursor1 } = makeChangeStream(events) + const { cursor: cursor2 } = makeChangeStream(secondEvents) + + const collection = { + watch: mock((options?: unknown) => { + callCount++ + if (callCount === 2) secondWatchOptions = options + return callCount === 1 ? cursor1 : cursor2 + }), + } + + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + adapter.start(() => {}) + + await new Promise((r) => setTimeout(r, 50)) + + // After processing tok-a and tok-b, the last token before invalidate is tok-b + // The resumeAfter on the second open should use tok-b + if (secondWatchOptions) { + const opts = secondWatchOptions as Record + expect(opts.resumeAfter).toEqual({ _data: 'tok-b' }) + } + }) + }) + + describe('invalidate events — reopen stream', () => { + it('should reopen the stream when an invalidate event is received', async () => { + const createMongoAdapter = await importAdapter() + + const firstEvents: ChangeEvent[] = [ + { _id: { _data: 'tok-1' }, operationType: 'invalidate' }, + ] + const secondEvents: ChangeEvent[] = [] + + const { cursor: c1 } = makeChangeStream(firstEvents) + const { cursor: c2 } = makeChangeStream(secondEvents) + + let callCount = 0 + const collection = { + watch: mock(() => { + callCount++ + return callCount === 1 ? c1 : c2 + }), + } + + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + adapter.start(() => {}) + + await new Promise((r) => setTimeout(r, 50)) + + expect(callCount).toBeGreaterThanOrEqual(2) + }) + + it('should NOT emit an event for the invalidate operation itself', async () => { + const createMongoAdapter = await importAdapter() + + const events: ChangeEvent[] = [ + { _id: { _data: 'tok-1' }, operationType: 'invalidate' }, + ] + const { cursor } = makeChangeStream(events) + const collection = { watch: mock(() => cursor) } + + const emitted: string[] = [] + const adapter = createMongoAdapter(collection, { + insert: 'item.created', + invalidate: 'item.created', // even if someone maps it, should not emit + }) + adapter.start((type) => emitted.push(type)) + + await new Promise((r) => setTimeout(r, 20)) + + // invalidate is a control event, not a data event — implementation may choose + // to not emit even if mapped. The key behavior is stream reopen. + // (If the impl does emit for it, that's also acceptable — we just verify reopen) + expect(true).toBe(true) // assertion is about reopen, tested in previous test + }) + }) + + describe('stop() — closes the Change Stream', () => { + it('stop() should close the Change Stream cursor', async () => { + const createMongoAdapter = await importAdapter() + + const { cursor, closeMock } = makeChangeStream([]) + const { collection } = makeMockCollection(cursor) + + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + adapter.start(() => {}) + + // Allow stream to open + await new Promise((r) => setTimeout(r, 10)) + + await adapter.stop() + + expect(closeMock).toHaveBeenCalledTimes(1) + }) + + it('stop() should return void or Promise', async () => { + const createMongoAdapter = await importAdapter() + + const { cursor } = makeChangeStream([]) + const { collection } = makeMockCollection(cursor) + + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + adapter.start(() => {}) + + await new Promise((r) => setTimeout(r, 10)) + + const result = adapter.stop() + if (result !== undefined) { + expect(result).toBeInstanceOf(Promise) + await result + } + }) + + it('after stop(), no further events should be emitted', async () => { + const createMongoAdapter = await importAdapter() + + // Stream with a delayed event + let resolveEvent!: (e: { value: ChangeEvent; done: false }) => void + const delayedCursor = { + [Symbol.asyncIterator]() { + return { + async next(): Promise<{ + value: ChangeEvent | undefined + done: boolean + }> { + return new Promise((resolve) => { + resolveEvent = resolve as typeof resolveEvent + }) + }, + } + }, + close: mock(async () => {}), + } + + const collection = { watch: mock(() => delayedCursor) } + + const emitted: string[] = [] + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + adapter.start((type) => emitted.push(type)) + + await new Promise((r) => setTimeout(r, 10)) + + // Stop the adapter before resolving the delayed event + await adapter.stop() + + // Now deliver the event — it should not be processed + resolveEvent?.({ + value: { + _id: { _data: 'tok' }, + operationType: 'insert', + fullDocument: { _id: '1' }, + ns: { db: 'db', coll: 'c' }, + }, + done: false, + }) + + await new Promise((r) => setTimeout(r, 20)) + + expect(emitted).toHaveLength(0) + }) + + it('stop() should not throw even if called before start()', async () => { + const createMongoAdapter = await importAdapter() + const { cursor } = makeChangeStream([]) + const { collection } = makeMockCollection(cursor) + + const adapter = createMongoAdapter(collection, { insert: 'item.created' }) + + await expect(adapter.stop()).resolves.toBeUndefined() + }) + }) + + describe('does NOT import mongodb', () => { + it('module should load without mongodb package installed', async () => { + const mod = await import('../server/adapters/mongodb.ts') + expect(mod).toBeDefined() + }) + }) +}) diff --git a/src/__tests__/adapter-pg.test.ts b/src/__tests__/adapter-pg.test.ts new file mode 100644 index 0000000..6ee5e72 --- /dev/null +++ b/src/__tests__/adapter-pg.test.ts @@ -0,0 +1,542 @@ +import { describe, expect, it, mock, spyOn } from 'bun:test' +import type { SSEAdapter } from '../server/adapters/types.ts' + +/** + * Tests for createPgAdapter() — wraps pg client LISTEN/NOTIFY to + * emit SSE events on database notifications. + * + * These tests verify that: + * 1. createPgAdapter(client, mapping) is exported from src/server/adapters/pg.ts + * 2. The adapter implements the SSEAdapter interface (start/stop) + * 3. start() calls client.query('LISTEN ') for each mapped channel + * 4. Adapter listens for 'notification' events on the pg client + * 5. NOTIFY payload is parsed as JSON and passed to emit() + * 6. stop() calls UNLISTEN for each channel and removes event listeners + * 7. The adapter does NOT import pg directly + * 8. Named export only (tree-shakeable) + * + * Tests FAIL initially because src/server/adapters/pg.ts has not been created yet. + */ + +// --------------------------------------------------------------------------- +// Mock pg client helpers +// --------------------------------------------------------------------------- + +interface PgNotification { + channel: string + payload?: string + processId?: number +} + +type NotificationListener = (notification: PgNotification) => void + +/** + * Minimal mock of a pg Client that captures LISTEN/UNLISTEN queries + * and exposes a helper to fire 'notification' events. + */ +function makeMockPgClient() { + const eventListeners: Map = new Map() + const queries: string[] = [] + + const client = { + query: mock(async (sql: string) => { + queries.push(sql) + return { rows: [], rowCount: 0 } + }), + on: mock((event: string, listener: NotificationListener) => { + const listeners = eventListeners.get(event) ?? [] + listeners.push(listener) + eventListeners.set(event, listeners) + }), + off: mock((event: string, listener: NotificationListener) => { + const listeners = eventListeners.get(event) ?? [] + eventListeners.set( + event, + listeners.filter((l) => l !== listener), + ) + }), + removeListener: mock((event: string, listener: NotificationListener) => { + const listeners = eventListeners.get(event) ?? [] + eventListeners.set( + event, + listeners.filter((l) => l !== listener), + ) + }), + } + + /** Simulate a NOTIFY arriving on the pg client */ + function fireNotification(notification: PgNotification): void { + const listeners = eventListeners.get('notification') ?? [] + for (const listener of listeners) { + listener(notification) + } + } + + return { client, queries, fireNotification, eventListeners } +} + +type PgAdapterMapping = { + [channelName: string]: string // pg NOTIFY channel → schema event type +} + +async function importAdapter() { + // biome-ignore lint/suspicious/noExplicitAny: dynamic import before implementation exists + const mod = (await import('../server/adapters/pg.ts')) as Record + return mod.createPgAdapter as ( + // biome-ignore lint/suspicious/noExplicitAny: mock pg client has minimal interface + client: Record, + mapping: PgAdapterMapping, + ) => SSEAdapter +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createPgAdapter()', () => { + describe('export and interface', () => { + it('should be exported from src/server/adapters/pg.ts', async () => { + const mod = await import('../server/adapters/pg.ts') + expect((mod as Record).createPgAdapter).toBeDefined() + expect(typeof (mod as Record).createPgAdapter).toBe( + 'function', + ) + }) + + it('should return an object implementing the SSEAdapter interface', async () => { + const createPgAdapter = await importAdapter() + const { client } = makeMockPgClient() + + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + + expect(typeof adapter.start).toBe('function') + expect(typeof adapter.stop).toBe('function') + }) + + it('should be a named export (not default)', async () => { + const mod = (await import('../server/adapters/pg.ts')) as Record< + string, + unknown + > + expect(mod.createPgAdapter).toBeDefined() + expect(mod.default).toBeUndefined() + }) + }) + + describe('start() — LISTEN on each mapped channel', () => { + it('start() should call client.query("LISTEN ") for each mapped channel', async () => { + const createPgAdapter = await importAdapter() + const { client, queries } = makeMockPgClient() + + const adapter = createPgAdapter(client, { + users_changed: 'user.updated', + orders_created: 'order.created', + }) + await adapter.start(() => {}) + + const listenQueries = queries.filter((q) => + q.toUpperCase().startsWith('LISTEN'), + ) + expect(listenQueries).toHaveLength(2) + + // Both channels must be LISTENed on — identifiers are always double-quoted + const listenedChannels = listenQueries.map((q) => + q.replace(/^LISTEN\s+/i, '').trim(), + ) + expect(listenedChannels).toContain('"users_changed"') + expect(listenedChannels).toContain('"orders_created"') + }) + + it('start() with a single mapped channel should issue one LISTEN query', async () => { + const createPgAdapter = await importAdapter() + const { client, queries } = makeMockPgClient() + + const adapter = createPgAdapter(client, { + events_channel: 'data.changed', + }) + await adapter.start(() => {}) + + const listenQueries = queries.filter((q) => + q.toUpperCase().startsWith('LISTEN'), + ) + expect(listenQueries).toHaveLength(1) + expect(listenQueries[0]).toMatch(/events_channel/i) + }) + + it('start() should register a notification event listener on the client', async () => { + const createPgAdapter = await importAdapter() + const { client } = makeMockPgClient() + + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + await adapter.start(() => {}) + + // client.on should have been called with 'notification' + const onCalls = (client.on as ReturnType).mock.calls + const notificationCall = onCalls.find( + (args) => args[0] === 'notification', + ) + expect(notificationCall).toBeDefined() + }) + + it('start() with empty mapping should not issue any LISTEN queries', async () => { + const createPgAdapter = await importAdapter() + const { client, queries } = makeMockPgClient() + + const adapter = createPgAdapter(client, {}) + await adapter.start(() => {}) + + const listenQueries = queries.filter((q) => + q.toUpperCase().startsWith('LISTEN'), + ) + expect(listenQueries).toHaveLength(0) + }) + }) + + describe('notification handling — emit on NOTIFY', () => { + it('should emit a schema event when a matching NOTIFY arrives', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification } = makeMockPgClient() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + await adapter.start((type, payload) => emitted.push({ type, payload })) + + fireNotification({ + channel: 'users_changed', + payload: JSON.stringify({ id: 42, name: 'Alice' }), + }) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.updated') + }) + + it('should parse the NOTIFY payload as JSON', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification } = makeMockPgClient() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createPgAdapter(client, { + orders_created: 'order.created', + }) + await adapter.start((type, payload) => emitted.push({ type, payload })) + + const orderData = { id: 99, total: 150.0, customerId: 'cust-1' } + fireNotification({ + channel: 'orders_created', + payload: JSON.stringify(orderData), + }) + + expect(emitted[0]?.payload).toEqual(orderData) + }) + + it('should route notifications from multiple channels to different schema events', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification } = makeMockPgClient() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createPgAdapter(client, { + users_changed: 'user.updated', + orders_created: 'order.created', + products_deleted: 'product.deleted', + }) + await adapter.start((type, payload) => emitted.push({ type, payload })) + + fireNotification({ + channel: 'users_changed', + payload: JSON.stringify({ id: 1 }), + }) + fireNotification({ + channel: 'orders_created', + payload: JSON.stringify({ id: 2 }), + }) + fireNotification({ + channel: 'products_deleted', + payload: JSON.stringify({ id: 3 }), + }) + + expect(emitted).toHaveLength(3) + expect(emitted[0]?.type).toBe('user.updated') + expect(emitted[1]?.type).toBe('order.created') + expect(emitted[2]?.type).toBe('product.deleted') + }) + + it('should not emit for notifications on unmapped channels', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification } = makeMockPgClient() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + await adapter.start((type, payload) => emitted.push({ type, payload })) + + // Notification on a channel not in the mapping + fireNotification({ + channel: 'unrelated_channel', + payload: JSON.stringify({ id: 5 }), + }) + + expect(emitted).toHaveLength(0) + }) + + it('should handle a NOTIFY payload that is an empty string gracefully (no throw)', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification } = makeMockPgClient() + + const adapter = createPgAdapter(client, { + events_channel: 'data.changed', + }) + await adapter.start(() => {}) + + // Empty payload is valid NOTIFY (no payload given) + expect(() => { + fireNotification({ channel: 'events_channel', payload: '' }) + }).not.toThrow() + }) + + it('should handle a NOTIFY with no payload field gracefully (no throw)', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification } = makeMockPgClient() + + const adapter = createPgAdapter(client, { + events_channel: 'data.changed', + }) + await adapter.start(() => {}) + + // pg may fire notifications without a payload property + expect(() => { + fireNotification({ channel: 'events_channel' }) + }).not.toThrow() + }) + + it('should handle malformed JSON payload gracefully (no throw)', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification } = makeMockPgClient() + + const adapter = createPgAdapter(client, { + events_channel: 'data.changed', + }) + await adapter.start(() => {}) + + // Malformed JSON — adapter should not throw + expect(() => { + fireNotification({ + channel: 'events_channel', + payload: 'not-valid-json', + }) + }).not.toThrow() + }) + + it('multiple NOTIFY on same channel should each emit once', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification } = makeMockPgClient() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + await adapter.start((type, payload) => emitted.push({ type, payload })) + + fireNotification({ + channel: 'users_changed', + payload: JSON.stringify({ id: 1 }), + }) + fireNotification({ + channel: 'users_changed', + payload: JSON.stringify({ id: 2 }), + }) + fireNotification({ + channel: 'users_changed', + payload: JSON.stringify({ id: 3 }), + }) + + expect(emitted).toHaveLength(3) + expect((emitted[0]?.payload as Record).id).toBe(1) + expect((emitted[1]?.payload as Record).id).toBe(2) + expect((emitted[2]?.payload as Record).id).toBe(3) + }) + }) + + describe('stop() — UNLISTEN and remove listener', () => { + it('stop() should call UNLISTEN for each mapped channel', async () => { + const createPgAdapter = await importAdapter() + const { client, queries } = makeMockPgClient() + + const adapter = createPgAdapter(client, { + users_changed: 'user.updated', + orders_created: 'order.created', + }) + await adapter.start(() => {}) + await adapter.stop() + + const unlistenQueries = queries.filter((q) => + q.toUpperCase().startsWith('UNLISTEN'), + ) + expect(unlistenQueries).toHaveLength(2) + + // Identifiers are always double-quoted + const unlistenedChannels = unlistenQueries.map((q) => + q.replace(/^UNLISTEN\s+/i, '').trim(), + ) + expect(unlistenedChannels).toContain('"users_changed"') + expect(unlistenedChannels).toContain('"orders_created"') + }) + + it('stop() should remove the notification event listener from the client', async () => { + const createPgAdapter = await importAdapter() + const { client, fireNotification, eventListeners } = makeMockPgClient() + + const emitted: string[] = [] + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + await adapter.start((type) => emitted.push(type)) + + // Verify events work before stop + fireNotification({ + channel: 'users_changed', + payload: JSON.stringify({ id: 1 }), + }) + expect(emitted).toHaveLength(1) + + await adapter.stop() + + // After stop, no listeners should be registered + const notifListeners = eventListeners.get('notification') ?? [] + expect(notifListeners).toHaveLength(0) + + // And events no longer flow + fireNotification({ + channel: 'users_changed', + payload: JSON.stringify({ id: 2 }), + }) + expect(emitted).toHaveLength(1) // unchanged + }) + + it('stop() should return void or Promise', async () => { + const createPgAdapter = await importAdapter() + const { client } = makeMockPgClient() + + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + await adapter.start(() => {}) + + const result = adapter.stop() + if (result !== undefined) { + expect(result).toBeInstanceOf(Promise) + await result + } + }) + + it('stop() should not throw if called without start()', async () => { + const createPgAdapter = await importAdapter() + const { client } = makeMockPgClient() + + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + + await expect(Promise.resolve(adapter.stop())).resolves.toBeUndefined() + }) + + it('stop() should not throw if called twice', async () => { + const createPgAdapter = await importAdapter() + const { client } = makeMockPgClient() + + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + await adapter.start(() => {}) + await adapter.stop() + + await expect(Promise.resolve(adapter.stop())).resolves.toBeUndefined() + }) + + it('stop() should log a console.warn when client has neither off() nor removeListener()', async () => { + const createPgAdapter = await importAdapter() + + // Minimal client without off() or removeListener() + const minimalClient = { + query: mock(async (_sql: string) => ({ rows: [], rowCount: 0 })), + on: mock((_event: string, _listener: unknown) => {}), + } + + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) + + try { + // biome-ignore lint/suspicious/noExplicitAny: minimal mock client + const adapter = createPgAdapter(minimalClient as any, { + users_changed: 'user.updated', + }) + await adapter.start(() => {}) + await adapter.stop() + + expect(warnSpy).toHaveBeenCalledWith( + 'PostgreSQL client does not support off() or removeListener() — notification handler may leak', + ) + } finally { + warnSpy.mockRestore() + } + }) + }) + + describe('does NOT import pg', () => { + it('module should load without pg package installed', async () => { + const mod = await import('../server/adapters/pg.ts') + expect(mod).toBeDefined() + }) + }) + + describe('identifier quoting — reserved keywords', () => { + it('should quote a channel named "select" (PostgreSQL reserved keyword) correctly in LISTEN', async () => { + const createPgAdapter = await importAdapter() + const { client, queries } = makeMockPgClient() + + // 'select' is a PostgreSQL reserved keyword — unquoted it causes a syntax error + const adapter = createPgAdapter(client, { select: 'data.changed' }) + await adapter.start(() => {}) + + const listenQueries = queries.filter((q) => + q.toUpperCase().startsWith('LISTEN'), + ) + expect(listenQueries).toHaveLength(1) + // Must be double-quoted so PostgreSQL treats it as an identifier, not a keyword + expect(listenQueries[0]).toBe('LISTEN "select"') + }) + + it('should quote a channel named "table" (PostgreSQL reserved keyword) correctly in UNLISTEN', async () => { + const createPgAdapter = await importAdapter() + const { client, queries } = makeMockPgClient() + + const adapter = createPgAdapter(client, { table: 'data.changed' }) + await adapter.start(() => {}) + await adapter.stop() + + const unlistenQueries = queries.filter((q) => + q.toUpperCase().startsWith('UNLISTEN'), + ) + expect(unlistenQueries).toHaveLength(1) + expect(unlistenQueries[0]).toBe('UNLISTEN "table"') + }) + + it('should escape embedded double-quotes in channel names', async () => { + const createPgAdapter = await importAdapter() + const { client, queries } = makeMockPgClient() + + // Channel name with a double-quote character in it + const adapter = createPgAdapter(client, { 'my"channel': 'data.changed' }) + await adapter.start(() => {}) + + const listenQueries = queries.filter((q) => + q.toUpperCase().startsWith('LISTEN'), + ) + expect(listenQueries).toHaveLength(1) + // Double-quote inside the identifier must be escaped as "" + expect(listenQueries[0]).toBe('LISTEN "my""channel"') + }) + + it('simple alphanumeric channel names should also be quoted', async () => { + const createPgAdapter = await importAdapter() + const { client, queries } = makeMockPgClient() + + const adapter = createPgAdapter(client, { users_changed: 'user.updated' }) + await adapter.start(() => {}) + + const listenQueries = queries.filter((q) => + q.toUpperCase().startsWith('LISTEN'), + ) + expect(listenQueries).toHaveLength(1) + // All identifiers are now always quoted + expect(listenQueries[0]).toBe('LISTEN "users_changed"') + }) + }) +}) diff --git a/src/__tests__/adapter-prisma.test.ts b/src/__tests__/adapter-prisma.test.ts new file mode 100644 index 0000000..3dd9cec --- /dev/null +++ b/src/__tests__/adapter-prisma.test.ts @@ -0,0 +1,810 @@ +import { describe, expect, it, mock } from 'bun:test' +import type { SSEAdapter } from '../server/adapters/types.ts' + +/** + * Tests for createPrismaAdapter() — wraps Prisma $use() middleware to + * intercept create/update/delete operations and emit SSE events. + * + * These tests verify that: + * 1. createPrismaAdapter(prisma, mapping) is exported from src/server/adapters/prisma.ts + * 2. The adapter implements the SSEAdapter interface (start/stop) + * 3. start() registers a Prisma $use() middleware + * 4. Events are emitted AFTER the operation completes (post-middleware) + * 5. Events are NOT emitted when the operation throws + * 6. Mapping config maps model names + actions to schema event types + * 7. stop() makes the middleware a no-op (no further events emitted) + * 8. The adapter does NOT import @prisma/client directly + * + * Tests FAIL initially because src/server/adapters/prisma.ts has not been created yet. + */ + +// --------------------------------------------------------------------------- +// Mock Prisma client helpers +// --------------------------------------------------------------------------- + +/** + * Minimal shape of Prisma middleware params + * (matches real @prisma/client MiddlewareParams) + */ +interface PrismaMiddlewareParams { + model?: string + action: string + args: unknown + dataPath: string[] + runInTransaction: boolean +} + +type PrismaMiddleware = ( + params: PrismaMiddlewareParams, + next: (params: PrismaMiddlewareParams) => Promise, +) => Promise + +/** + * Build a mock Prisma client that captures registered $use() middlewares + * and exposes a helper to invoke them manually in tests. + */ +function makeMockPrisma() { + const middlewares: PrismaMiddleware[] = [] + + const prisma = { + $use: mock((middleware: PrismaMiddleware) => { + middlewares.push(middleware) + }), + } + + /** Invoke all registered middlewares in sequence for a given params object */ + async function runMiddleware( + params: PrismaMiddlewareParams, + result: unknown = { id: 1 }, + ): Promise { + // next() is the final handler that returns the mock operation result + const baseNext = async (_p: PrismaMiddlewareParams) => result + + // Chain middlewares: each calls next which calls the subsequent one + let chain = baseNext + for (let i = middlewares.length - 1; i >= 0; i--) { + const mw = middlewares[i]! + const nextInChain = chain + chain = (p) => mw(p, nextInChain) + } + + return chain(params) + } + + return { prisma, runMiddleware, middlewares } +} + +// --------------------------------------------------------------------------- +// Mapping config type (mirrors the expected public API) +// --------------------------------------------------------------------------- + +type PrismaAdapterMapping = { + [modelName: string]: { + created?: string + updated?: string + deleted?: string + } +} + +// --------------------------------------------------------------------------- +// Import helper — loads the adapter lazily so tests fail cleanly if missing +// --------------------------------------------------------------------------- + +async function importAdapter() { + const mod = (await import('../server/adapters/prisma.ts')) as Record< + string, + // biome-ignore lint/suspicious/noExplicitAny: dynamic import typing + any + > + return mod.createPrismaAdapter as ( + prisma: { $use: (middleware: PrismaMiddleware) => void }, + mapping: PrismaAdapterMapping, + ) => SSEAdapter +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createPrismaAdapter()', () => { + describe('export and interface', () => { + it('should be exported from src/server/adapters/prisma.ts', async () => { + const mod = await import('../server/adapters/prisma.ts') + expect((mod as Record).createPrismaAdapter).toBeDefined() + expect(typeof (mod as Record).createPrismaAdapter).toBe( + 'function', + ) + }) + + it('should return an object implementing the SSEAdapter interface', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + + expect(typeof adapter.start).toBe('function') + expect(typeof adapter.stop).toBe('function') + }) + }) + + describe('start() — registers $use() middleware', () => { + it('start() should call prisma.$use() to register a middleware', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(() => {}) + + expect(prisma.$use).toHaveBeenCalledTimes(1) + }) + + it('start() should pass a function to prisma.$use()', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(() => {}) + + const registeredMiddleware = (prisma.$use as ReturnType).mock + .calls[0]?.[0] + expect(typeof registeredMiddleware).toBe('function') + }) + + it('start() should call next(params) to allow the operation to proceed', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(() => {}) + + const _nextMock = mock(async (_p: PrismaMiddlewareParams) => ({ id: 99 })) + const params: PrismaMiddlewareParams = { + model: 'User', + action: 'create', + args: { data: { name: 'Alice' } }, + dataPath: [], + runInTransaction: false, + } + + // Call via runMiddleware so next() is also invoked + await runMiddleware(params, { id: 99 }) + + // next must have been called (operation must proceed) + // Verify by checking the result flows through + const result = await runMiddleware(params, { id: 42 }) + expect(result).toEqual({ id: 42 }) + }) + }) + + describe('event emission — post-middleware, create/update/delete', () => { + it('should emit a mapped event after a create operation completes', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(emit) + + await runMiddleware( + { + model: 'User', + action: 'create', + args: { data: { name: 'Alice' } }, + dataPath: [], + runInTransaction: false, + }, + { id: 1, name: 'Alice' }, + ) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.created') + }) + + it('should emit a mapped event after an update operation completes', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + const adapter = createPrismaAdapter(prisma, { + User: { updated: 'user.updated' }, + }) + adapter.start(emit) + + await runMiddleware( + { + model: 'User', + action: 'update', + args: { where: { id: 1 }, data: { name: 'Bob' } }, + dataPath: [], + runInTransaction: false, + }, + { id: 1, name: 'Bob' }, + ) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.updated') + }) + + it('should emit a mapped event after a delete operation completes', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + const adapter = createPrismaAdapter(prisma, { + User: { deleted: 'user.deleted' }, + }) + adapter.start(emit) + + await runMiddleware( + { + model: 'User', + action: 'delete', + args: { where: { id: 1 } }, + dataPath: [], + runInTransaction: false, + }, + { id: 1 }, + ) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.type).toBe('user.deleted') + }) + + it('event payload should be the result returned by next() (the operation result)', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + const adapter = createPrismaAdapter(prisma, { + Order: { created: 'order.created' }, + }) + adapter.start(emit) + + const operationResult = { id: 42, total: 99.99, status: 'pending' } + await runMiddleware( + { + model: 'Order', + action: 'create', + args: { data: { total: 99.99 } }, + dataPath: [], + runInTransaction: false, + }, + operationResult, + ) + + expect(emitted[0]?.payload).toEqual(operationResult) + }) + + it('emit is called AFTER next() completes, not before', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const callOrder: string[] = [] + const emit = (_type: string, _payload: unknown) => callOrder.push('emit') + + // Wrap runMiddleware to track when next() resolves + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(emit) + + // Use a slow next() to verify ordering + const slowNext = async (_p: PrismaMiddlewareParams) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + callOrder.push('next-resolved') + return { id: 1 } + } + + // Directly invoke the registered middleware with slow next + const middleware = (prisma.$use as ReturnType).mock + .calls[0]?.[0] as PrismaMiddleware + await middleware( + { + model: 'User', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + slowNext, + ) + + expect(callOrder).toEqual(['next-resolved', 'emit']) + }) + }) + + describe('mapping — model name + action routing', () => { + it('should not emit for unmapped models', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + // Only User is mapped, not Post + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(emit) + + await runMiddleware( + { + model: 'Post', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 1 }, + ) + + expect(emitted).toHaveLength(0) + }) + + it('should not emit for unmapped actions on a mapped model', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + // User.updated and User.deleted not mapped — only created + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(emit) + + await runMiddleware( + { + model: 'User', + action: 'update', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 1 }, + ) + + await runMiddleware( + { + model: 'User', + action: 'delete', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 1 }, + ) + + expect(emitted).toHaveLength(0) + }) + + it('should route events from multiple models correctly', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created', updated: 'user.updated' }, + Order: { created: 'order.created', deleted: 'order.deleted' }, + }) + adapter.start(emit) + + await runMiddleware( + { + model: 'User', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 1 }, + ) + await runMiddleware( + { + model: 'Order', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 2 }, + ) + await runMiddleware( + { + model: 'User', + action: 'update', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 1 }, + ) + await runMiddleware( + { + model: 'Order', + action: 'delete', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 2 }, + ) + + expect(emitted).toHaveLength(4) + expect(emitted[0]?.type).toBe('user.created') + expect(emitted[1]?.type).toBe('order.created') + expect(emitted[2]?.type).toBe('user.updated') + expect(emitted[3]?.type).toBe('order.deleted') + }) + + it('should not emit for non-mutation Prisma actions (e.g. findMany, findUnique)', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + const adapter = createPrismaAdapter(prisma, { + User: { + created: 'user.created', + updated: 'user.updated', + deleted: 'user.deleted', + }, + }) + adapter.start(emit) + + // Read-only operations — should not trigger events + for (const action of [ + 'findMany', + 'findUnique', + 'findFirst', + 'count', + 'aggregate', + ]) { + await runMiddleware( + { + model: 'User', + action, + args: {}, + dataPath: [], + runInTransaction: false, + }, + [], + ) + } + + expect(emitted).toHaveLength(0) + }) + + it('should handle Prisma upsert action as both create and update (or at least not throw)', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + // upsert is a valid Prisma action — adapter should handle it gracefully + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created', updated: 'user.updated' }, + }) + adapter.start(() => {}) + + // Should not throw for upsert + await expect( + runMiddleware( + { + model: 'User', + action: 'upsert', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 1 }, + ), + ).resolves.toBeDefined() + }) + }) + + describe('error handling — no event emitted on operation failure', () => { + it('should NOT emit an event when the Prisma operation throws', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(emit) + + // Get the registered middleware + const middleware = (prisma.$use as ReturnType).mock + .calls[0]?.[0] as PrismaMiddleware + + // Simulate a failing DB operation + const failingNext = async ( + _p: PrismaMiddlewareParams, + ): Promise => { + throw new Error('Database constraint violation') + } + + await expect( + middleware( + { + model: 'User', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + failingNext, + ), + ).rejects.toThrow('Database constraint violation') + + // No event should have been emitted + expect(emitted).toHaveLength(0) + }) + + it('should propagate the error from next() to the caller', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(() => {}) + + const middleware = (prisma.$use as ReturnType).mock + .calls[0]?.[0] as PrismaMiddleware + const dbError = new Error('Unique constraint failed') + + await expect( + middleware( + { + model: 'User', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + async () => { + throw dbError + }, + ), + ).rejects.toBe(dbError) + }) + }) + + describe('stop() — deactivates middleware', () => { + it('stop() should not throw', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(() => {}) + + expect(() => adapter.stop()).not.toThrow() + }) + + it('after stop(), middleware should no longer emit events', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma, runMiddleware } = makeMockPrisma() + + const emitted: Array<{ type: string; payload: unknown }> = [] + const emit = (type: string, payload: unknown) => + emitted.push({ type, payload }) + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(emit) + + // Verify events flow before stop + await runMiddleware( + { + model: 'User', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 1 }, + ) + expect(emitted).toHaveLength(1) + + // Stop the adapter + adapter.stop() + + // Further operations should not emit + await runMiddleware( + { + model: 'User', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + { id: 2 }, + ) + expect(emitted).toHaveLength(1) // still 1, no new events + }) + + it('after stop(), middleware should still call next() (operation must still proceed)', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const adapter = createPrismaAdapter(prisma, { + User: { created: 'user.created' }, + }) + adapter.start(() => {}) + adapter.stop() + + const middleware = (prisma.$use as ReturnType).mock + .calls[0]?.[0] as PrismaMiddleware + + const nextResult = { id: 99 } + const result = await middleware( + { + model: 'User', + action: 'create', + args: {}, + dataPath: [], + runInTransaction: false, + }, + async () => nextResult, + ) + + // Operation must still return a result even after stop + expect(result).toEqual(nextResult) + }) + + it('stop() return value satisfies void | Promise', async () => { + const createPrismaAdapter = await importAdapter() + const { prisma } = makeMockPrisma() + + const adapter = createPrismaAdapter(prisma, { + User: { updated: 'user.updated' }, + }) + adapter.start(() => {}) + + const result = adapter.stop() + // Either undefined (sync) or a Promise that resolves + if (result !== undefined) { + expect(result).toBeInstanceOf(Promise) + await result + } + }) + }) + + describe('start() — error handling when $use() throws', () => { + it('should re-throw when prisma.$use() throws', async () => { + const createPrismaAdapter = await importAdapter() + + const brokenPrisma = { + $use: (_middleware: PrismaMiddleware) => { + throw new Error('invalid client state') + }, + } + + const adapter = createPrismaAdapter(brokenPrisma, { + User: { created: 'user.created' }, + }) + + expect(() => adapter.start(() => {})).toThrow('invalid client state') + }) + + it('should reset active and started to false when $use() throws', async () => { + const createPrismaAdapter = await importAdapter() + + const brokenPrisma = { + $use: (_middleware: PrismaMiddleware) => { + throw new Error('invalid client state') + }, + } + + const adapter = createPrismaAdapter(brokenPrisma, { + User: { created: 'user.created' }, + }) + + try { + adapter.start(() => {}) + } catch { + // expected + } + + // After a failed start(), calling start() again should attempt to register + // (not be blocked by the started=true guard), proving state was reset. + // We verify this by using a working prisma client the second time. + makeMockPrisma() + // Replace the broken client by creating a fresh adapter to re-verify state was reset: + // The key observable is that stop() on the broken adapter doesn't error, + // and a second start() call (with a different approach) is attempted. + expect(() => adapter.stop()).not.toThrow() + }) + + it('should allow start() to be retried after $use() throws', async () => { + const createPrismaAdapter = await importAdapter() + + let shouldThrow = true + const conditionalPrisma = { + $use: mock((_middleware: PrismaMiddleware) => { + if (shouldThrow) throw new Error('not ready yet') + // On second call, behave normally (do nothing, no throw) + }), + } + + const adapter = createPrismaAdapter( + conditionalPrisma as unknown as Parameters< + typeof createPrismaAdapter + >[0], + { + User: { created: 'user.created' }, + }, + ) + + // First call should throw + expect(() => adapter.start(() => {})).toThrow('not ready yet') + + // Now allow $use to succeed + shouldThrow = false + + // Second call should not throw (state was reset, so started === false) + expect(() => adapter.start(() => {})).not.toThrow() + expect(conditionalPrisma.$use).toHaveBeenCalledTimes(2) + }) + }) + + describe('does NOT import @prisma/client', () => { + it('module source should not contain @prisma/client import', async () => { + // Verify by reading the source file once it exists + // This test will pass as long as the implementation doesn't directly import the driver + const mod = await import('../server/adapters/prisma.ts') + // If the module loaded without @prisma/client being installed, it doesn't import it + expect(mod).toBeDefined() + }) + }) + + describe('tree-shakeability — named export only', () => { + it('createPrismaAdapter should be a named export (not default)', async () => { + const mod = (await import('../server/adapters/prisma.ts')) as Record< + string, + unknown + > + + // Named export must exist + expect(mod.createPrismaAdapter).toBeDefined() + // Default export must NOT exist (tree-shaking works better with named exports) + expect(mod.default).toBeUndefined() + }) + }) +}) diff --git a/src/__tests__/adapter-types.test.ts b/src/__tests__/adapter-types.test.ts new file mode 100644 index 0000000..0a05e15 --- /dev/null +++ b/src/__tests__/adapter-types.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from 'bun:test' +import type { SchemaResult } from '../types.ts' + +/** + * Type-level tests for SSEAdapter interface and AdapterMapping generic type. + * + * These tests verify that: + * 1. SSEAdapter interface has start() and stop() methods with correct signatures + * 2. AdapterMapping generic type maps source event names to schema event type keys + * 3. AdapterMapping rejects mappings to non-existent schema event keys + * 4. SSEAdapter is exported from src/server/index.ts (reactive-swr/server) + * 5. Types work correctly with TypeScript strict mode + * + * Tests FAIL initially because src/server/adapters/types.ts has not been implemented yet. + */ + +describe('SSEAdapter interface', () => { + it('should be importable as a type from src/server/index.ts', async () => { + // Verify the module loads without error (type-only export still affects module shape) + const serverExports = await import('../server/index.ts') + // SSEAdapter is a type — no runtime value, but module must load + expect(serverExports).toBeDefined() + }) + + it('should accept a valid synchronous implementation of SSEAdapter', () => { + // Verify the interface shape at the type level + // SSEAdapter requires start(emit) and stop() methods + type SSEAdapter = import('../server/index.ts').SSEAdapter + + const syncAdapter: SSEAdapter = { + start: (emit: (eventType: string, payload: unknown) => void): void => { + emit('user.updated', { id: 1 }) + }, + stop: (): void => { + // cleanup + }, + } + + expect(typeof syncAdapter.start).toBe('function') + expect(typeof syncAdapter.stop).toBe('function') + }) + + it('should accept an async implementation of SSEAdapter', () => { + type SSEAdapter = import('../server/index.ts').SSEAdapter + + const asyncAdapter: SSEAdapter = { + start: async ( + emit: (eventType: string, payload: unknown) => void, + ): Promise => { + await Promise.resolve() + emit('order.placed', { orderId: '123' }) + }, + stop: async (): Promise => { + await Promise.resolve() + }, + } + + expect(typeof asyncAdapter.start).toBe('function') + expect(typeof asyncAdapter.stop).toBe('function') + }) + + it('start() method should receive an emit callback with (eventType: string, payload: unknown) signature', () => { + type SSEAdapter = import('../server/index.ts').SSEAdapter + + const capturedEmits: Array<{ eventType: string; payload: unknown }> = [] + + const adapter: SSEAdapter = { + start: (emit: (eventType: string, payload: unknown) => void): void => { + // Invoke emit to verify the callback signature + emit('test.event', { data: 'value' }) + emit('another.event', 42) + emit('null.event', null) + }, + stop: (): void => {}, + } + + // Call start with a test emit callback to verify the signature + adapter.start((eventType, payload) => { + capturedEmits.push({ eventType, payload }) + }) + + expect(capturedEmits).toHaveLength(3) + expect(capturedEmits[0]).toEqual({ + eventType: 'test.event', + payload: { data: 'value' }, + }) + expect(capturedEmits[1]).toEqual({ + eventType: 'another.event', + payload: 42, + }) + expect(capturedEmits[2]).toEqual({ eventType: 'null.event', payload: null }) + }) + + it('stop() method should return void or Promise', () => { + type SSEAdapter = import('../server/index.ts').SSEAdapter + + // Synchronous stop + const syncAdapter: SSEAdapter = { + start: (_emit) => {}, + stop: (): void => {}, + } + + const syncResult = syncAdapter.stop() + // void return — result is undefined + expect(syncResult).toBeUndefined() + + // Async stop + const asyncAdapter: SSEAdapter = { + start: (_emit) => {}, + stop: (): Promise => Promise.resolve(), + } + + const asyncResult = asyncAdapter.stop() + expect(asyncResult).toBeInstanceOf(Promise) + }) + + it('should not require any properties beyond start and stop', () => { + type SSEAdapter = import('../server/index.ts').SSEAdapter + + // Minimal valid implementation — must satisfy the interface with only start and stop + const minimalAdapter: SSEAdapter = { + start: (_emit) => {}, + stop: () => {}, + } + + expect(minimalAdapter).toBeDefined() + expect(Object.keys(minimalAdapter)).toContain('start') + expect(Object.keys(minimalAdapter)).toContain('stop') + }) +}) + +describe('AdapterMapping generic type', () => { + it('should be importable as a type from src/server/index.ts', async () => { + // Type-only import — verify module loads + const serverExports = await import('../server/index.ts') + expect(serverExports).toBeDefined() + }) + + it('should accept a valid mapping from source event names to schema event keys', () => { + type AdapterMapping< + S extends SchemaResult>, + > = import('../server/index.ts').AdapterMapping + + // Define a mock schema result type (as returned by defineSchema()) + type MockSchema = SchemaResult<{ + 'user.updated': { key: string } + 'order.placed': { key: string } + 'item.deleted': { key: string } + }> + + // AdapterMapping maps adapter-level source event names to schema event type keys + // Source keys are strings (adapter-specific), values must be keyof the schema + const mapping: AdapterMapping = { + update: 'user.updated', + insert: 'order.placed', + delete: 'item.deleted', + } + + expect(mapping.update).toBe('user.updated') + expect(mapping.insert).toBe('order.placed') + expect(mapping.delete).toBe('item.deleted') + }) + + it('should allow partial mappings (not all schema events need to be mapped)', () => { + type AdapterMapping< + S extends SchemaResult>, + > = import('../server/index.ts').AdapterMapping + + type MockSchema = SchemaResult<{ + 'user.updated': { key: string } + 'order.placed': { key: string } + 'item.deleted': { key: string } + }> + + // Only mapping a subset of schema events is valid + const partialMapping: AdapterMapping = { + changed: 'user.updated', + } + + expect(partialMapping.changed).toBe('user.updated') + }) + + it('should allow mapping multiple source events to the same schema event key', () => { + type AdapterMapping< + S extends SchemaResult>, + > = import('../server/index.ts').AdapterMapping + + type MockSchema = SchemaResult<{ + 'user.updated': { key: string } + }> + + // Multiple source events can map to the same schema event + const mapping: AdapterMapping = { + update: 'user.updated', + replace: 'user.updated', + patch: 'user.updated', + } + + expect(mapping.update).toBe('user.updated') + expect(mapping.replace).toBe('user.updated') + expect(mapping.patch).toBe('user.updated') + }) + + it('should reject mappings to non-existent schema event keys at compile time', () => { + // This test verifies via @ts-expect-error that AdapterMapping enforces valid schema keys. + // The type system should reject any value that is not a key of the schema. + + type AdapterMapping< + S extends SchemaResult>, + > = import('../server/index.ts').AdapterMapping + + type MockSchema = SchemaResult<{ + 'user.updated': { key: string } + 'order.placed': { key: string } + }> + + // @ts-expect-error - 'nonexistent.event' is not a key in MockSchema + const _invalidMapping: AdapterMapping = { + someEvent: 'nonexistent.event', + } + + // Suppress unused variable warning — the test is at the type level + expect(_invalidMapping).toBeDefined() + }) + + it('should work with an empty mapping (no source events mapped)', () => { + type AdapterMapping< + S extends SchemaResult>, + > = import('../server/index.ts').AdapterMapping + + type MockSchema = SchemaResult<{ + 'user.updated': { key: string } + }> + + // An empty mapping is valid — no source events are mapped yet + const emptyMapping: AdapterMapping = {} + + expect(emptyMapping).toBeDefined() + expect(Object.keys(emptyMapping)).toHaveLength(0) + }) + + it('should work with any string as a source event key', () => { + type AdapterMapping< + S extends SchemaResult>, + > = import('../server/index.ts').AdapterMapping + + type MockSchema = SchemaResult<{ + 'data.changed': { key: string } + }> + + // Source event names (keys) can be any string — adapter-specific + const mapping: AdapterMapping = { + insert: 'data.changed', + update: 'data.changed', + 'my-custom-event': 'data.changed', + UPPERCASE_EVENT: 'data.changed', + } + + expect(Object.keys(mapping)).toHaveLength(4) + }) +}) + +describe('SSEAdapter exported from reactive-swr/server', () => { + it('should export SSEAdapter as a type from src/server/index.ts', async () => { + // The server module must be loadable — SSEAdapter is a type export + const serverModule = await import('../server/index.ts') + + // createChannel is the existing runtime export from server — verify server module still works + expect(typeof serverModule.createChannel).toBe('function') + }) + + it('should export AdapterMapping as a type from src/server/index.ts', async () => { + // Type-only export — verify module loads and existing runtime exports are intact + const serverModule = await import('../server/index.ts') + expect(serverModule).toBeDefined() + }) +}) + +describe('TypeScript strict mode compatibility', () => { + it('SSEAdapter start() emit callback enforces string eventType', () => { + type SSEAdapter = import('../server/index.ts').SSEAdapter + + // Verify via type assignment that the emit parameter is typed as + // (eventType: string, payload: unknown) => void + // This is a compile-time check — the type must not accept non-string eventType + type EmitFn = Parameters[0] + type EventTypeParam = Parameters[0] + type PayloadParam = Parameters[1] + + // If these type assertions compile, the signature is correct + const _eventTypeCheck: EventTypeParam = 'test.event' + const _payloadCheck: PayloadParam = { anything: true } + + // @ts-expect-error - eventType must be string, not number + const _invalidEventType: EventTypeParam = 42 + + expect(_eventTypeCheck).toBe('test.event') + expect(_payloadCheck).toBeDefined() + expect(_invalidEventType).toBeDefined() + }) + + it('SSEAdapter start() emit callback accepts unknown payload', () => { + type SSEAdapter = import('../server/index.ts').SSEAdapter + + const payloads: unknown[] = [] + + const adapter: SSEAdapter = { + start: (emit) => { + // payload is unknown — any value is valid + emit('event.one', { id: 1 }) + emit('event.two', 'a string payload') + emit('event.three', null) + emit('event.four', undefined) + emit('event.five', [1, 2, 3]) + }, + stop: () => {}, + } + + adapter.start((_type, payload) => { + payloads.push(payload) + }) + + expect(payloads).toHaveLength(5) + expect(payloads[0]).toEqual({ id: 1 }) + expect(payloads[1]).toBe('a string payload') + expect(payloads[2]).toBeNull() + expect(payloads[3]).toBeUndefined() + expect(payloads[4]).toEqual([1, 2, 3]) + }) +}) diff --git a/src/__tests__/channel-watch.test.ts b/src/__tests__/channel-watch.test.ts new file mode 100644 index 0000000..1e8fd42 --- /dev/null +++ b/src/__tests__/channel-watch.test.ts @@ -0,0 +1,573 @@ +import { describe, expect, it, mock } from 'bun:test' +import { defineSchema } from '../schema.ts' +import type { SSEAdapter } from '../server/adapters/types.ts' + +/** + * Tests for channel.watch(adapter) — connects an SSEAdapter to a Channel, + * routing adapter-emitted events through channel.emit(). + * + * These tests verify that: + * 1. watch() accepts an SSEAdapter and calls adapter.start() with a bound emit callback + * 2. Events emitted via the adapter callback flow through channel.emit() to clients + * 3. watch() returns a cleanup function (() => Promise) that calls adapter.stop() + * 4. channel.close() stops all watched adapters + * 5. Multiple adapters can be watched simultaneously + * 6. watch() after channel.close() throws + * 7. Errors from adapter.start() propagate to the caller of watch() + * 8. Async adapter.start() causes watch() to return a Promise + * 9. The Channel interface has a watch method + * + * Tests FAIL initially because src/server/index.ts has not been updated yet. + */ + +// Minimal schema used across tests +const testSchema = defineSchema({ + 'user.updated': { key: '/api/users', update: 'set' }, + 'order.placed': { key: '/api/orders', update: 'refetch' }, +}) + +// Helper: collect SSE text chunks from a ReadableStream +async function _collectChunks( + stream: ReadableStream, + count: number, +): Promise { + const decoder = new TextDecoder() + const reader = stream.getReader() + const chunks: string[] = [] + for (let i = 0; i < count; i++) { + const { value, done } = await reader.read() + if (done) break + chunks.push(decoder.decode(value)) + } + reader.cancel() + return chunks +} + +// Helper: build a mock NodeResponse +function _makeMockRes() { + return { + writeHead: mock((_status: number, _headers: Record) => {}), + write: mock((_chunk: string) => {}), + end: mock(() => {}), + writableEnded: false, + on: mock((_event: string, _cb: () => void) => {}), + } +} + +describe('channel.watch(adapter)', () => { + describe('method existence', () => { + it('channel should have a watch method', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + expect(typeof channel.watch).toBe('function') + channel.close() + }) + }) + + describe('adapter.start() is called with an emit callback', () => { + it('watch() should call adapter.start() immediately', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const startMock = mock( + (_emit: (eventType: string, payload: unknown) => void) => {}, + ) + const adapter: SSEAdapter = { + start: startMock, + stop: () => {}, + } + + channel.watch(adapter) + channel.close() + + expect(startMock).toHaveBeenCalledTimes(1) + }) + + it('watch() should pass an emit callback to adapter.start()', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + let receivedEmit: + | ((eventType: string, payload: unknown) => void) + | undefined + + const adapter: SSEAdapter = { + start: (emit) => { + receivedEmit = emit + }, + stop: () => {}, + } + + channel.watch(adapter) + channel.close() + + expect(typeof receivedEmit).toBe('function') + }) + + it('emit callback passed to adapter.start() should call channel.emit()', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + // Connect a client to capture broadcast events + const response = channel.connect( + new Request('http://localhost/api/events'), + ) + const reader = response.body?.getReader() + const decoder = new TextDecoder() + + // Drain initial connected event + await reader.read() + + let adapterEmit: + | ((eventType: string, payload: unknown) => void) + | undefined + + const adapter: SSEAdapter = { + start: (emit) => { + adapterEmit = emit + }, + stop: () => {}, + } + + channel.watch(adapter) + + // Simulate the adapter detecting a data change and emitting + adapterEmit?.('user.updated', { id: 7, name: 'alice' }) + + const { value } = await reader.read() + const text = decoder.decode(value) + + reader.cancel() + channel.close() + + expect(text).toContain('event: user.updated') + expect(text).toContain('"id":7') + expect(text).toContain('"name":"alice"') + }) + + it('emit callback should broadcast to all connected clients', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const r1 = channel.connect(new Request('http://localhost/api/events')) + const r2 = channel.connect(new Request('http://localhost/api/events')) + const decoder = new TextDecoder() + const reader1 = r1.body?.getReader() + const reader2 = r2.body?.getReader() + + // Drain initial events + await reader1.read() + await reader2.read() + + let adapterEmit: + | ((eventType: string, payload: unknown) => void) + | undefined + + const adapter: SSEAdapter = { + start: (emit) => { + adapterEmit = emit + }, + stop: () => {}, + } + + channel.watch(adapter) + adapterEmit?.('order.placed', { orderId: '99' }) + + const [res1, res2] = await Promise.all([reader1.read(), reader2.read()]) + const text1 = decoder.decode(res1.value) + const text2 = decoder.decode(res2.value) + + reader1.cancel() + reader2.cancel() + channel.close() + + expect(text1).toContain('event: order.placed') + expect(text2).toContain('event: order.placed') + }) + }) + + describe('cleanup function returned by watch()', () => { + it('watch() should return a function', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const adapter: SSEAdapter = { + start: () => {}, + stop: () => {}, + } + + const cleanup = channel.watch(adapter) + channel.close() + + expect(typeof cleanup).toBe('function') + }) + + it('cleanup function should always return a Promise', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const adapter: SSEAdapter = { + start: () => {}, + stop: () => {}, // synchronous stop + } + + const cleanup = channel.watch(adapter) + const result = cleanup() + channel.close() + + expect(result).toBeInstanceOf(Promise) + }) + + it('cleanup function should call adapter.stop()', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const stopMock = mock(() => {}) + const adapter: SSEAdapter = { + start: () => {}, + stop: stopMock, + } + + const cleanup = channel.watch(adapter) + await cleanup() + channel.close() + + expect(stopMock).toHaveBeenCalledTimes(1) + }) + + it('cleanup function should await async adapter.stop()', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const order: string[] = [] + const adapter: SSEAdapter = { + start: () => {}, + stop: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + order.push('stopped') + }, + } + + const cleanup = channel.watch(adapter) + await cleanup() + order.push('after-cleanup') + channel.close() + + // 'stopped' must appear before 'after-cleanup' (stop was awaited) + expect(order).toEqual(['stopped', 'after-cleanup']) + }) + + it('calling cleanup should not affect other watched adapters', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const stop1 = mock(() => {}) + const stop2 = mock(() => {}) + + const adapter1: SSEAdapter = { start: () => {}, stop: stop1 } + const adapter2: SSEAdapter = { start: () => {}, stop: stop2 } + + const cleanup1 = channel.watch(adapter1) + channel.watch(adapter2) + + // Only clean up adapter1 + await cleanup1() + channel.close() + + expect(stop1).toHaveBeenCalledTimes(1) + // adapter2 is stopped by channel.close(), not by cleanup1 + }) + }) + + describe('multiple adapters simultaneously', () => { + it('should call start() on each adapter independently', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const start1 = mock((_emit: (e: string, p: unknown) => void) => {}) + const start2 = mock((_emit: (e: string, p: unknown) => void) => {}) + const start3 = mock((_emit: (e: string, p: unknown) => void) => {}) + + const adapter1: SSEAdapter = { start: start1, stop: () => {} } + const adapter2: SSEAdapter = { start: start2, stop: () => {} } + const adapter3: SSEAdapter = { start: start3, stop: () => {} } + + channel.watch(adapter1) + channel.watch(adapter2) + channel.watch(adapter3) + channel.close() + + expect(start1).toHaveBeenCalledTimes(1) + expect(start2).toHaveBeenCalledTimes(1) + expect(start3).toHaveBeenCalledTimes(1) + }) + + it('each adapter emit callback should route to the same channel broadcast pool', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const response = channel.connect( + new Request('http://localhost/api/events'), + ) + const reader = response.body?.getReader() + const decoder = new TextDecoder() + + // Drain connected event + await reader.read() + + let emit1: ((e: string, p: unknown) => void) | undefined + let emit2: ((e: string, p: unknown) => void) | undefined + + const adapter1: SSEAdapter = { + start: (e) => { + emit1 = e + }, + stop: () => {}, + } + const adapter2: SSEAdapter = { + start: (e) => { + emit2 = e + }, + stop: () => {}, + } + + channel.watch(adapter1) + channel.watch(adapter2) + + // Emit from adapter1 + emit1?.('user.updated', { source: 'adapter1' }) + const { value: v1 } = await reader.read() + const t1 = decoder.decode(v1) + + // Emit from adapter2 + emit2?.('order.placed', { source: 'adapter2' }) + const { value: v2 } = await reader.read() + const t2 = decoder.decode(v2) + + reader.cancel() + channel.close() + + expect(t1).toContain('event: user.updated') + expect(t1).toContain('"source":"adapter1"') + expect(t2).toContain('event: order.placed') + expect(t2).toContain('"source":"adapter2"') + }) + }) + + describe('channel.close() stops all watched adapters', () => { + it('channel.close() should call stop() on all watched adapters', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const stop1 = mock(() => {}) + const stop2 = mock(() => {}) + const stop3 = mock(() => {}) + + channel.watch({ start: () => {}, stop: stop1 }) + channel.watch({ start: () => {}, stop: stop2 }) + channel.watch({ start: () => {}, stop: stop3 }) + + channel.close() + + expect(stop1).toHaveBeenCalledTimes(1) + expect(stop2).toHaveBeenCalledTimes(1) + expect(stop3).toHaveBeenCalledTimes(1) + }) + + it('channel.close() should await async stop() on all watched adapters', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const stopOrder: string[] = [] + + const makeAsyncAdapter = (name: string): SSEAdapter => ({ + start: () => {}, + stop: async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + stopOrder.push(name) + }, + }) + + channel.watch(makeAsyncAdapter('a1')) + channel.watch(makeAsyncAdapter('a2')) + + await channel.close() + + // Both adapters must have stopped before close() resolves + expect(stopOrder).toContain('a1') + expect(stopOrder).toContain('a2') + }) + + it('after channel.close(), adapter emit callbacks should be no-ops', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + let adapterEmit: ((e: string, p: unknown) => void) | undefined + + channel.watch({ + start: (emit) => { + adapterEmit = emit + }, + stop: () => {}, + }) + + channel.close() + + // Emitting after close should not throw + expect(() => { + adapterEmit?.('user.updated', { id: 1 }) + }).not.toThrow() + }) + + it('adapter already cleaned up via cleanup fn should not be double-stopped by channel.close()', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const stopMock = mock(() => {}) + const adapter: SSEAdapter = { start: () => {}, stop: stopMock } + + const cleanup = channel.watch(adapter) + await cleanup() // Manually clean up first + + channel.close() // Should not call stop() again + + expect(stopMock).toHaveBeenCalledTimes(1) + }) + }) + + describe('watch() after channel.close() throws', () => { + it('should throw when calling watch() on a closed channel', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + channel.close() + + const adapter: SSEAdapter = { + start: () => {}, + stop: () => {}, + } + + expect(() => { + channel.watch(adapter) + }).toThrow() + }) + + it('error message should indicate channel is closed', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + channel.close() + + try { + channel.watch({ start: () => {}, stop: () => {} }) + expect(true).toBe(false) // should not reach here + } catch (err) { + expect(err).toBeInstanceOf(Error) + } + }) + }) + + describe('adapter.start() error propagation', () => { + it('synchronous error from adapter.start() should propagate from watch()', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const adapter: SSEAdapter = { + start: () => { + throw new Error('adapter start failed') + }, + stop: () => {}, + } + + expect(() => { + channel.watch(adapter) + }).toThrow('adapter start failed') + + channel.close() + }) + + it('async error from adapter.start() should cause watch() to return a rejected Promise', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const adapter: SSEAdapter = { + start: async () => { + await Promise.resolve() + throw new Error('async adapter start failed') + }, + stop: () => {}, + } + + // watch() should return a Promise that rejects + const result = channel.watch(adapter) + expect(result).toBeInstanceOf(Promise) + + await expect(result as Promise).rejects.toThrow( + 'async adapter start failed', + ) + + channel.close() + }) + }) + + describe('async adapter.start() causes watch() to return a Promise', () => { + it('watch() should return a Promise when adapter.start() is async', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const adapter: SSEAdapter = { + start: async (_emit) => { + await Promise.resolve() + // Async start completes + }, + stop: () => {}, + } + + const result = channel.watch(adapter) + expect(result).toBeInstanceOf(Promise) + + await result + channel.close() + }) + + it('watch() result Promise should resolve when async adapter.start() completes', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const startOrder: string[] = [] + + const adapter: SSEAdapter = { + start: async (_emit) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + startOrder.push('started') + }, + stop: () => {}, + } + + await channel.watch(adapter) + startOrder.push('watch-resolved') + channel.close() + + expect(startOrder).toEqual(['started', 'watch-resolved']) + }) + + it('watch() with sync adapter.start() can return void or a resolved Promise', async () => { + const { createChannel } = await import('../server/index.ts') + const channel = createChannel(testSchema) + + const adapter: SSEAdapter = { + start: (_emit) => { + // synchronous — returns void + }, + stop: () => {}, + } + + // Should not throw regardless of whether it returns void or Promise + expect(() => { + channel.watch(adapter) + }).not.toThrow() + + channel.close() + }) + }) +}) diff --git a/src/__tests__/schema-resources.test.ts b/src/__tests__/schema-resources.test.ts new file mode 100644 index 0000000..19f304f --- /dev/null +++ b/src/__tests__/schema-resources.test.ts @@ -0,0 +1,577 @@ +import { afterEach, describe, expect, it, spyOn } from 'bun:test' + +/** + * Tests for the enhanced defineSchema() `resources` field. + * + * These tests verify that defineSchema(): + * 1. Accepts an optional `resources` field alongside existing top-level event keys + * 2. Expands each resource key into .created, .updated, .deleted + * 3. Supports custom key derivation per operation (string, string[], or function) + * 4. Merges expanded resource events with explicitly defined events + * 5. Explicit event definitions take precedence over generated resource events + * 6. Empty resources field ({}) is a no-op + * 7. Returns a frozen SchemaResult that reflects all expanded events + * 8. Backward compatible — existing behavior unchanged when resources is omitted + * + * Tests FAIL initially because src/schema.ts and src/types.ts have not been updated yet. + */ + +describe('defineSchema() - resources field', () => { + describe('basic resource expansion', () => { + it('should expand a single resource into .created, .updated, .deleted events', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + }) as Record + + expect(Object.keys(schema)).toContain('orders.created') + expect(Object.keys(schema)).toContain('orders.updated') + expect(Object.keys(schema)).toContain('orders.deleted') + }) + + it('should expand multiple resources independently', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + users: {}, + products: {}, + }, + }) as Record + + // orders + expect(Object.keys(schema)).toContain('orders.created') + expect(Object.keys(schema)).toContain('orders.updated') + expect(Object.keys(schema)).toContain('orders.deleted') + + // users + expect(Object.keys(schema)).toContain('users.created') + expect(Object.keys(schema)).toContain('users.updated') + expect(Object.keys(schema)).toContain('users.deleted') + + // products + expect(Object.keys(schema)).toContain('products.created') + expect(Object.keys(schema)).toContain('products.updated') + expect(Object.keys(schema)).toContain('products.deleted') + }) + + it('should produce exactly 3 events per resource (no extras)', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + }) as Record + + const orderKeys = Object.keys(schema).filter((k) => + k.startsWith('orders.'), + ) + expect(orderKeys).toHaveLength(3) + expect(orderKeys.sort()).toEqual([ + 'orders.created', + 'orders.deleted', + 'orders.updated', + ]) + }) + + it('should default update to "set" for all generated resource events', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + }) as Record + + expect(schema['orders.created']?.update).toBe('set') + expect(schema['orders.updated']?.update).toBe('set') + expect(schema['orders.deleted']?.update).toBe('set') + }) + + it('should generate a default key for each expanded event based on resource name', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + }) as Record + + // Each expanded event must have a key — exact format is implementation-defined + // but must be a non-empty string or function + const createdKey = schema['orders.created']?.key + const updatedKey = schema['orders.updated']?.key + const deletedKey = schema['orders.deleted']?.key + + expect(createdKey).toBeDefined() + expect(updatedKey).toBeDefined() + expect(deletedKey).toBeDefined() + }) + }) + + describe('custom key per operation', () => { + it('should accept a custom key for the created operation', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: { + created: { key: '/api/orders/new' }, + }, + }, + }) as Record + + expect(schema['orders.created']?.key).toBe('/api/orders/new') + }) + + it('should accept a custom key for the updated operation', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: { + updated: { key: (p: { id: string }) => `/api/orders/${p.id}` }, + }, + }, + }) as Record + + expect(typeof schema['orders.updated']?.key).toBe('function') + const keyFn = schema['orders.updated']?.key as (p: { + id: string + }) => string + expect(keyFn({ id: '42' })).toBe('/api/orders/42') + }) + + it('should accept a custom key for the deleted operation', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: { + deleted: { key: ['/api/orders', '/api/cache/orders'] }, + }, + }, + }) as Record + + expect(schema['orders.deleted']?.key).toEqual([ + '/api/orders', + '/api/cache/orders', + ]) + }) + + it('should accept custom keys for all three operations independently', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: { + created: { key: '/api/orders' }, + updated: { key: (p: { id: string }) => `/api/orders/${p.id}` }, + deleted: { key: ['/api/orders', '/api/orders/list'] }, + }, + }, + }) as Record + + expect(schema['orders.created']?.key).toBe('/api/orders') + expect(typeof schema['orders.updated']?.key).toBe('function') + expect(schema['orders.deleted']?.key).toEqual([ + '/api/orders', + '/api/orders/list', + ]) + }) + }) + + describe('custom update strategy per operation', () => { + it('should accept a custom update strategy for the created operation', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: { + created: { key: '/api/orders', update: 'refetch' }, + }, + }, + }) as Record + + expect(schema['orders.created']?.update).toBe('refetch') + }) + + it('should accept a custom merge function for the updated operation', async () => { + const { defineSchema } = await import('../index.ts') + + const mergeFn = ( + current: Array<{ id: string }> | undefined, + payload: { id: string }, + ): Array<{ id: string }> => { + const list = current ?? [] + return list.map((item) => (item.id === payload.id ? payload : item)) + } + + const schema = defineSchema({ + resources: { + orders: { + updated: { key: '/api/orders', update: mergeFn }, + }, + }, + }) as Record + + expect(typeof schema['orders.updated']?.update).toBe('function') + }) + }) + + describe('merging resources with explicit events', () => { + it('should include both resource-expanded events and explicit top-level events', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + 'user.updated': { key: '/api/users' }, + }) as Record + + // Resource events + expect(Object.keys(schema)).toContain('orders.created') + expect(Object.keys(schema)).toContain('orders.updated') + expect(Object.keys(schema)).toContain('orders.deleted') + + // Explicit events + expect(Object.keys(schema)).toContain('user.updated') + }) + + it('should have exactly the right number of events when mixing resources and explicit events', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + 'notification.sent': { key: '/api/notifications' }, + 'session.expired': { key: '/api/sessions' }, + }) as Record + + // 3 from orders resource + 2 explicit = 5 total + expect(Object.keys(schema)).toHaveLength(5) + }) + + it('should preserve explicit event definitions alongside resource events', async () => { + const { defineSchema } = await import('../index.ts') + + const filterFn = (payload: { active: boolean }) => payload.active + + const schema = defineSchema({ + resources: { + products: {}, + }, + 'cart.updated': { + key: (p: { userId: string }) => `/api/cart/${p.userId}`, + update: 'refetch', + filter: filterFn, + }, + }) as Record + + expect(schema['cart.updated']?.update).toBe('refetch') + expect(typeof schema['cart.updated']?.filter).toBe('function') + }) + }) + + describe('explicit events take precedence over generated resource events', () => { + it('should use explicit event definition when it conflicts with a generated resource event', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + // Explicit override of the generated orders.created event + 'orders.created': { + key: '/api/orders/explicit', + update: 'refetch', + }, + }) as Record + + // Explicit definition wins + expect(schema['orders.created']?.key).toBe('/api/orders/explicit') + expect(schema['orders.created']?.update).toBe('refetch') + }) + + it('should only override the conflicting event, leaving other resource events intact', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + 'orders.deleted': { key: '/api/orders/trash' }, + }) as Record + + // The explicit override applies only to orders.deleted + expect(schema['orders.deleted']?.key).toBe('/api/orders/trash') + + // orders.created and orders.updated come from the resource expansion + expect(schema['orders.created']).toBeDefined() + expect(schema['orders.updated']).toBeDefined() + }) + }) + + describe('collision warning — explicit event overrides resource-generated event', () => { + afterEach(() => { + // Restore console.warn after each test in this describe block + }) + + it('should log a console.warn when an explicit event collides with a resource-generated event', async () => { + const { defineSchema } = await import('../index.ts') + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) + + defineSchema({ + resources: { + orders: {}, + }, + 'orders.created': { + key: '/api/orders/explicit', + update: 'refetch' as const, + }, + }) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0]?.[0]).toContain('orders.created') + warnSpy.mockRestore() + }) + + it('should warn once per colliding key (not for non-colliding explicit events)', async () => { + const { defineSchema } = await import('../index.ts') + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) + + defineSchema({ + resources: { + orders: {}, + }, + // This collides with resource-generated orders.deleted + 'orders.deleted': { key: '/api/orders/trash' }, + // This does NOT collide — it is a new explicit event + 'notification.sent': { key: '/api/notifications' }, + }) + + // Only orders.deleted causes a warning, not notification.sent + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0]?.[0]).toContain('orders.deleted') + warnSpy.mockRestore() + }) + + it('should NOT warn when an explicit event does not collide with any resource-generated event', async () => { + const { defineSchema } = await import('../index.ts') + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) + + defineSchema({ + resources: { + orders: {}, + }, + 'user.updated': { key: '/api/users' }, + }) + + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('should warn for each colliding key when multiple collisions occur', async () => { + const { defineSchema } = await import('../index.ts') + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) + + defineSchema({ + resources: { + orders: {}, + }, + 'orders.created': { key: '/api/orders/c' }, + 'orders.updated': { key: '/api/orders/u' }, + }) + + expect(warnSpy).toHaveBeenCalledTimes(2) + warnSpy.mockRestore() + }) + }) + + describe('empty resources field', () => { + it('should treat empty resources ({}) as a no-op', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: {}, + }) as Record + + expect(Object.keys(schema)).toHaveLength(0) + }) + + it('should still include explicit events when resources is empty', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: {}, + 'user.updated': { key: '/api/users' }, + }) as Record + + expect(Object.keys(schema)).toHaveLength(1) + expect(Object.keys(schema)).toContain('user.updated') + }) + }) + + describe('frozen SchemaResult', () => { + it('should return a frozen object when using resources', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + }) + + expect(Object.isFrozen(schema)).toBe(true) + }) + + it('should not be modifiable after creation when using resources', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + }) as Record + + expect(() => { + schema['orders.created'] = 'overwritten' + }).toThrow() + }) + }) + + describe('backward compatibility', () => { + it('should work exactly as before when resources is not provided', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + 'user.updated': { key: '/api/users', update: 'set' }, + 'order.placed': { + key: (p: { orderId: string }) => `/api/orders/${p.orderId}`, + update: 'refetch', + }, + }) as Record + + expect(Object.keys(schema)).toHaveLength(2) + expect(schema['user.updated']?.key).toBe('/api/users') + expect(schema['user.updated']?.update).toBe('set') + expect(typeof schema['order.placed']?.key).toBe('function') + expect(schema['order.placed']?.update).toBe('refetch') + }) + + it('should still default update to "set" for explicit events without resources', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + 'item.deleted': { key: '/api/items' }, + }) as Record + + expect(schema['item.deleted']?.update).toBe('set') + }) + + it('should still return a frozen object when resources is not provided', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + 'session.ended': { key: '/api/sessions' }, + }) + + expect(Object.isFrozen(schema)).toBe(true) + }) + + it('should handle defineSchema({}) with no events and no resources', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({}) + + expect(Object.isFrozen(schema)).toBe(true) + expect(Object.keys(schema)).toHaveLength(0) + }) + }) + + describe('filter and transform per resource operation', () => { + it('should accept an optional filter for the created operation', async () => { + const { defineSchema } = await import('../index.ts') + + const filterFn = (payload: { status: string }) => + payload.status === 'active' + + const schema = defineSchema({ + resources: { + orders: { + created: { + key: '/api/orders', + filter: filterFn, + }, + }, + }, + }) as Record + + expect(typeof schema['orders.created']?.filter).toBe('function') + const fn = schema['orders.created']?.filter as (p: { + status: string + }) => boolean + expect(fn({ status: 'active' })).toBe(true) + expect(fn({ status: 'inactive' })).toBe(false) + }) + + it('should accept an optional transform for the updated operation', async () => { + const { defineSchema } = await import('../index.ts') + + const transformFn = (payload: { total: number }) => ({ + ...payload, + total: Math.round(payload.total), + }) + + const schema = defineSchema({ + resources: { + orders: { + updated: { + key: '/api/orders', + transform: transformFn, + }, + }, + }, + }) as Record + + expect(typeof schema['orders.updated']?.transform).toBe('function') + const fn = schema['orders.updated']?.transform as (p: { + total: number + }) => { total: number } + expect(fn({ total: 9.7 })).toEqual({ total: 10 }) + }) + + it('should allow filter and transform to be omitted on resource operations', async () => { + const { defineSchema } = await import('../index.ts') + + const schema = defineSchema({ + resources: { + orders: {}, + }, + }) as Record + + expect(schema['orders.created']?.filter).toBeUndefined() + expect(schema['orders.created']?.transform).toBeUndefined() + expect(schema['orders.updated']?.filter).toBeUndefined() + expect(schema['orders.updated']?.transform).toBeUndefined() + expect(schema['orders.deleted']?.filter).toBeUndefined() + expect(schema['orders.deleted']?.transform).toBeUndefined() + }) + }) + + describe('schema exported from index', () => { + it('should still export defineSchema from src/index.ts', async () => { + const exports = await import('../index.ts') + expect(typeof (exports as Record).defineSchema).toBe( + 'function', + ) + }) + }) +}) diff --git a/src/schema.ts b/src/schema.ts index fd04fbb..a70d812 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,4 +1,33 @@ -import type { SchemaDefinition, SchemaResult } from './types.ts' +import type { + ResourceDefinition, + SchemaDefinition, + SchemaEventDefinition, + SchemaResult, +} from './types.ts' + +const RESOURCE_OPS = ['created', 'updated', 'deleted'] as const +type ResourceOp = (typeof RESOURCE_OPS)[number] + +function expandResource( + resourceName: string, + resourceDef: ResourceDefinition, +): Record { + const expanded: Record = {} + + for (const op of RESOURCE_OPS) { + const eventName = `${resourceName}.${op}` + const opDef = resourceDef[op as ResourceOp] + + expanded[eventName] = { + key: opDef?.key ?? resourceName, + ...(opDef?.update !== undefined ? { update: opDef.update } : {}), + ...(opDef?.filter !== undefined ? { filter: opDef.filter } : {}), + ...(opDef?.transform !== undefined ? { transform: opDef.transform } : {}), + } + } + + return expanded +} /** * Define a shared, frozen schema object consumed by both createChannel() (server) @@ -7,12 +36,26 @@ import type { SchemaDefinition, SchemaResult } from './types.ts' * Event names are preserved as string literal keys for full TypeScript inference * and autocomplete. The `update` property defaults to `'set'` when not specified. * + * An optional `resources` field auto-expands each resource into .created, .updated, + * and .deleted event definitions. Explicit event definitions take precedence over + * generated resource events. + * * @example * ```ts * const schema = defineSchema({ * 'user.updated': { key: '/api/users', update: 'set' }, * 'order.placed': { key: (p: { id: string }) => `/api/orders/${p.id}` }, * }) + * + * // With resources: + * const schema = defineSchema({ + * resources: { + * orders: { + * updated: { key: (p: { id: string }) => `/api/orders/${p.id}` }, + * }, + * }, + * 'notification.sent': { key: '/api/notifications' }, + * }) * ``` */ export function defineSchema( @@ -20,8 +63,43 @@ export function defineSchema( ): SchemaResult { const result: Record = {} - for (const eventName of Object.keys(definition)) { - const def = definition[eventName] + // First, expand resources into event triplets + const { resources, ...explicitEvents } = definition as { + resources?: Record + } & Record + + if (resources) { + for (const resourceName of Object.keys(resources)) { + // Guard: empty resource names would generate malformed event keys like ".created" + if (resourceName.trim() === '') { + throw new Error( + `defineSchema: resource name must be a non-empty string, got ${JSON.stringify(resourceName)}`, + ) + } + const expanded = expandResource( + resourceName, + resources[resourceName] ?? {}, + ) + for (const [eventName, eventDef] of Object.entries(expanded)) { + result[eventName] = { + ...eventDef, + update: eventDef.update ?? 'set', + } + } + } + } + + // Then apply explicit events, which take precedence over any resource-generated events. + // This is intentional: if the same key exists in both resources (expanded) and explicit events, + // the explicit definition always wins. A warning is logged when a collision occurs. + for (const eventName of Object.keys(explicitEvents)) { + if (eventName in result) { + console.warn( + `defineSchema: explicit event "${eventName}" overrides a resource-generated event. ` + + 'The explicit definition will be used.', + ) + } + const def = explicitEvents[eventName] result[eventName] = { ...def, update: def?.update ?? 'set', diff --git a/src/server/adapters/emitter.ts b/src/server/adapters/emitter.ts new file mode 100644 index 0000000..96992c8 --- /dev/null +++ b/src/server/adapters/emitter.ts @@ -0,0 +1,57 @@ +import type { SSEAdapter } from './types.ts' + +type EventListener = (...args: unknown[]) => void | Promise + +interface OnOffEmitter { + on(event: string, listener: EventListener): void + off(event: string, listener: EventListener): void +} + +type EmitterAdapterMapping = { + [emitterEvent: string]: string +} + +/** + * Create an adapter that bridges any on/off-compatible event emitter to SSE events. + * + * Does NOT require Node.js EventEmitter — works with any object that has + * on(event, listener) and off(event, listener) methods. + */ +export function createEmitterAdapter( + emitter: OnOffEmitter, + mapping: EmitterAdapterMapping, +): SSEAdapter { + const handlers = new Map() + let started = false + + return { + start(emit: (eventType: string, payload: unknown) => void): void { + if (started) return + + for (const [emitterEvent, schemaEvent] of Object.entries(mapping)) { + const handler: EventListener = (...args: unknown[]) => { + try { + emit(schemaEvent, args[0]) + } catch { + // emit() errors must not propagate through the emitter's event dispatch + } + } + handlers.set(emitterEvent, handler) + emitter.on(emitterEvent, handler) + } + started = true + }, + + stop(): void { + for (const [emitterEvent, handler] of handlers) { + try { + emitter.off(emitterEvent, handler) + } catch { + // best-effort cleanup + } + } + handlers.clear() + started = false + }, + } +} diff --git a/src/server/adapters/index.ts b/src/server/adapters/index.ts new file mode 100644 index 0000000..90bab91 --- /dev/null +++ b/src/server/adapters/index.ts @@ -0,0 +1,8 @@ +// Barrel file for SSE adapters +// Re-exports all adapter factories and shared types + +export { createEmitterAdapter } from './emitter.ts' +export { createMongoAdapter } from './mongodb.ts' +export { createPgAdapter } from './pg.ts' +export { createPrismaAdapter } from './prisma.ts' +export type { AdapterMapping, SSEAdapter } from './types.ts' diff --git a/src/server/adapters/mongodb.ts b/src/server/adapters/mongodb.ts new file mode 100644 index 0000000..92cfaa3 --- /dev/null +++ b/src/server/adapters/mongodb.ts @@ -0,0 +1,126 @@ +import type { SSEAdapter } from './types.ts' + +interface ChangeEvent { + _id: unknown + operationType: string + fullDocument?: Record + documentKey?: { _id: unknown } + updateDescription?: { updatedFields: Record } + ns?: { db: string; coll: string } +} + +interface ChangeStreamCursor { + [Symbol.asyncIterator](): AsyncIterator + close(): Promise +} + +interface MongoCollection { + // biome-ignore lint/suspicious/noExplicitAny: watch options are driver-specific + watch(options?: Record): ChangeStreamCursor +} + +type MongoAdapterMapping = { + [operationType: string]: string +} + +/** + * Create a MongoDB Change Stream adapter that watches a collection and emits + * SSE events for insert/update/replace/delete operations. + * + * Does NOT import mongodb — accepts the collection instance as a parameter. + * Handles invalidate events by reopening the stream with a resume token. + */ +export function createMongoAdapter( + collection: MongoCollection, + mapping: MongoAdapterMapping, +): SSEAdapter { + let stopped = false + let started = false + let currentCursor: ChangeStreamCursor | undefined + let resumeToken: unknown + + const MAX_RECONNECT_ATTEMPTS = 5 + let reconnectAttempts = 0 + + async function runStream( + emit: (eventType: string, payload: unknown) => void, + ): Promise { + // biome-ignore lint/suspicious/noExplicitAny: resume token shape is driver-specific + const options: Record = {} + if (resumeToken !== undefined) { + options.resumeAfter = resumeToken + } + + const cursor = collection.watch(options) + currentCursor = cursor + + try { + for await (const event of cursor) { + if (stopped) break + if (!event) continue + + // Handle invalidate — close current cursor and reopen + // (do NOT update resume token for invalidate events) + if (event.operationType === 'invalidate') { + try { + await cursor.close() + } catch { + /* ignore */ + } + currentCursor = undefined + if (!stopped && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++ + // Reopen with the last resume token + await runStream(emit) + } + return + } + + // Track resume token from regular (non-invalidate) events + if (event._id !== undefined) { + resumeToken = event._id + } + + // Emit mapped events + const eventType = mapping[event.operationType] + if (eventType) { + // Payload: fullDocument for insert/update/replace, documentKey for delete + const payload = event.fullDocument ?? event.documentKey ?? event + try { + emit(eventType, payload) + } catch { + // emit() errors must not break the stream iteration + } + } + } + } catch { + // Stream closed or errored — stop iteration + } + } + + return { + async start( + emit: (eventType: string, payload: unknown) => void, + ): Promise { + if (started) return + + stopped = false + started = true + reconnectAttempts = 0 + await runStream(emit) + }, + + async stop(): Promise { + stopped = true + started = false + if (currentCursor) { + try { + await currentCursor.close() + } catch { + /* ignore */ + } + currentCursor = undefined + } + }, + } +} diff --git a/src/server/adapters/pg.ts b/src/server/adapters/pg.ts new file mode 100644 index 0000000..5fddfc2 --- /dev/null +++ b/src/server/adapters/pg.ts @@ -0,0 +1,111 @@ +import type { SSEAdapter } from './types.ts' + +interface PgNotification { + channel: string + payload?: string + processId?: number +} + +interface PgClient { + query(sql: string): Promise + on(event: string, listener: (notification: PgNotification) => void): void + off?(event: string, listener: (notification: PgNotification) => void): void + removeListener?( + event: string, + listener: (notification: PgNotification) => void, + ): void +} + +type PgAdapterMapping = { + [channelName: string]: string +} + +/** + * Create a PostgreSQL LISTEN/NOTIFY adapter that listens on pg channels and + * emits SSE events when NOTIFY messages arrive. + * + * Does NOT import pg — accepts the client instance as a parameter. + * Calls UNLISTEN and removes event listeners on stop(). + */ +export function createPgAdapter( + client: PgClient, + mapping: PgAdapterMapping, +): SSEAdapter { + let notificationListener: ((notification: PgNotification) => void) | undefined + let started = false + const channels = Object.keys(mapping) + + /** + * Quote a PostgreSQL identifier to prevent SQL injection and reserved keyword + * collisions. Always uses double-quote escaping per PostgreSQL rules — this is + * safe for all identifiers and avoids issues with reserved keywords like + * "select", "table", "index" that would be syntactically invalid unquoted. + */ + function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, '""')}"` + } + + function removeListener(): void { + if (!notificationListener) return + const fn = notificationListener + notificationListener = undefined + if (typeof client.off === 'function') { + client.off('notification', fn) + } else if (typeof client.removeListener === 'function') { + client.removeListener('notification', fn) + } else { + console.warn( + 'PostgreSQL client does not support off() or removeListener() — notification handler may leak', + ) + } + } + + return { + async start( + emit: (eventType: string, payload: unknown) => void, + ): Promise { + if (started) return + + // Issue LISTEN for each mapped channel with proper identifier quoting + await Promise.all( + channels.map((ch) => client.query(`LISTEN ${quoteIdentifier(ch)}`)), + ) + + // Only mark started after LISTEN queries succeed + started = true + + // Register a single notification listener + notificationListener = (notification: PgNotification) => { + const eventType = mapping[notification.channel] + if (!eventType) return + + // Parse JSON payload; fall back gracefully for empty/malformed payloads + let parsed: unknown + if (notification.payload) { + try { + parsed = JSON.parse(notification.payload) + } catch { + // Malformed JSON — emit undefined payload rather than throwing + } + } + + try { + emit(eventType, parsed) + } catch { + // emit() errors must not propagate as uncaught exceptions + } + } + + client.on('notification', notificationListener) + }, + + async stop(): Promise { + removeListener() + started = false + // Issue UNLISTEN for each channel with proper identifier quoting + await Promise.all( + channels.map((ch) => client.query(`UNLISTEN ${quoteIdentifier(ch)}`)), + ) + }, + } +} diff --git a/src/server/adapters/prisma.ts b/src/server/adapters/prisma.ts new file mode 100644 index 0000000..67dc9f7 --- /dev/null +++ b/src/server/adapters/prisma.ts @@ -0,0 +1,100 @@ +import type { SSEAdapter } from './types.ts' + +interface PrismaMiddlewareParams { + model?: string + action: string + args: unknown + dataPath: string[] + runInTransaction: boolean +} + +type PrismaMiddlewareFn = ( + params: PrismaMiddlewareParams, + next: (params: PrismaMiddlewareParams) => Promise, +) => Promise + +interface PrismaClient { + $use(middleware: PrismaMiddlewareFn): void +} + +type PrismaAdapterMapping = { + [modelName: string]: { + created?: string + updated?: string + deleted?: string + } +} + +const ACTION_TO_OP: Record = { + create: 'created', + createMany: 'created', + update: 'updated', + updateMany: 'updated', + delete: 'deleted', + deleteMany: 'deleted', +} + +/** + * Create a Prisma middleware adapter that intercepts create/update/delete operations + * and emits SSE events after each operation completes. + * + * Does NOT import @prisma/client — accepts the client instance as a parameter. + */ +export function createPrismaAdapter( + prisma: PrismaClient, + mapping: PrismaAdapterMapping, +): SSEAdapter { + let active = false + let started = false + let emitFn: ((eventType: string, payload: unknown) => void) | undefined + + return { + start(emit: (eventType: string, payload: unknown) => void): void { + if (started) return + + emitFn = emit + active = true + started = true + + try { + prisma.$use(async (params, next) => { + const result = await next(params) + + if (active && emitFn && params.model) { + const modelMapping = mapping[params.model] + if (modelMapping) { + const op = ACTION_TO_OP[params.action] + if (op) { + const eventType = modelMapping[op] + if (eventType) { + try { + emitFn(eventType, result) + } catch { + // emit() errors must not propagate to the Prisma caller + } + } + } + } + } + + return result + }) + } catch (err) { + // If $use() throws, reset state so callers know the adapter failed to start + active = false + started = false + emitFn = undefined + throw err + } + }, + + stop(): void { + active = false + emitFn = undefined + // Note: `started` is intentionally NOT reset. Prisma's $use() permanently + // registers middleware — there is no $removeUse(). Resetting `started` would + // cause a second $use() call on restart, stacking duplicate middleware. + // To restart emission, create a new adapter instance. + }, + } +} diff --git a/src/server/adapters/types.ts b/src/server/adapters/types.ts new file mode 100644 index 0000000..8622e74 --- /dev/null +++ b/src/server/adapters/types.ts @@ -0,0 +1,39 @@ +/** + * Interface that all SSE adapters must implement. + * Provides a standard contract for database/event source adapters + * with start/stop lifecycle methods. + */ +export interface SSEAdapter { + /** + * Start the adapter and begin emitting events. + * @param emit - Callback to emit events to connected SSE clients + */ + start( + emit: (eventType: string, payload: unknown) => void, + ): void | Promise + + /** + * Stop the adapter and clean up resources. + */ + stop(): void | Promise +} + +/** + * Extracts only the keys of S whose value is not `never`. + * This filters out utility/config keys (like `resources`) that SchemaResult maps to `never`. + */ +type EventKeysOf = { + [K in keyof S]: S[K] extends never ? never : K +}[keyof S] + +/** + * Maps source event names (adapter-specific strings) to schema event type keys. + * Constrained so TypeScript verifies that mapped event types exist in the schema, + * excluding any keys resolved to `never` (e.g. internal config keys like `resources`). + * + * @typeParam S - A SchemaResult type (returned by defineSchema()) + */ +// biome-ignore lint/suspicious/noExplicitAny: SchemaResult generic requires any for broad compatibility +export type AdapterMapping> = { + [sourceEvent: string]: EventKeysOf +} diff --git a/src/server/index.ts b/src/server/index.ts index 319c24b..7d917dd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,12 @@ // Server-side utilities for reactiveSWR import { formatSSEEvent } from '../sseParser' +import type { SSEAdapter } from './adapters/types.ts' + +export { createEmitterAdapter } from './adapters/emitter.ts' +export { createMongoAdapter } from './adapters/mongodb.ts' +export { createPgAdapter } from './adapters/pg.ts' +export { createPrismaAdapter } from './adapters/prisma.ts' +export type { AdapterMapping, SSEAdapter } from './adapters/types.ts' // Capture built-in timer functions at module load time so that test patches to // globalThis.setInterval cannot cause infinite recursion inside createChannel. @@ -34,7 +41,16 @@ interface Channel { /** Node.js: writes SSE headers to `res` and returns a `ScopedEmitter` */ respond(req: NodeRequest, res: NodeResponse): ScopedEmitter emit(type: string, payload: unknown): void - close(): void + /** + * Connect an SSEAdapter to this channel. The adapter's emitted events are + * broadcast to all connected clients via channel.emit(). + * Returns a cleanup function (always async) when adapter.start() is sync, + * or a Promise that resolves to a cleanup function when adapter.start() is async. + */ + watch( + adapter: SSEAdapter, + ): (() => Promise) | Promise<() => Promise> + close(): void | Promise isClosed(): boolean } @@ -163,6 +179,8 @@ export function createChannel( ): Channel { const heartbeatMs = options.heartbeatInterval ?? 30000 const broadcastPool = new Set() + const watchedAdapters = new Set() + const stoppedAdapters = new Set() let closed = false let heartbeatTimer: ReturnType | undefined @@ -185,7 +203,7 @@ export function createChannel( } function connectWeb(request: Request): Response { - if (closed) throw new Error('Channel is closed') + if (closed) throw new Error('Cannot connect: channel is closed') // Suppress unused param lint — request is part of the public API signature void request @@ -225,8 +243,9 @@ export function createChannel( } function connectNode(req: NodeRequest, res: NodeResponse): void { - if (closed) throw new Error('Channel is closed') - if (res.writableEnded) throw new Error('ServerResponse is already ended') + if (closed) throw new Error('Cannot connect: channel is closed') + if (res.writableEnded) + throw new Error('Cannot connect: ServerResponse is already ended') res.writeHead(200, SSE_HEADERS) @@ -247,7 +266,7 @@ export function createChannel( } function respondWeb(request: Request): WebRespondResult { - if (closed) throw new Error('Channel is closed') + if (closed) throw new Error('Cannot respond: channel is closed') // Suppress unused param lint — request is part of the public API signature void request @@ -291,8 +310,9 @@ export function createChannel( } function respondNode(req: NodeRequest, res: NodeResponse): ScopedEmitter { - if (closed) throw new Error('Channel is closed') - if (res.writableEnded) throw new Error('ServerResponse is already ended') + if (closed) throw new Error('Cannot respond: channel is closed') + if (res.writableEnded) + throw new Error('Cannot respond: ServerResponse is already ended') // Suppress unused param lint — req is part of the public API signature void req @@ -322,6 +342,23 @@ export function createChannel( return emitter } + function broadcastEmit(type: string, payload: unknown): void { + if (broadcastPool.size === 0) return + + const chunk = formatSSEEvent(type, payload) + const dead: Client[] = [] + + for (const client of broadcastPool) { + const ok = writeToClient(client, chunk) + if (!ok) dead.push(client) + } + + for (const client of dead) { + broadcastPool.delete(client) + } + if (dead.length > 0 && broadcastPool.size === 0) stopHeartbeat() + } + return { connect( reqOrRequest: Request | NodeRequest, @@ -345,31 +382,68 @@ export function createChannel( }, emit(type: string, payload: unknown): void { - if (broadcastPool.size === 0) return + broadcastEmit(type, payload) + }, - const chunk = formatSSEEvent(type, payload) - const dead: Client[] = [] + watch( + adapter: SSEAdapter, + ): (() => Promise) | Promise<() => Promise> { + if (closed) throw new Error('Channel is closed') - for (const client of broadcastPool) { - const ok = writeToClient(client, chunk) - if (!ok) dead.push(client) + // Clear any previous stopped state so re-watching the same adapter works + stoppedAdapters.delete(adapter) + + // Bug 3 fix: idempotent cleanup — only stop once regardless of who calls it + const cleanup = async (): Promise => { + if (stoppedAdapters.has(adapter)) return + stoppedAdapters.add(adapter) + watchedAdapters.delete(adapter) + await adapter.stop() } - for (const client of dead) { - broadcastPool.delete(client) + let startResult: void | Promise + startResult = adapter.start(broadcastEmit) + + if (startResult instanceof Promise) { + // For async start: add to tracked set optimistically, remove if start rejects + watchedAdapters.add(adapter) + return startResult.then( + () => cleanup, + (err) => { + // Bug 1 fix (async): start rejected — remove from tracked set + watchedAdapters.delete(adapter) + throw err + }, + ) } - if (dead.length > 0 && broadcastPool.size === 0) stopHeartbeat() + + // Bug 1 fix: only add after successful synchronous start + watchedAdapters.add(adapter) + return cleanup }, - close(): void { + close(): void | Promise { closed = true stopHeartbeat() for (const client of broadcastPool) { closeClient(client) } - broadcastPool.clear() + + const adapters = [...watchedAdapters] + watchedAdapters.clear() + + if (adapters.length === 0) return + + // Bug 2 fix: async wrapper captures synchronous throws inside allSettled + return Promise.allSettled( + adapters.map(async (a) => { + if (stoppedAdapters.has(a)) return + stoppedAdapters.add(a) + await a.stop() + }), + ).then(() => {}) }, isClosed(): boolean { diff --git a/src/types.ts b/src/types.ts index 2e0c0b6..c5010ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -173,17 +173,84 @@ export interface SchemaEventDefinition { transform?: (payload: TPayload) => TPayload } +/** + * Definition for a single resource operation (created, updated, or deleted). + * All fields are optional — omitting key yields a default key based on the resource name. + */ +// biome-ignore lint/suspicious/noExplicitAny: resource operation allows any payload/data types +export interface ResourceOperationDefinition { + key?: string | string[] | ((payload: TPayload) => string | string[]) + update?: UpdateStrategy + filter?: (payload: TPayload) => boolean + transform?: (payload: TPayload) => TPayload +} + +/** + * Definition for a resource in defineSchema(). + * Each resource can optionally specify custom definitions for created, updated, and deleted. + */ +export interface ResourceDefinition { + created?: ResourceOperationDefinition + updated?: ResourceOperationDefinition + deleted?: ResourceOperationDefinition +} + /** * The input shape accepted by defineSchema(). * Keys are event type names (string literals), values are event definitions. + * The optional `resources` key expands each entry into .created/.updated/.deleted events. + */ +export type SchemaDefinition = { + resources?: Record +} & Record< + string, + SchemaEventDefinition | Record | undefined +> + +/** + * Expands a resource name into the three event key literals it generates at runtime. + * e.g. 'orders' -> 'orders.created' | 'orders.updated' | 'orders.deleted' */ -export type SchemaDefinition = Record +type ResourceEventKeys = + | `${R}.created` + | `${R}.updated` + | `${R}.deleted` + +/** + * Builds the mapped type for all resource-expanded events. + * For each resource name R, produces entries for R.created, R.updated, R.deleted. + */ +type ResourceSchemaEntries< + TResources extends Record, +> = { + [R in string & keyof TResources as ResourceEventKeys]: Required< + Pick + > & + Omit & { + // biome-ignore lint/suspicious/noExplicitAny: resource events have erased payload/data types + update: UpdateStrategy | 'set' + } +} /** * The frozen schema object returned by defineSchema(). * Preserves string literal event names from the input for TypeScript autocomplete. + * When the input has a `resources` field, the output includes the expanded + * `.created`, `.updated`, `.deleted` event keys with proper typing. */ -export type SchemaResult = Readonly<{ - [K in keyof T]: Required> & - Omit & { update: NonNullable | 'set' } -}> +// biome-ignore lint/suspicious/noExplicitAny: SchemaResult generic requires any for broad compatibility +export type SchemaResult> = Readonly< + { + [K in keyof T as T[K] extends SchemaEventDefinition + ? K + : never]: T[K] extends SchemaEventDefinition + ? Required> & + Omit & { update: NonNullable | 'set' } + : never + } & (T extends { resources: infer R } + ? R extends Record + ? ResourceSchemaEntries + : // biome-ignore lint/suspicious/noExplicitAny: fallback for unknown resource shape + Record + : Record) +> diff --git a/tsconfig.emit.json b/tsconfig.emit.json index b7d7a4f..47f4ac0 100644 --- a/tsconfig.emit.json +++ b/tsconfig.emit.json @@ -10,6 +10,7 @@ "src/index.ts", "src/testing/index.ts", "src/server/index.ts", + "src/server/adapters", "src/hooks", "src/SSEProvider.tsx", "src/fetchTransport.ts",