@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 +33 -0
- package/package.json +36 -0
- package/src/broadcast_error.ts +61 -0
- package/src/broadcast_provider.ts +62 -0
- package/src/broadcaster.ts +82 -0
- package/src/channel_authorizer.ts +81 -0
- package/src/drivers/memory/index.ts +4 -0
- package/src/drivers/memory/memory_broadcaster.ts +151 -0
- package/src/drivers/postgres/apply_broadcast_migration.ts +34 -0
- package/src/drivers/postgres/broadcast_event_schema.ts +41 -0
- package/src/drivers/postgres/index.ts +14 -0
- package/src/drivers/postgres/postgres_broadcast_provider.ts +56 -0
- package/src/drivers/postgres/postgres_broadcaster.ts +243 -0
- package/src/index.ts +28 -0
- package/src/types.ts +38 -0
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,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
|
+
}
|