import { DataStore } from '@voltro/database'; import { Effect } from 'effect'; import { ReactivityChannel } from '@voltro/protocol'; import { Schema } from 'effect'; import { VoltroPlugin } from '@voltro/protocol'; import { YieldableError } from 'effect/Cause'; /** * Wire a tracker to the transport and the membership registry. * * Returns a working tracker EVEN WITH NO PROVIDER — a single-instance * deployment is just a cluster of one, and its roster is complete. Making the * provider optional here rather than branching at the call site is what keeps * "works in dev, silently different in production" off the table. */ export declare const attachPresenceBus: (options: AttachPresenceBusOptions) => PresenceBusHandle; export declare interface AttachPresenceBusOptions { readonly instanceId: string; readonly membership: PresenceMembership; /** Absent ⇒ single instance. The tracker still works; nothing is broadcast. */ readonly provider?: PresenceTransport | undefined; readonly logger?: { readonly warn: (m: string, f?: Record) => void; }; /** The namespaced presence channel, from the framework's one resolver. */ readonly channel?: string | undefined; /** * Called when a REMOTE event moved this replica's roster — a peer's delta, or * a peer's departure taking its partition with it. * * Only for the remote paths. A local heartbeat's push is the caller's, because * only the caller knows whether the write it just made changed anything; * routing it through here as well would give one fact two owners. */ readonly onRosterChanged?: (() => void) | undefined; readonly now?: () => number; } /** * Its own channel, for the same reason events are not on the change channel — * and namespaced by the framework, which passes the resolved value in. * * This constant is the fallback for a single-app broker and for tests. It is NOT * the normal path: a flat name is what let two apps on one Redis apply each * other's presence deltas, and adding a member nobody is connected to is a * member that can never leave — there is no owner to time out. */ export declare const PRESENCE_BROADCAST_CHANNEL = "voltro:presence"; export declare interface PresenceBusHandle { readonly tracker: PresenceTracker; /** * Whether a cross-instance transport is attached. * * Exposed so the plugin can WARN at boot. Without one every replica keeps a * correct roster of its own clients and nothing errors — which is why the * multi-replica failure looks exactly like success on a single box. */ readonly hasTransport: boolean; /** Broadcast one local change. Returns immediately; delivery is best-effort. */ readonly announce: (delta: PresenceDelta) => void; readonly detach: () => void; } /** The wire shape of one change, broadcast by the owning instance. */ export declare type PresenceDelta = { readonly op: 'set'; readonly channel: string; readonly tenantId: string | null; readonly key: string; readonly meta: Record | null; readonly lastSeen: number; } | { readonly op: 'remove'; readonly channel: string; readonly tenantId: string | null; readonly key: string; }; /** The caller has no presence identity — an anonymous subject (no subject id) * that also passed no explicit `key`. There's nothing stable to register a * heartbeat under, so the route fails typed instead of silently no-opping. * Pass `usePresence(channel, { key })` (or authenticate) to supply one. */ export declare class PresenceKeyMissing extends PresenceKeyMissing_base { } declare const PresenceKeyMissing_base: Schema.Class, YieldableError>; export declare interface PresenceMember { /** Stable per-client key — the subject id, or a client-supplied one. */ readonly key: string; readonly meta: Record | null; /** The owning instance's clock when it last saw a heartbeat. Compared only * against other timestamps from THAT owner. */ readonly lastSeen: number; } /** The one method of the membership registry this needs. */ export declare interface PresenceMembership { readonly onChange: (listener: (event: { readonly kind: 'joined' | 'left' | 'restarted'; readonly instanceId: string; }) => void) => () => void; } export declare const presencePlugin: (options?: PresencePluginOptions) => VoltroPlugin; export declare interface PresencePluginOptions { /** A member counts as online for this long after its last heartbeat. Default 30s. */ readonly timeoutMs?: number; /** * How often to sweep members whose heartbeat has gone stale. * * Defaults to a THIRD of `timeoutMs`, so a vanished member is gone within * roughly 1.3x the online window. Set it explicitly only if you need that * detection delay tightened or relaxed independently of the window itself — * the default is the right relationship for almost every room. */ readonly sweepIntervalMs?: number; /** * Resolve the fields OTHER members see about this one, server-side, from the * authenticated subject — display name, avatar URL, anything identity. * * Without it, `meta` is CLIENT-supplied end to end: the heartbeat takes it * from the caller and the roster hands it to every other member of the * channel verbatim, unvalidated. That is the right contract for a cursor or * a status flag — and the wrong one for identity, because it lets any member * present any name and any `` to everyone else. A deployment nearly * shipped exactly that migration path: their previous hand-rolled presence * resolved name/avatar from the subject's user row on the server, and the * move onto this plugin silently turned those into caller-filled fields. * * Resolved fields are merged OVER the client's `meta` (`{ ...meta, * ...resolved }`). Runs on every heartbeat — keep it cheap or cached; the * roster key is already the subject id, so a by-id lookup is the whole job. * May return a Promise. * * READ THE MERGE PRECISELY, because the obvious resolver has a hole in it. * A key the resolver DOES NOT return is not overwritten — it keeps whatever * the client sent. So a resolver that returns only what it found * (`{ name }` for a user with no avatar) leaves a client-supplied * `avatarUrl` — or a `userName` the resolver has never heard of — standing * in the roster every other member reads. That is the exact substitution * this option exists to prevent, and it was reported by a deployment that * hit it while adopting the option. Two ways to close it, and declaring is * the better one: * * - {@link PresencePluginOptions.identityFields} — name the keys the SERVER * owns. They are stripped from the client's `meta` BEFORE the merge, so a * key the resolver happens not to return this time is absent rather than * caller-controlled. * - return every identity key on every call, `null` for the ones you have * no value for. * * `store` is the app's DataStore, handed over at boot — the resolver needs a * by-id read and `app.config.ts` is evaluated long before there is a store * to close over, which otherwise forces a module cell filled from a * `*.startup.ts`. */ readonly resolveMember?: (input: { readonly subject: unknown; readonly channel: string; readonly key: string; /** The app's store. Read-only in practice — a heartbeat is not a write. */ readonly store: DataStore; }) => Record | undefined | Promise | undefined>; /** * The `meta` keys the SERVER owns — stripped from the client's payload * before {@link PresencePluginOptions.resolveMember} merges over it. * * Without this, "the server decides identity" holds only for the keys the * resolver returned on THIS call (see the note there). With it, the answer * is the same on every call: a listed key is never caller-supplied, whether * or not the resolver produced a value for it. * * identityFields: ['name', 'avatarUrl', 'userName'] * * Ignored when no `resolveMember` is declared: with no server-side identity * to protect, stripping a client field would just delete data the app put * there deliberately. */ readonly identityFields?: ReadonlyArray; /** * Namespace for this plugin's rpc tags. Default `presence`. * * Set it when your app already publishes under that name — an exact tag * collision is fatal at codegen, and this is the way out. Orthogonal to * `name` below: `alias` REPLACES the namespace, `name` distinguishes two * installations within it. */ readonly alias?: string; /** * Discriminator for a SECOND installation of this plugin, when one app runs * two (`@voltro/plugin-presence#analytics`). Not a rename — for that use * `alias`. */ readonly name?: string; } /** * The roster's reactivity channel — presence's push target. * * Declared HERE, in the browser-safe descriptor module, for two reasons. It is * the `source:` of `listDescriptor`, so the client descriptor and the server * route now carry the SAME value instead of the server adding one the client * never saw. And the server's `publishReactivity` imports this exact object, so * the thing that publishes and the thing that subscribes cannot name two * different keys — the failure a pair of strings makes possible and silent. * * It replaces `_voltro_presence`, a table that was declared and deliberately * never written purely to own this name. */ export declare const presenceRoster: ReactivityChannel; /** The presence store isn't bound — `bindDataStore` never ran. This is a boot/ * wiring bug (the plugin wasn't installed on an app with a DataStore), not a * per-call condition, so it fails loud rather than returning an empty roster. */ export declare class PresenceStoreUnavailable extends PresenceStoreUnavailable_base { } declare const PresenceStoreUnavailable_base: Schema.Class, YieldableError>; /** * The merged presence view this instance can see. * * Owner-partitioned: `owner → route → key → member`. Every write names its * owner, so dropping a departed instance is deleting one branch rather than * scanning for its entries — which matters when a pod dies holding a thousand * connections and the roster must settle in one operation, not a thousand. */ export declare class PresenceTracker { private readonly partitions; private readonly selfId; private readonly now; constructor(options: { readonly instanceId: string; readonly now?: () => number; }); /** This instance's id — the owner stamped on everything it tracks. */ get owner(): string; private branch; private removeMember; /** * Record a heartbeat from a client THIS instance holds. * * Returns the delta to broadcast, so the caller cannot forget to — a local * write that is not announced is a member the other replicas never see, and * nothing would report it. It returns `changed` beside it for the same kind of * reason: a caller that has to derive "did this move the roster" would derive * it from the delta, which cannot answer the question. */ track(input: { readonly channel: string; readonly tenantId: string | null; readonly key: string; readonly meta?: Record | null; }): PresenceWrite; /** A client of THIS instance left. */ untrack(input: { readonly channel: string; readonly tenantId: string | null; readonly key: string; }): PresenceWrite; /** * Apply a delta from ANOTHER instance. Returns whether the roster moved. * * Our own owner id is ignored: the local write already happened synchronously * in `track`, and re-applying the echo would be harmless today but would make * the local map depend on the broker — so a broker outage would silently stop * this instance from seeing its OWN clients. * * The return value is what lets the bus push a remote join to LOCAL * subscribers. Until it existed, nothing did: a member who joined on replica A * appeared on replica B's screens only when one of B's own clients happened to * heartbeat, and the unconditional push on that heartbeat is what hid it. */ apply(owner: string, delta: PresenceDelta): boolean; /** * An instance is gone — drop everything it owned, in one operation. * * This is the whole reason membership had to be built. Its clients are not * connected to anything any more, and no message will ever say so: a crashing * process does not send a goodbye, and on the event channel it is simply * quiet. Without this the roster keeps showing them forever. */ dropOwner(owner: string): number; /** * Drop OUR OWN members that have stopped heartbeating, and return the deltas * to broadcast. * * The comment on `roster` used to argue that no staleness filter was needed * because "an entry here leaves when its client does". That premise was * false: an entry left only when the client explicitly CALLED leave. A closed * laptop, a dropped network or a crashed tab call nothing, and the owning * replica is still alive so `dropOwner` never fires either — so those members * stayed in the roster forever and every screen showed people who had gone. * * ONLY our own partition. Another owner's entries carry timestamps from THEIR * clock, and judging them against ours is precisely the mistake instance * membership exists to avoid: a peer that is gone is dropped whole, on a * signal, not on a guess about clock skew. That asymmetry is the design — * every entry is owned by exactly one instance, and only its owner can say * whether it is still there. * * Returns the removals rather than being void for the same reason `track` * does: a local removal nobody broadcasts is a member every OTHER replica * keeps showing. */ sweep(timeoutMs: number): ReadonlyArray; /** Everything this instance owns — what a newly-seen peer must be told. */ ownSnapshot(): ReadonlyArray; /** * The roster for one channel, in one tenant. * * TENANT-SCOPED AND FAIL-CLOSED, exactly as the table version was: a caller * only ever sees members whose tenant equals theirs, and an anonymous caller * (`null`) sees only anonymous members. A same-named channel in another * tenant is invisible, not merged. * * No staleness filter IN THE READ — the sweep does it on the write side * instead, and the distinction matters. * * This comment used to argue that no staleness handling was needed at all, * because "an entry is removed when the client leaves". That was false: an * entry left only when a client explicitly CALLED leave. A closed laptop, a * dropped network or a crashed tab call nothing, and the owning replica is * still alive so `dropOwner` never fires either — so those members stayed in * the roster forever and every screen showed people who had gone home. * * `sweep()` removes them on the OWNER's side, on a timer, and announces the * removals. Reading stays filter-free because by the time a read happens the * stale entries are already gone — which is the property the original comment * claimed and did not have. */ roster(channel: string, tenantId: string | null): ReadonlyArray; /** Owners currently holding at least one member — for inspect. */ owners(): ReadonlyArray<{ readonly owner: string; readonly members: number; }>; /** Total tracked members across every owner and channel. */ size(): number; } /** The two methods of a broadcast provider this needs. */ export declare interface PresenceTransport { readonly publish: (channel: string, payload: string) => Effect.Effect; readonly subscribe: (channel: string, handler: (payload: string) => void) => Effect.Effect<() => void, unknown>; } /** * A local write, and whether it MOVED the roster. * * The second field is the whole reason this is not just a `PresenceDelta`. A * heartbeat arrives every 15 seconds per client and almost never changes * anything a subscriber can observe — the member was already there with the same * metadata. Pushing on it anyway made the cost quadratic: N clients heartbeating * into a room woke all N subscribers each time, N² wake-ups per interval, for a * roster that had not moved. * * `changed` is exact rather than approximate because `lastSeen` is no longer on * the wire (see `rpc.ts`). It is a heartbeat's ONLY effect in the steady state, * so while it was observable, "did anything change" could only ever be answered * "yes". */ export declare interface PresenceWrite { /** The delta to broadcast. Always present — a peer must learn of a heartbeat * even when the roster did not move, or its own sweep will drop the member. */ readonly delta: PresenceDelta; /** Whether a local subscriber would see a different roster. */ readonly changed: boolean; } /** One member as the roster reports it, with the owner it belongs to. */ export declare interface TrackedMember extends PresenceMember { readonly owner: string; readonly channel: string; readonly tenantId: string | null; } export { }