import { ChangeEvent } from '@voltro/database'; import { Effect } from 'effect'; import { Schema } from 'effect'; import { VoltroPlugin } from '@voltro/protocol'; /** * Wire a store to a broadcast provider. Publishes local changes outward * and injects remote changes inward (skipping own-origin). Returns a * handle whose `close()` tears everything down. * * Publish failures are logged + dropped — they MUST NOT propagate onto * the mutation path (the write already committed + inline-emitted * locally; only cross-replica fan-out is affected). */ export declare const attachBroadcastBus: (options: AttachBroadcastBusOptions) => Promise; export declare interface AttachBroadcastBusOptions { readonly store: BroadcastStore; readonly provider: BroadcastProvider; /** This replica's id — stamped as `origin` on every publish and matched * against incoming `origin` to skip own writes. */ readonly replicaId: string; readonly logger?: BroadcastBusLogger; /** Channel override (tests). Default `voltro:changes`. */ readonly channel?: string; /** * Called when this replica has a hole in what it received. * * Two causes, one shape (see `BroadcastGap`), because there is one recovery: * re-run every live query. A live query is idempotent, so re-running all of * them is always safe and always complete — which is the only recovery * available, since pub/sub keeps no log and there is nothing to replay. * * - a peer's serial jumped: the count is EXACT, not an estimate. * - this replica was not subscribed for a while: no origin, no count, and * the same instruction. * * Absent ⇒ the hole is still DETECTED and logged; only the recovery is * missing. That is deliberate: a bus used without a dispatcher (a test, an * embedder) should still say what it lost. */ readonly onGap?: (gap: BroadcastGap) => void; } /** * The changes channel when no namespace is known — a last resort, not the * normal path. A real boot resolves a namespace from the app's name and passes * the channel in, so two apps on one broker do not share it. */ export declare const BROADCAST_CHANNEL: string; export declare interface BroadcastBusHandle { /** Detach the onChange listener, the bus subscription, and close the * provider. Idempotent. */ readonly close: () => Promise; /** The bus's own liveness — see `BroadcastHealth`. */ readonly health: () => BroadcastHealth; } export declare interface BroadcastBusLogger { info: (message: string, fields?: Record) => void; warn: (message: string, fields?: Record) => void; debug: (message: string, fields?: Record) => void; } /** The channels the framework runs over one broker. `revalidate` is the * api→web ISR-revalidation fanout — the one kind whose subscriber is a * `voltro start` WEB process rather than an api replica. */ export declare type BroadcastChannelKind = 'changes' | 'events' | 'members' | 'presence' | 'revalidate'; /** * What rides the wire on the `voltro:changes` channel. `origin` is the * publishing replica's id; subscribers skip their OWN origin (the writer * already inline-emitted locally) so there is no double-emit and no need * for a dedup table. `event` is the app-mutation ChangeEvent to inject * into every OTHER replica's store emitter. */ export declare interface BroadcastEnvelope { readonly origin: string; readonly event: ChangeEvent; /** * This origin's monotonic serial, from 1. * * The one thing a receiver cannot infer, and without it a dropped message is * undetectable: pub/sub has no retention, so a replica whose broker * connection blips simply never learns that a change happened. Its clients * keep their sockets — the client-side reconnect never fires — and their live * queries stay stale until something else touches the same table, which for a * quiet table can be never. * * Optional on the type because a message from an older replica during a * rolling deploy has none; the receiver treats that as "cannot tell" rather * than as a gap. */ readonly n?: number; /** * Which PROCESS of that origin published this — a nonce minted once per bus * attach, meaningless except for being different after a restart. * * `origin` is a replica's NAME, and a name can outlive the process wearing * it: a StatefulSet pod keeps `POD_NAME` across a restart, and * `VOLTRO_REPLICA_ID` is stable by definition. The serial, however, restarts * at 1 — so a receiver holding a watermark of 500 sees the new process's * 1, 2, 3… as "not newer than what I have", never advances, and reports no * gap for the next 500 changes. Gap detection for that peer is simply off, * silently, and precisely after the event that most deserves a refresh. * * The epoch turns that into a fact the receiver can read: a different epoch * under a known origin means a NEW process, so reset the watermark. It is * NOT reported as a gap — a restart is not evidence that this replica missed * anything, and a gap is a claim about loss. * * Optional for the same reason `n` is: a message from an older replica * mid-rolling-deploy carries none, and "cannot tell" must not read as * "restarted". */ readonly epoch?: string; } /** * Transport failure. `transient` flags broker hiccups (connection reset, * timeout) worth a retry vs configuration faults (bad URL, missing dep) * that won't fix themselves. The wiring never rethrows it onto the * mutation path — a publish failure logs + drops; local reactivity is * unaffected. * * A `Schema.TaggedError` (the framework house style, same shape as * `@voltro/plugin-storage`'s `StorageError`): it rides the Effect error * channel with a stable `_tag`, is `instanceof`-checkable, and — should a * future path ever surface it across the rpc wire — is Schema-encodable. */ export declare class BroadcastError extends BroadcastError_base { } declare const BroadcastError_base: Schema.TaggedErrorClass; } & { provider: typeof Schema.String; message: typeof Schema.String; transient: typeof Schema.Boolean; cause: Schema.optional; }>; /** * A proven — or presumed — hole in what this replica received. * * ONE shape for both causes, because the recovery is one thing: re-run every * live query. Two callbacks would have been two things for `voltro dev` and * `voltro serve` to each wire, and this repo has the history to say how that * ends. */ export declare interface BroadcastGap { /** What happened, for the refresh's log line. */ readonly reason: string; /** The peer whose serial jumped — absent when the hole is this replica's own * (it was not subscribed). */ readonly origin?: string; /** EXACT count of missed messages, when it is known. Never an estimate. */ readonly missed?: number; } /** What the bus knows about its own liveness — for a readiness probe, a * support dump, or a log line. */ export declare interface BroadcastHealth { readonly provider: string; readonly channel: string; /** This replica's identity on the bus — what `origin` carries. */ readonly replicaId: string; readonly connected: boolean; /** Last envelope RECEIVED from another replica (epoch ms), or null. */ readonly lastReceivedAt: number | null; /** Last envelope PUBLISHED by this replica (epoch ms), or null. */ readonly lastPublishedAt: number | null; /** Transport reconnects observed since attach. */ readonly reconnects: number; /** Gaps reported (a reconnect, a serial hole) since attach. */ readonly gaps: number; /** Envelopes known missed inside those gaps. */ readonly missed: number; } /** * A broadcast plugin instance also exposes the resolved provider so the * CLI's serve pipeline can attach the bus to the live DataStore. The * extra fields ride on the returned `VoltroPlugin` and are read by the * framework via `getBroadcastProvider`. */ export declare interface BroadcastPlugin extends VoltroPlugin { readonly broadcast: { readonly provider: BroadcastProvider; readonly url: string | null; readonly crossReplica: boolean; /** * The namespace as DECLARED here, before the app name or env is consulted. * `undefined` means "derive it" — the framework resolves the effective value * at boot, because only it knows the app's name. */ readonly namespace: string | undefined; }; } export declare const broadcastPlugin: (options?: BroadcastPluginOptions) => BroadcastPlugin; export declare interface BroadcastPluginOptions { /** Provider name (`'redis' | 'nats' | 'memory'`) or a pre-built * `BroadcastProvider`. Default: inferred from `BROADCAST_URL` / * `BROADCAST_PROVIDER` / `REDIS_URL` env, else `'memory'`. */ readonly provider?: BroadcastProviderName | BroadcastProvider; /** Broker URL — a broker-agnostic override (redis:// or nats://). Takes * precedence over the named-connection env. Default `BROADCAST_URL` env. */ readonly url?: string; /** Named connection for the redis provider — resolves the broker URL via the * shared convention `_REDIS_URL` → `REDIS_URL` (like cache / kv / * ratelimit). Default `'broadcast'` (→ `BROADCAST_REDIS_URL`, then * `REDIS_URL`). Point broadcast at its own server or the shared one purely * by which env var you set. */ readonly connection?: string; /** * Namespace for EVERY framework channel on this broker — changes, events, * membership and presence alike. * * Defaults to your app's name, so two different apps sharing one Redis or * NATS separate without anyone remembering to do anything. Set it explicitly * for the case that default cannot see: **several deployments of the SAME app * on one broker** (staging and production, say) have the same name and the * same code, so nothing derivable tells them apart. There, this — or * `VOLTRO_BROADCAST_NAMESPACE` — is the only thing that works. */ readonly namespace?: string; /** Disambiguates multiple instances of this plugin in one app. */ readonly name?: string; } /** * Dumb pub/sub transport. One channel, string payloads, no framing. * * - `publish` — fire-and-forget a payload onto a channel. Transient * broker hiccups should fail the Effect (the caller logs + drops; * cross-replica reactivity degrades, local reactivity is untouched). * - `subscribe` — register a handler for every payload on a channel. * Returns an unsubscribe Effect. The handler is invoked once per * received message; it must not throw (errors are swallowed by the * wiring's decode guard). * - `close` — tear down connections / the pub + sub sockets. * * Implementations are constructed eagerly but connect lazily where the * backend allows, so a process that wires the plugin but never publishes * doesn't pay a connect cost on an unrelated path. */ export declare interface BroadcastProvider { /** Backend name — `'redis' | 'nats' | 'memory'`. Surfaced in the boot banner. */ readonly name: string; readonly publish: (channel: string, payload: string) => Effect.Effect; readonly subscribe: (channel: string, handler: (payload: string) => void) => Effect.Effect<() => void, BroadcastError>; readonly close: () => Effect.Effect; /** * Subscribe to the TRANSPORT's own connection lifecycle, when the backend * has one to report. * * A broker outage is not visible in `publish`/`subscribe`: a driver that * reconnects on its own hands back a working transport and says nothing, and * every message published while it was down is gone. The bus's serial * accounting catches that on the next message from a peer — but only if a * peer publishes again. On a quiet table, "no peer published again" and "we * are up to date" look identical, and one of them is stale forever. * * Optional: the memory provider has no connection to lose, and a provider a * user brings themselves need not implement it. Absent means the bus falls * back to serial-only detection, which is where it was before this existed. */ readonly onTransportEvent?: (listener: (event: BroadcastTransportEvent) => void) => () => void; } /** Provider id understood by `resolveBroadcastProvider`. */ export declare type BroadcastProviderName = 'redis' | 'nats' | 'memory'; /** The minimal store surface the bus drives — a subset of `DataStore` so * the bus stays decoupled from the concrete store classes. */ export declare interface BroadcastStore { onChange: (listener: (event: ChangeEvent) => void) => () => void; injectExternalChange: (event: ChangeEvent) => void; } export declare interface BroadcastTransportEvent { readonly kind: 'disconnected' | 'reconnected'; /** Driver detail for the log line — never parsed. */ readonly detail?: string; } /** * One framework channel inside a namespace. * * Every channel goes through here so a fifth one cannot be added as a flat * constant that quietly skips the namespace — which is how the first four came * to be flat in the first place. */ export declare const channelFor: (namespace: string, kind: BroadcastChannelKind) => string; /** * The namespace used when nothing else is known. * * Only reachable when there is no app name to derive from — a bare library use. * A real boot always has one. */ export declare const DEFAULT_BROADCAST_NAMESPACE = "voltro"; /** * Whether the resolved namespace differs from what was CONFIGURED — and if so, * from which source. * * Sanitising is correct and must stay: the value becomes a broker subject, and a * subject with whitespace is refused outright by NATS. But normalising silently * has a hazard of its own, and it is exactly the one this namespace exists to * remove. `my app` and `my.app` both resolve to `my-app`, so two deployments * configured DIFFERENTLY share a channel — which is the failure the option was * added to prevent, arrived at by the option itself. * * Nothing here refuses: the resolved value is safe either way, and a boot * failure over a dot would be worse than the collapse. The boot logs it instead, * so an operator who wrote `prod env` sees `prod-env` and can spot two * environments landing in one place. */ export declare const describeNamespaceResolution: (input: ResolveBroadcastNamespaceInput) => { readonly resolved: string; readonly configured: string | undefined; readonly changed: boolean; }; /** * The channel for ONE declared event. * * Per event rather than per app, so a replica can decline the traffic it has no * subscribers for. The event name comes from a declaration and is unique by boot * audit, but it is sanitised anyway: a name containing a NATS wildcard would * turn one event's channel into a pattern matching others. */ export declare const eventChannelFor: (namespace: string, event: string) => string; /** * Pull the resolved broadcast carrier off a plugin list (the framework's * serve pipeline calls this after building the store). Returns the FIRST * broadcast plugin found, or `null`. */ export declare const getBroadcastPlugin: (plugins: ReadonlyArray) => BroadcastPlugin | null; /** * In-process pub/sub over a named bus. Delivery is synchronous within the * process. Two providers sharing a `bus` name see each other's publishes — * the seam the cross-instance test exercises without a real broker. */ export declare const memoryProvider: (bus?: string) => BroadcastProvider; declare interface NatsLike { publish: (subject: string, data: Uint8Array) => void; subscribe: (subject: string) => NatsSubscription; drain: () => Promise; /** nats.js' connection events. Read for two reasons: to report a reconnect as * the hole it is, and to notice a connection that gave up. */ status: () => AsyncIterable<{ type: string; data?: unknown; }>; } export declare const natsProvider: (options: NatsProviderOptions) => BroadcastProvider; export declare interface NatsProviderOptions { /** `nats://host:port` (comma-separated for a cluster). Required. */ readonly url: string; /** Bring your own connected NATS connection (e.g. for shared creds). */ readonly connection?: NatsLike; } declare interface NatsSubscription extends AsyncIterable<{ data: Uint8Array; }> { unsubscribe: () => void; } /** The slice of ioredis the provider touches — keeps the optional dep out * of the type surface. */ declare interface RedisLike { publish: (channel: string, message: string) => Promise; subscribe: (channel: string) => Promise; unsubscribe: (channel: string) => Promise; on(event: 'message', listener: (channel: string, message: string) => void): void; /** ioredis' connection lifecycle. The subscriber re-subscribes its channels * itself on `ready`; what it does NOT do is tell anyone that the messages * sent while it was away are gone. That is what these are read for. */ on(event: 'ready' | 'reconnecting' | 'end' | 'close', listener: () => void): void; /** `error` is REQUIRED, not optional decoration: an `EventEmitter` with no * `error` listener turns the event into an uncaught exception. See the * registration below. */ on(event: 'error', listener: (err: unknown) => void): void; off(event: 'message', listener: (channel: string, message: string) => void): void; off(event: 'ready' | 'reconnecting' | 'end' | 'close', listener: () => void): void; duplicate: () => RedisLike; quit: () => Promise; } export declare const redisProvider: (options: RedisProviderOptions) => BroadcastProvider; export declare interface RedisProviderOptions { /** `redis://[:pass@]host:port[/db]`. Required. */ readonly url: string; /** Bring your own ioredis client (publisher). A `subscriber` is * `client.duplicate()`d internally — a RESP connection in subscribe * mode can't issue normal commands. */ readonly client?: RedisLike; } /* Excluded from this release type: resetMemoryBus */ export declare const resolveBroadcastNamespace: (input: ResolveBroadcastNamespaceInput) => string; export declare interface ResolveBroadcastNamespaceInput { /** `broadcast({ namespace })` — explicit, and wins over everything. */ readonly option?: string | undefined; /** `VOLTRO_BROADCAST_NAMESPACE` — for the case the code cannot see. */ readonly env?: string | undefined; /** The app's name, the default. Different apps therefore separate on their own. */ readonly appName?: string | undefined; } export declare interface ResolveBroadcastOptions { /** Explicit provider name or a pre-built `BroadcastProvider`. */ readonly provider?: BroadcastProviderName | BroadcastProvider; /** Broker URL — a broker-agnostic override (redis:// or nats://). Takes * precedence over the named-connection env. Default `BROADCAST_URL` env. */ readonly url?: string; /** Named connection for the redis provider. The broker URL resolves via the * shared convention `_REDIS_URL` → `REDIS_URL` (like cache / kv / * ratelimit), so a deployment points broadcast at the same server or its own * purely by env. Default `'broadcast'` (→ `BROADCAST_REDIS_URL`). */ readonly connection?: string; } /** * Resolve the bus backend. Order: an object provider is used verbatim; a * named provider builds from `url`/env; absent → `BROADCAST_PROVIDER` env, * else inferred from a `redis://` / `nats://` URL, else `memory`. * * Returns the provider plus the URL it resolved (for the boot banner) and * whether the choice is a real cross-replica bus or the single-process * memory fallback. */ export declare const resolveBroadcastProvider: (options: ResolveBroadcastOptions, env: NodeJS.ProcessEnv) => { provider: BroadcastProvider; url: string | null; crossReplica: boolean; }; /** Retry cadence for a subscribe that could not land: 500ms doubling to a 10s * ceiling, forever. A bus that gives up subscribing is a replica that is * permanently deaf and says so once, which is the state this retry exists to * make impossible. */ export declare const subscribeRetryDelayMs: (attempt: number) => number; export { }