@strav/broadcast 1.0.0-alpha.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @strav/broadcast
2
+
3
+ In-process and multi-node pub/sub for Strav 1.0. Powers SSE endpoints (`router.sse(...)` in `@strav/http`) and the broadcast notification channel (`@strav/notification/broadcast`). Apps inject the abstract `Broadcaster` token; the provider in the container picks the concrete driver — `MemoryBroadcaster` for single-node dev, `PostgresBroadcaster` for multi-node deployments.
4
+
5
+ ```ts
6
+ import { Broadcaster } from '@strav/broadcast'
7
+
8
+ @inject()
9
+ class OrdersController {
10
+ constructor(private readonly broadcaster: Broadcaster) {}
11
+
12
+ async pay(req: Request): Promise<Response> {
13
+ const order = await this.orders.markPaid(req)
14
+ await this.broadcaster.publish(`private-orders.${order.tenantId}`, {
15
+ id: order.eventId,
16
+ event: 'order.paid',
17
+ data: { orderId: order.id, amount: order.amountCents },
18
+ })
19
+ return new Response(null, { status: 204 })
20
+ }
21
+ }
22
+ ```
23
+
24
+ Canonical docs live in [`docs/broadcast/README.md`](../../docs/broadcast/README.md).
25
+
26
+ ## What ships
27
+
28
+ | Driver | Subpath | Notes |
29
+ |---|---|---|
30
+ | Memory | `@strav/broadcast` (root) + `@strav/broadcast/memory` | In-process pub/sub. Single-node only. Bounded per-subscription buffer with overflow hooks. |
31
+ | Postgres | `@strav/broadcast/postgres` | Polling-ledger backplane (`strav_broadcast_events` table). Multi-node. ~250ms p50 latency at default polling interval. |
32
+
33
+ Per-channel authorization is built into the base class — register exact names or trailing-wildcard patterns (`'private-orders.*'`) via `broadcaster.authorize(pattern, fn)`. Channels with the `private-` or `presence-` prefix are denied by default unless an authorizer says yes.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@strav/broadcast",
3
+ "version": "1.0.0-alpha.25",
4
+ "description": "Strav broadcast / pub-sub primitive — Broadcaster interface + MemoryBroadcaster (in-process) + PostgresBroadcaster (polling-ledger backplane) + per-channel authorization. Pairs with @strav/http's router.sse and @strav/notification/broadcast.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./memory": "./src/drivers/memory/index.ts",
11
+ "./postgres": "./src/drivers/postgres/index.ts"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "bun": ">=1.3.14"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@strav/kernel": "1.0.0-alpha.25"
25
+ },
26
+ "peerDependencies": {
27
+ "@strav/database": "1.0.0-alpha.25",
28
+ "@types/bun": ">=1.3.14"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@strav/database": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "devDependencies": null
36
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Typed error hierarchy. Same shape as `@strav/notification` — base
3
+ * `BroadcastError` extending `StravError`, narrower subclasses with
4
+ * stable `code`s apps branch on.
5
+ */
6
+
7
+ import { StravError } from '@strav/kernel'
8
+
9
+ interface BroadcastErrorOptions {
10
+ code?: string
11
+ status?: number
12
+ context?: Record<string, unknown>
13
+ cause?: unknown
14
+ }
15
+
16
+ export class BroadcastError extends StravError {
17
+ constructor(message: string, options: BroadcastErrorOptions = {}) {
18
+ super(
19
+ message,
20
+ { code: options.code ?? 'broadcast.error', status: options.status ?? 500 },
21
+ {
22
+ ...(options.context ? { context: options.context } : {}),
23
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
24
+ },
25
+ )
26
+ }
27
+ }
28
+
29
+ export class BroadcastConfigError extends BroadcastError {
30
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
31
+ super(message, {
32
+ code: 'broadcast.config',
33
+ status: 500,
34
+ ...(options.context ? { context: options.context } : {}),
35
+ })
36
+ }
37
+ }
38
+
39
+ export class BroadcastPublishError extends BroadcastError {
40
+ constructor(
41
+ message: string,
42
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
43
+ ) {
44
+ super(message, {
45
+ code: 'broadcast.publish',
46
+ status: 502,
47
+ ...(options.context ? { context: options.context } : {}),
48
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
49
+ })
50
+ }
51
+ }
52
+
53
+ export class BroadcastUnauthorizedError extends BroadcastError {
54
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
55
+ super(message, {
56
+ code: 'broadcast.unauthorized',
57
+ status: 403,
58
+ ...(options.context ? { context: options.context } : {}),
59
+ })
60
+ }
61
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `BroadcastProvider` — registers a `MemoryBroadcaster` under the
3
+ * `Broadcaster` token by default.
4
+ *
5
+ * Apps that want the Postgres backplane swap providers:
6
+ *
7
+ * import { BroadcastProvider } from '@strav/broadcast'
8
+ * import { PostgresBroadcastProvider } from '@strav/broadcast/postgres'
9
+ *
10
+ * providers: [
11
+ * ...,
12
+ * new PostgresBroadcastProvider(), // instead of BroadcastProvider
13
+ * ]
14
+ *
15
+ * Both providers register under the same `Broadcaster` token, so app
16
+ * code injecting `Broadcaster` doesn't change between dev and prod.
17
+ *
18
+ * Eager singleton — the constructor runs at register-time so config
19
+ * errors surface at boot rather than first `publish()`.
20
+ */
21
+
22
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
23
+ import { Broadcaster } from './broadcaster.ts'
24
+ import {
25
+ MemoryBroadcaster,
26
+ type MemoryBroadcasterOptions,
27
+ } from './drivers/memory/memory_broadcaster.ts'
28
+
29
+ export interface MemoryBroadcastConfig extends MemoryBroadcasterOptions {
30
+ driver: 'memory'
31
+ }
32
+
33
+ export class BroadcastProvider extends ServiceProvider {
34
+ override readonly name = 'broadcast'
35
+ override readonly dependencies = ['config']
36
+
37
+ override register(app: Application): void {
38
+ app.singleton(Broadcaster, (c) => {
39
+ const cfg = c.resolve(ConfigRepository).get('broadcast') as MemoryBroadcastConfig | undefined
40
+ // Default to memory with no overrides if the app didn't configure
41
+ // anything — broadcast is opt-in; explicit config only buys you
42
+ // overrides. Apps that don't use broadcast simply never inject
43
+ // the token.
44
+ return new MemoryBroadcaster(
45
+ cfg !== undefined
46
+ ? {
47
+ ...(cfg.maxBufferSize !== undefined ? { maxBufferSize: cfg.maxBufferSize } : {}),
48
+ ...(cfg.onOverflow !== undefined ? { onOverflow: cfg.onOverflow } : {}),
49
+ }
50
+ : {},
51
+ )
52
+ })
53
+ }
54
+
55
+ override async boot(app: Application): Promise<void> {
56
+ app.resolve(Broadcaster)
57
+ }
58
+
59
+ override async shutdown(app: Application): Promise<void> {
60
+ await app.resolve(Broadcaster).close()
61
+ }
62
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `Broadcaster` — the abstract base that every driver extends and apps
3
+ * inject via the container.
4
+ *
5
+ * - `publish(channel, event)` — fan-out an event to every subscriber.
6
+ * Returns once the driver has accepted the publish (for memory:
7
+ * synchronously; for postgres: after the INSERT commits).
8
+ * - `subscribe(channel)` — open an `AsyncIterable<BroadcastEvent>`
9
+ * that yields events until closed via `unsubscribe()` (or by
10
+ * breaking out of the `for await` loop).
11
+ * - `authorize(pattern, fn)` — register a per-channel authorization
12
+ * check. SSE handlers + the notification driver call
13
+ * `authorizeFor(channel, subject)` before opening a subscription
14
+ * on behalf of a user.
15
+ * - `close()` — release driver resources. Optional override; the
16
+ * `BroadcastProvider.shutdown()` hook calls it.
17
+ *
18
+ * The class is abstract so it serves as the container token —
19
+ * `app.singleton(Broadcaster, factory)` binds the concrete driver,
20
+ * `container.resolve(Broadcaster)` returns it. Same pattern as
21
+ * `Database` / `Logger`.
22
+ *
23
+ * Multi-driver routing is not in scope — broadcast is typically one
24
+ * backplane per app (memory in dev, postgres in prod). Apps that want
25
+ * to mix wire two `Broadcaster` instances behind named tokens.
26
+ */
27
+
28
+ import {
29
+ type ChannelAuthorizationResult,
30
+ type ChannelAuthorizer,
31
+ ChannelAuthorizerRegistry,
32
+ normalizeAuthorizerResult,
33
+ } from './channel_authorizer.ts'
34
+ import type { BroadcastEvent, BroadcastSubscription } from './types.ts'
35
+
36
+ // Non-abstract so the class can be used as a container token —
37
+ // `app.singleton(Broadcaster, factory)`. Subclasses MUST override
38
+ // `publish` and `subscribe`; the defaults throw to surface forgotten
39
+ // overrides during development. Same trade-off as `kernel`'s `Logger`.
40
+ export class Broadcaster {
41
+ protected readonly authorizers = new ChannelAuthorizerRegistry()
42
+
43
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
44
+ publish(channel: string, event: BroadcastEvent): Promise<void> {
45
+ throw new Error('Broadcaster.publish must be overridden by the driver subclass.')
46
+ }
47
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
48
+ subscribe(channel: string): BroadcastSubscription {
49
+ throw new Error('Broadcaster.subscribe must be overridden by the driver subclass.')
50
+ }
51
+
52
+ authorize(pattern: string, fn: ChannelAuthorizer): void {
53
+ this.authorizers.register(pattern, fn)
54
+ }
55
+
56
+ /**
57
+ * Resolve a registered authorizer against `subject` for `channel`.
58
+ *
59
+ * Default policy when no authorizer matches:
60
+ * - Channels with the `private-` or `presence-` prefix → denied
61
+ * (Echo / Pusher convention; opt in by registering an
62
+ * authorizer for the pattern).
63
+ * - Everything else → allowed.
64
+ *
65
+ * Returns the structured `ChannelAuthorizationResult` — never
66
+ * throws on denial. Callers (SSE handler, notification driver)
67
+ * branch on `result.authorized`.
68
+ */
69
+ async authorizeFor(channel: string, subject: unknown): Promise<ChannelAuthorizationResult> {
70
+ const matched = this.authorizers.match(channel)
71
+ if (matched !== undefined) {
72
+ return normalizeAuthorizerResult(await matched(channel, subject))
73
+ }
74
+ if (channel.startsWith('private-') || channel.startsWith('presence-')) {
75
+ return { authorized: false }
76
+ }
77
+ return { authorized: true }
78
+ }
79
+
80
+ /** Optional resource cleanup. Default implementation is a no-op. */
81
+ async close(): Promise<void> {}
82
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Per-channel authorization.
3
+ *
4
+ * A `ChannelAuthorizer` decides whether a subject — typically the
5
+ * authenticated user, but any opaque value works — may subscribe to a
6
+ * named channel. Authorizers are matched literally first, then by
7
+ * pattern (`presence-room-*`); the first match wins. The default
8
+ * policy when no authorizer is registered is open / public (matching
9
+ * the in-process broadcast semantics — there's no auth across SSE
10
+ * connections by default; apps opt in).
11
+ *
12
+ * The function may return either a boolean (allowed?) or a richer
13
+ * `ChannelAuthorizationResult` carrying presence metadata that the
14
+ * caller (typically the SSE handler) embeds in the initial event
15
+ * stream. This mirrors Laravel Echo / Pusher conventions.
16
+ */
17
+
18
+ export interface ChannelAuthorizationResult {
19
+ authorized: boolean
20
+ /**
21
+ * Optional structured metadata about the subject — surfaces on
22
+ * presence channels (e.g. `{ id: 'u_1', name: 'Alice' }`). The
23
+ * caller decides what to do with it; broadcasters themselves treat
24
+ * it as opaque.
25
+ */
26
+ presence?: Record<string, unknown>
27
+ }
28
+
29
+ export type ChannelAuthorizer = (
30
+ channel: string,
31
+ subject: unknown,
32
+ ) => boolean | ChannelAuthorizationResult | Promise<boolean | ChannelAuthorizationResult>
33
+
34
+ /**
35
+ * Channel-name registry — used by `Broadcaster.authorize(pattern, fn)`.
36
+ * Supports exact names (`'orders.42'`) and trailing-wildcard patterns
37
+ * (`'orders.*'`, `'presence-room-*'`). No regex; the wildcard is a
38
+ * single `*` at the end. Keeps the implementation tight and avoids
39
+ * regex-injection footguns.
40
+ */
41
+ export class ChannelAuthorizerRegistry {
42
+ private readonly exact = new Map<string, ChannelAuthorizer>()
43
+ private readonly prefixes: Array<{ prefix: string; fn: ChannelAuthorizer }> = []
44
+
45
+ register(pattern: string, fn: ChannelAuthorizer): void {
46
+ if (pattern.endsWith('*')) {
47
+ this.prefixes.push({ prefix: pattern.slice(0, -1), fn })
48
+ // Longer prefixes first → "orders.special.*" wins over "orders.*".
49
+ this.prefixes.sort((a, b) => b.prefix.length - a.prefix.length)
50
+ return
51
+ }
52
+ this.exact.set(pattern, fn)
53
+ }
54
+
55
+ /** Resolve the authorizer matching `channel`, or `undefined`. */
56
+ match(channel: string): ChannelAuthorizer | undefined {
57
+ const direct = this.exact.get(channel)
58
+ if (direct !== undefined) return direct
59
+ for (const { prefix, fn } of this.prefixes) {
60
+ if (channel.startsWith(prefix)) return fn
61
+ }
62
+ return undefined
63
+ }
64
+
65
+ clear(): void {
66
+ this.exact.clear()
67
+ this.prefixes.length = 0
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Normalise an authorizer's return value to the canonical
73
+ * `ChannelAuthorizationResult` shape. Boolean returns become
74
+ * `{ authorized: bool }`; the structured form passes through.
75
+ */
76
+ export function normalizeAuthorizerResult(
77
+ result: boolean | ChannelAuthorizationResult,
78
+ ): ChannelAuthorizationResult {
79
+ if (typeof result === 'boolean') return { authorized: result }
80
+ return result
81
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ MemoryBroadcaster,
3
+ type MemoryBroadcasterOptions,
4
+ } from './memory_broadcaster.ts'
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `MemoryBroadcaster` — in-process pub/sub.
3
+ *
4
+ * Subscribers register a per-channel buffer; `publish` walks the
5
+ * buffer list, pushes the event onto each, and resolves any pending
6
+ * `next()` awaiters. Backpressure is bounded — buffers cap at
7
+ * `maxBufferSize` (default 1000), and the oldest event is dropped
8
+ * when the cap is hit. Apps that need stricter backpressure register
9
+ * an `onOverflow` handler at construction time.
10
+ *
11
+ * Single-process only. Apps deploying more than one node use
12
+ * `PostgresBroadcaster` (or write their own driver against Redis /
13
+ * NATS / etc.).
14
+ */
15
+
16
+ import { Broadcaster } from '../../broadcaster.ts'
17
+ import type { BroadcastEvent, BroadcastSubscription } from '../../types.ts'
18
+
19
+ export interface MemoryBroadcasterOptions {
20
+ /**
21
+ * Maximum events buffered per subscription before the oldest is
22
+ * dropped. Tune higher if subscribers can pause for long stretches;
23
+ * tune lower to surface back-pressure faster. Default `1000`.
24
+ */
25
+ maxBufferSize?: number
26
+ /**
27
+ * Called when a subscription's buffer overflows and an event is
28
+ * dropped. Apps wire telemetry / metrics here. Default: silent.
29
+ */
30
+ onOverflow?: (channel: string, droppedEvent: BroadcastEvent) => void
31
+ }
32
+
33
+ interface MemorySubscriberState {
34
+ channel: string
35
+ buffer: BroadcastEvent[]
36
+ /** Resolves the consumer's pending `next()` call, if any. */
37
+ pendingResolve: ((result: IteratorResult<BroadcastEvent>) => void) | undefined
38
+ closed: boolean
39
+ }
40
+
41
+ export class MemoryBroadcaster extends Broadcaster {
42
+ private readonly subscribers = new Map<string, Set<MemorySubscriberState>>()
43
+ private readonly maxBufferSize: number
44
+ private readonly onOverflow: ((channel: string, event: BroadcastEvent) => void) | undefined
45
+
46
+ constructor(options: MemoryBroadcasterOptions = {}) {
47
+ super()
48
+ this.maxBufferSize = options.maxBufferSize ?? 1000
49
+ this.onOverflow = options.onOverflow
50
+ }
51
+
52
+ override async publish(channel: string, event: BroadcastEvent): Promise<void> {
53
+ const set = this.subscribers.get(channel)
54
+ if (set === undefined) return
55
+ for (const sub of set) {
56
+ this.deliver(sub, event)
57
+ }
58
+ }
59
+
60
+ override subscribe(channel: string): BroadcastSubscription {
61
+ const state: MemorySubscriberState = {
62
+ channel,
63
+ buffer: [],
64
+ pendingResolve: undefined,
65
+ closed: false,
66
+ }
67
+ let set = this.subscribers.get(channel)
68
+ if (set === undefined) {
69
+ set = new Set()
70
+ this.subscribers.set(channel, set)
71
+ }
72
+ set.add(state)
73
+
74
+ const detach = (): void => {
75
+ if (state.closed) return
76
+ state.closed = true
77
+ const s = this.subscribers.get(channel)
78
+ if (s !== undefined) {
79
+ s.delete(state)
80
+ if (s.size === 0) this.subscribers.delete(channel)
81
+ }
82
+ if (state.pendingResolve !== undefined) {
83
+ const resolve = state.pendingResolve
84
+ state.pendingResolve = undefined
85
+ resolve({ value: undefined, done: true })
86
+ }
87
+ }
88
+
89
+ const subscription: BroadcastSubscription = {
90
+ [Symbol.asyncIterator](): AsyncIterableIterator<BroadcastEvent> {
91
+ return subscription
92
+ },
93
+ async next(): Promise<IteratorResult<BroadcastEvent>> {
94
+ if (state.closed && state.buffer.length === 0) {
95
+ return { value: undefined, done: true }
96
+ }
97
+ const buffered = state.buffer.shift()
98
+ if (buffered !== undefined) return { value: buffered, done: false }
99
+ return new Promise<IteratorResult<BroadcastEvent>>((resolve) => {
100
+ state.pendingResolve = resolve
101
+ })
102
+ },
103
+ async return(): Promise<IteratorResult<BroadcastEvent>> {
104
+ detach()
105
+ return { value: undefined, done: true }
106
+ },
107
+ async unsubscribe(): Promise<void> {
108
+ detach()
109
+ },
110
+ }
111
+ return subscription
112
+ }
113
+
114
+ override async close(): Promise<void> {
115
+ for (const set of this.subscribers.values()) {
116
+ for (const sub of set) {
117
+ sub.closed = true
118
+ if (sub.pendingResolve !== undefined) {
119
+ const resolve = sub.pendingResolve
120
+ sub.pendingResolve = undefined
121
+ resolve({ value: undefined, done: true })
122
+ }
123
+ }
124
+ }
125
+ this.subscribers.clear()
126
+ }
127
+
128
+ /** Snapshot of subscriber count per channel — diagnostics / tests. */
129
+ subscriberCount(channel: string): number {
130
+ return this.subscribers.get(channel)?.size ?? 0
131
+ }
132
+
133
+ private deliver(state: MemorySubscriberState, event: BroadcastEvent): void {
134
+ if (state.closed) return
135
+ // Fast path — consumer is waiting on next().
136
+ if (state.pendingResolve !== undefined) {
137
+ const resolve = state.pendingResolve
138
+ state.pendingResolve = undefined
139
+ resolve({ value: event, done: false })
140
+ return
141
+ }
142
+ // Slow path — buffer + cap.
143
+ if (state.buffer.length >= this.maxBufferSize) {
144
+ const dropped = state.buffer.shift()
145
+ if (dropped !== undefined && this.onOverflow !== undefined) {
146
+ this.onOverflow(state.channel, dropped)
147
+ }
148
+ }
149
+ state.buffer.push(event)
150
+ }
151
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `applyBroadcastMigration` — emit DDL for the
3
+ * `strav_broadcast_events` ledger plus the two indexes the
4
+ * `PostgresBroadcaster` relies on:
5
+ *
6
+ * - `id` PK (created by the `bigSerial` column) — the poller's cursor.
7
+ * - `created_at` index — used by the retention sweep.
8
+ *
9
+ * Apps register `broadcastEventSchema` with their `SchemaRegistry`
10
+ * and call this helper from a migration's `up`:
11
+ *
12
+ * await applyBroadcastMigration(db, { registry })
13
+ *
14
+ * Mirrors `applyNotificationMigration` from `@strav/notification/database`.
15
+ */
16
+
17
+ import { type DatabaseExecutor, emitCreateTable, type SchemaRegistry } from '@strav/database'
18
+ import { broadcastEventSchema } from './broadcast_event_schema.ts'
19
+
20
+ export interface ApplyBroadcastMigrationOptions {
21
+ registry: SchemaRegistry
22
+ }
23
+
24
+ export async function applyBroadcastMigration(
25
+ db: DatabaseExecutor,
26
+ options: ApplyBroadcastMigrationOptions,
27
+ ): Promise<void> {
28
+ const { registry } = options
29
+ await db.execute(emitCreateTable(broadcastEventSchema, { registry }).sql)
30
+ await db.execute(
31
+ `CREATE INDEX IF NOT EXISTS "idx_strav_broadcast_events_created_at"
32
+ ON "${broadcastEventSchema.name}" ("created_at")`,
33
+ )
34
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `strav_broadcast_events` schema — the backing ledger for
3
+ * `PostgresBroadcaster`.
4
+ *
5
+ * Append-only event log. Each `publish()` INSERTs one row; the
6
+ * shared per-process poller runs `SELECT * FROM strav_broadcast_events
7
+ * WHERE id > $lastId ORDER BY id` on a configurable interval and
8
+ * fanouts new rows to in-process subscribers.
9
+ *
10
+ * Columns:
11
+ * - `id` (bigSerial PK) — monotonically increasing; the poller's
12
+ * cursor. Big enough that we never wrap.
13
+ * - `channel` (text) — routing key.
14
+ * - `event_name` (text) — the publisher-set verb tag.
15
+ * - `event_id` (text) — publisher-assigned identifier (ULIDs
16
+ * recommended). Receivers use this to dedup; we keep it as text
17
+ * rather than reusing `id` so a publish from one node carries an
18
+ * identifier other nodes can quote in their fan-out without
19
+ * coordinating SERIAL values.
20
+ * - `data` (jsonb) — JSON-serialised payload.
21
+ * - `created_at` (timestamptz, default now()) — used by the retention
22
+ * sweep to drop old events; not exposed to subscribers.
23
+ *
24
+ * `Archetype.Event` — matches the table's append-only semantics.
25
+ * Non-tenanted by default (framework policy: multitenancy is opt-in).
26
+ * Per-tenant pub/sub is achievable by namespacing channels with the
27
+ * tenant ID (`tenant:42:orders.*`) — RLS on this table would force a
28
+ * subscriber connection to know every tenant in advance, which
29
+ * defeats the polling model.
30
+ */
31
+
32
+ import { Archetype, defineSchema } from '@strav/database'
33
+
34
+ export const broadcastEventSchema = defineSchema('strav_broadcast_events', Archetype.Event, (t) => {
35
+ t.bigSerial('id')
36
+ t.text('channel')
37
+ t.text('event_name')
38
+ t.text('event_id')
39
+ t.json<unknown>('data')
40
+ t.timestamp('created_at')
41
+ })
@@ -0,0 +1,14 @@
1
+ export {
2
+ type ApplyBroadcastMigrationOptions,
3
+ applyBroadcastMigration,
4
+ } from './apply_broadcast_migration.ts'
5
+ export { broadcastEventSchema } from './broadcast_event_schema.ts'
6
+ export {
7
+ type PostgresBroadcastConfig,
8
+ PostgresBroadcastProvider,
9
+ } from './postgres_broadcast_provider.ts'
10
+ export {
11
+ PostgresBroadcaster,
12
+ type PostgresBroadcasterDatabase,
13
+ type PostgresBroadcasterOptions,
14
+ } from './postgres_broadcaster.ts'
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `PostgresBroadcastProvider` — wires `PostgresBroadcaster` under the
3
+ * `Broadcaster` token. Apps register this INSTEAD OF
4
+ * `BroadcastProvider` to swap the dev-friendly memory backplane for
5
+ * the multi-node Postgres ledger.
6
+ *
7
+ * Reads `config.broadcast` for the polling / retention knobs; the
8
+ * `Database` binding is resolved straight from the container.
9
+ */
10
+
11
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
12
+ import { Broadcaster } from '../../broadcaster.ts'
13
+ import {
14
+ PostgresBroadcaster,
15
+ type PostgresBroadcasterDatabase,
16
+ type PostgresBroadcasterOptions,
17
+ } from './postgres_broadcaster.ts'
18
+
19
+ export interface PostgresBroadcastConfig extends Omit<PostgresBroadcasterOptions, 'db'> {
20
+ driver: 'postgres'
21
+ }
22
+
23
+ export class PostgresBroadcastProvider extends ServiceProvider {
24
+ override readonly name = 'broadcast'
25
+ override readonly dependencies = ['config', 'database']
26
+
27
+ override register(app: Application): void {
28
+ app.singleton(Broadcaster, (c) => {
29
+ // Resolve `Database` by string token to avoid a hard import dep
30
+ // on `@strav/database` from this package's barrel — the peer is
31
+ // optional and only loaded when this provider is registered.
32
+ const db = c.resolve<PostgresBroadcasterDatabase>('database' as never)
33
+ const cfg = c.resolve(ConfigRepository).get('broadcast') as
34
+ | PostgresBroadcastConfig
35
+ | undefined
36
+ return new PostgresBroadcaster({
37
+ db,
38
+ ...(cfg?.pollIntervalMs !== undefined ? { pollIntervalMs: cfg.pollIntervalMs } : {}),
39
+ ...(cfg?.retentionSeconds !== undefined ? { retentionSeconds: cfg.retentionSeconds } : {}),
40
+ ...(cfg?.cleanupIntervalMs !== undefined
41
+ ? { cleanupIntervalMs: cfg.cleanupIntervalMs }
42
+ : {}),
43
+ ...(cfg?.maxBufferSize !== undefined ? { maxBufferSize: cfg.maxBufferSize } : {}),
44
+ ...(cfg?.onOverflow !== undefined ? { onOverflow: cfg.onOverflow } : {}),
45
+ })
46
+ })
47
+ }
48
+
49
+ override async boot(app: Application): Promise<void> {
50
+ app.resolve(Broadcaster)
51
+ }
52
+
53
+ override async shutdown(app: Application): Promise<void> {
54
+ await app.resolve(Broadcaster).close()
55
+ }
56
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * `PostgresBroadcaster` — multi-node broadcast backplane via a
3
+ * polled ledger table.
4
+ *
5
+ * Why polling and not LISTEN/NOTIFY:
6
+ *
7
+ * Bun's built-in Postgres driver does not surface LISTEN/NOTIFY
8
+ * to user code (as of Bun 1.3). The next-cheapest cross-node
9
+ * primitive is a write-then-read ledger — same pattern as
10
+ * `@strav/queue`'s `DatabaseQueue`. The polling interval
11
+ * determines latency; 250ms (default) keeps round-trip on the
12
+ * order of a network hop while consuming negligible DB CPU.
13
+ *
14
+ * How it works:
15
+ *
16
+ * - `publish(channel, event)` INSERTs one row into
17
+ * `strav_broadcast_events`.
18
+ * - One poller per process runs `SELECT * FROM ... WHERE id > $lastId
19
+ * ORDER BY id` every `pollIntervalMs`. New rows are fanned out to
20
+ * local subscribers via an internal `MemoryBroadcaster`.
21
+ * - The poller starts on first `subscribe()` and stops when the
22
+ * broadcaster closes. Subscriber count drops to zero → poller stops
23
+ * (lazy) so apps that only publish don't spin a poll loop.
24
+ * - On startup, `lastId = SELECT max(id)`. Subscribers always start
25
+ * from "events published from now on" — no historical replay.
26
+ * - A retention sweep runs every `cleanupIntervalMs` and deletes
27
+ * rows older than `retentionSeconds`. Keeps the table from growing
28
+ * forever without inventing TTL semantics in app code.
29
+ *
30
+ * Apps wire `PostgresBroadcastProvider` instead of `BroadcastProvider`
31
+ * to use this driver; the SSE handler / Notification driver inject the
32
+ * shared `Broadcaster` token and see this concrete instance.
33
+ */
34
+
35
+ import { BroadcastPublishError } from '../../broadcast_error.ts'
36
+ import { Broadcaster } from '../../broadcaster.ts'
37
+ import type { BroadcastEvent, BroadcastSubscription } from '../../types.ts'
38
+ import { MemoryBroadcaster } from '../memory/memory_broadcaster.ts'
39
+ import { broadcastEventSchema } from './broadcast_event_schema.ts'
40
+
41
+ /**
42
+ * Minimal slice of `@strav/database`'s `DatabaseExecutor` we actually
43
+ * need. Declared inline (not imported) to keep the runtime peer-dep
44
+ * optional — apps using `MemoryBroadcaster` shouldn't pay for
45
+ * `@strav/database` in their bundle.
46
+ */
47
+ export interface PostgresBroadcasterDatabase {
48
+ query<T = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<T[]>
49
+ execute(sql: string, params?: readonly unknown[]): Promise<number>
50
+ }
51
+
52
+ export interface PostgresBroadcasterOptions {
53
+ db: PostgresBroadcasterDatabase
54
+ /** Poll interval (ms). Default `250`. */
55
+ pollIntervalMs?: number
56
+ /**
57
+ * How long to retain events in the ledger before the sweep deletes
58
+ * them. Default `300` seconds (5 minutes). Set higher only if your
59
+ * SSE clients can reconnect and need replay capability; the
60
+ * default is "keep enough for a node to recover from a crash and
61
+ * catch up to live", not "audit log".
62
+ */
63
+ retentionSeconds?: number
64
+ /** Interval between retention sweeps. Default `30000` ms. */
65
+ cleanupIntervalMs?: number
66
+ /**
67
+ * Per-subscription buffer cap forwarded to the in-process
68
+ * `MemoryBroadcaster`. Default `1000`.
69
+ */
70
+ maxBufferSize?: number
71
+ /** Forwarded to the in-process `MemoryBroadcaster`'s onOverflow hook. */
72
+ onOverflow?: (channel: string, droppedEvent: BroadcastEvent) => void
73
+ }
74
+
75
+ interface BroadcastEventRow {
76
+ id: string | number
77
+ channel: string
78
+ event_name: string
79
+ event_id: string
80
+ data: unknown
81
+ }
82
+
83
+ export class PostgresBroadcaster extends Broadcaster {
84
+ private readonly db: PostgresBroadcasterDatabase
85
+ private readonly local: MemoryBroadcaster
86
+ private readonly tableName = broadcastEventSchema.name
87
+ private readonly pollIntervalMs: number
88
+ private readonly retentionSeconds: number
89
+ private readonly cleanupIntervalMs: number
90
+
91
+ private lastId = 0n
92
+ private cursorPrimed = false
93
+ private pollTimer: ReturnType<typeof setTimeout> | undefined
94
+ private cleanupTimer: ReturnType<typeof setInterval> | undefined
95
+ private closed = false
96
+ private pollInFlight: Promise<void> | undefined
97
+
98
+ constructor(options: PostgresBroadcasterOptions) {
99
+ super()
100
+ this.db = options.db
101
+ this.pollIntervalMs = options.pollIntervalMs ?? 250
102
+ this.retentionSeconds = options.retentionSeconds ?? 300
103
+ this.cleanupIntervalMs = options.cleanupIntervalMs ?? 30_000
104
+ this.local = new MemoryBroadcaster({
105
+ ...(options.maxBufferSize !== undefined ? { maxBufferSize: options.maxBufferSize } : {}),
106
+ ...(options.onOverflow !== undefined ? { onOverflow: options.onOverflow } : {}),
107
+ })
108
+ }
109
+
110
+ override async publish(channel: string, event: BroadcastEvent): Promise<void> {
111
+ let serialised: string
112
+ try {
113
+ serialised = JSON.stringify(event.data)
114
+ } catch (cause) {
115
+ throw new BroadcastPublishError('PostgresBroadcaster: event.data is not JSON-serialisable.', {
116
+ context: { channel, event: event.event },
117
+ cause,
118
+ })
119
+ }
120
+ try {
121
+ await this.db.execute(
122
+ `INSERT INTO "${this.tableName}" ("channel", "event_name", "event_id", "data", "created_at")
123
+ VALUES ($1, $2, $3, $4::jsonb, now())`,
124
+ [channel, event.event, event.id, serialised],
125
+ )
126
+ } catch (cause) {
127
+ throw new BroadcastPublishError('PostgresBroadcaster: INSERT failed.', {
128
+ context: { channel, event: event.event },
129
+ cause,
130
+ })
131
+ }
132
+ }
133
+
134
+ override subscribe(channel: string): BroadcastSubscription {
135
+ const subscription = this.local.subscribe(channel)
136
+ void this.ensurePoller()
137
+ return subscription
138
+ }
139
+
140
+ override async close(): Promise<void> {
141
+ this.closed = true
142
+ if (this.pollTimer !== undefined) {
143
+ clearTimeout(this.pollTimer)
144
+ this.pollTimer = undefined
145
+ }
146
+ if (this.cleanupTimer !== undefined) {
147
+ clearInterval(this.cleanupTimer)
148
+ this.cleanupTimer = undefined
149
+ }
150
+ if (this.pollInFlight !== undefined) {
151
+ try {
152
+ await this.pollInFlight
153
+ } catch {
154
+ // The next start() will surface this if it persists.
155
+ }
156
+ }
157
+ await this.local.close()
158
+ }
159
+
160
+ // ─── Internals ─────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Run one polling pass immediately and re-arm the timer. Exposed for
164
+ * tests so the suite doesn't have to sleep through `pollIntervalMs`.
165
+ */
166
+ async pollOnce(): Promise<void> {
167
+ if (this.closed) return
168
+ if (!this.cursorPrimed) await this.primeCursor()
169
+ const rows = await this.db.query<BroadcastEventRow>(
170
+ `SELECT "id", "channel", "event_name", "event_id", "data"
171
+ FROM "${this.tableName}"
172
+ WHERE "id" > $1
173
+ ORDER BY "id"
174
+ LIMIT 1000`,
175
+ [this.lastId.toString()],
176
+ )
177
+ for (const row of rows) {
178
+ this.lastId = BigInt(row.id)
179
+ await this.local.publish(row.channel, {
180
+ id: row.event_id,
181
+ event: row.event_name,
182
+ // Bun.SQL returns jsonb as a string (no auto-hydration on
183
+ // `unsafe()`). Parse here so subscribers receive the same
184
+ // object shape they passed to publish().
185
+ data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
186
+ })
187
+ }
188
+ }
189
+
190
+ /** Same idea — exposed for tests that want to verify cleanup behaviour. */
191
+ async sweepOnce(): Promise<number> {
192
+ return this.db.execute(
193
+ `DELETE FROM "${this.tableName}" WHERE "created_at" < now() - ($1::text || ' seconds')::interval`,
194
+ [String(this.retentionSeconds)],
195
+ )
196
+ }
197
+
198
+ private async ensurePoller(): Promise<void> {
199
+ if (this.closed) return
200
+ if (this.pollTimer !== undefined) return
201
+ await this.primeCursor()
202
+ this.startPollLoop()
203
+ this.startCleanupLoop()
204
+ }
205
+
206
+ private async primeCursor(): Promise<void> {
207
+ if (this.cursorPrimed) return
208
+ const [row] = await this.db.query<{ max: string | number | null }>(
209
+ `SELECT MAX("id") AS "max" FROM "${this.tableName}"`,
210
+ )
211
+ this.lastId = row?.max !== null && row?.max !== undefined ? BigInt(row.max) : 0n
212
+ this.cursorPrimed = true
213
+ }
214
+
215
+ private startPollLoop(): void {
216
+ if (this.closed) return
217
+ const tick = async (): Promise<void> => {
218
+ if (this.closed) return
219
+ this.pollInFlight = this.pollOnce().catch(() => {
220
+ // Errors are silent at the loop level — we don't want a
221
+ // transient DB blip to tear the broadcaster down. Apps that
222
+ // need visibility wire the database driver's own logging.
223
+ })
224
+ await this.pollInFlight
225
+ this.pollInFlight = undefined
226
+ if (!this.closed) {
227
+ this.pollTimer = setTimeout(() => void tick(), this.pollIntervalMs)
228
+ }
229
+ }
230
+ this.pollTimer = setTimeout(() => void tick(), this.pollIntervalMs)
231
+ }
232
+
233
+ private startCleanupLoop(): void {
234
+ if (this.cleanupTimer !== undefined) return
235
+ this.cleanupTimer = setInterval(() => {
236
+ void this.sweepOnce().catch(() => {
237
+ // Same rationale as the poll loop.
238
+ })
239
+ }, this.cleanupIntervalMs)
240
+ // Allow the process to exit if this is the only timer keeping it alive.
241
+ this.cleanupTimer.unref?.()
242
+ }
243
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Public API of @strav/broadcast.
2
+ //
3
+ // Root barrel exports the primitive — `Broadcaster` abstract class +
4
+ // types + errors + the in-memory provider. Drivers ship under subpaths:
5
+ // - `@strav/broadcast/memory` (re-exports for explicit construction)
6
+ // - `@strav/broadcast/postgres` (Postgres polling-ledger backplane)
7
+
8
+ export {
9
+ BroadcastConfigError,
10
+ BroadcastError,
11
+ BroadcastPublishError,
12
+ BroadcastUnauthorizedError,
13
+ } from './broadcast_error.ts'
14
+ export {
15
+ BroadcastProvider,
16
+ type MemoryBroadcastConfig,
17
+ } from './broadcast_provider.ts'
18
+ export { Broadcaster } from './broadcaster.ts'
19
+ export {
20
+ type ChannelAuthorizationResult,
21
+ type ChannelAuthorizer,
22
+ ChannelAuthorizerRegistry,
23
+ } from './channel_authorizer.ts'
24
+ export {
25
+ MemoryBroadcaster,
26
+ type MemoryBroadcasterOptions,
27
+ } from './drivers/memory/memory_broadcaster.ts'
28
+ export type { BroadcastEvent, BroadcastSubscription } from './types.ts'
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Public types for `@strav/broadcast`.
3
+ *
4
+ * The wire shape of a published event is intentionally tiny — `event`
5
+ * (a verb tag) + `data` (JSON-serialisable payload) + `id` (assigned by
6
+ * the publisher so receivers can dedup / replay). Driver implementations
7
+ * round-trip this shape verbatim; bigger wire metadata (timestamps,
8
+ * sender IDs, etc.) ride on `data` rather than getting promoted to
9
+ * separate fields here.
10
+ */
11
+
12
+ export interface BroadcastEvent {
13
+ /**
14
+ * Event name — a verb tag like `'invoice.paid'` or `'message.created'`.
15
+ * Receivers branch on this; keep it stable.
16
+ */
17
+ event: string
18
+ /** JSON-serialisable payload. Must round-trip through `JSON.stringify`. */
19
+ data: unknown
20
+ /**
21
+ * Publisher-assigned identifier. ULIDs are the recommended shape —
22
+ * monotonically ordered, globally unique. Receivers use this to
23
+ * dedup retried deliveries and to seed `Last-Event-ID` replay on
24
+ * SSE reconnects.
25
+ */
26
+ id: string
27
+ }
28
+
29
+ /**
30
+ * Callback / async iterator interaction is wrapped by the
31
+ * `Broadcaster.subscribe()` return value. Subscribers consume events
32
+ * via `for await (const event of subscription) { ... }` and call
33
+ * `subscription.return()` (or break out of the loop) to unsubscribe.
34
+ */
35
+ export interface BroadcastSubscription extends AsyncIterableIterator<BroadcastEvent> {
36
+ /** Unsubscribe + release driver resources. Idempotent. */
37
+ unsubscribe(): Promise<void>
38
+ }