@rotorsoft/act-http 1.9.4 → 1.9.5

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.
@@ -5,7 +5,7 @@ import {
5
5
  runSseSubscription,
6
6
  toApiError,
7
7
  withIdempotency
8
- } from "../chunk-IKLTEKBL.js";
8
+ } from "../chunk-BNEUODIM.js";
9
9
  import "../chunk-WGFX4G27.js";
10
10
  import "../chunk-PZ5AY32C.js";
11
11
 
@@ -36,7 +36,8 @@ function applyPatchMessage(msg, cached) {
36
36
  const cachedV = cached?._v ?? -1;
37
37
  const versions = Object.keys(msg).map(Number).filter((v) => Number.isInteger(v)).sort((a, b) => a - b);
38
38
  if (msg._resync) return { ok: false, reason: "behind" };
39
- if (!versions.length) return { ok: false, reason: "stale" };
39
+ if (!versions.length)
40
+ return { ok: false, reason: hasBaseline ? "stale" : "behind" };
40
41
  const minV = versions[0];
41
42
  const maxV = versions[versions.length - 1];
42
43
  if (msg._overlay) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // Distinguish an *absent* baseline from a real version-0 baseline (#1346).\n // Act assigns the first event of any stream `version = 0`, so a fresh client\n // (no cache) must resume from a pre-baseline watermark of -1 — otherwise the\n // `?? 0` default collides with a genuine version-0 genesis patch and drops\n // it as \"stale\". A present baseline keeps its own `_v`.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else — a resync frame carries no versions, and the empty-frame\n // branch below would otherwise report it as `stale`, which is the one\n // answer that does NOT refetch (#1423).\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n if (!versions.length) return { ok: false, reason: \"stale\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled EXHAUSTIVELY here — an overlay must never reach\n // the contiguous fold below, which would apply its payload as if it were\n // that version's domain patch. At exactly `cachedV + 1` that fold made the\n // client adopt presence data as version N, stamp itself caught up, and\n // never refetch — the real update for N lost permanently (#1419).\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { log } from \"@rotorsoft/act\";\nimport { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\n/**\n * Deliver a frame to every subscriber, containing each one individually.\n *\n * SSE subscribers are the least-trusted callbacks in the system — one per\n * connection, driven by network state. The unguarded loop this replaces let\n * the first thrower abort iteration, so every later subscriber lost the\n * frame and the exception escaped into the host's commit path (#1423). Same\n * containment principle the orchestrator applies to lifecycle listeners.\n * Guarding each callback rather than the loop is the point: one guard around\n * the whole loop would still let the first throw suppress the rest.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite `undefined`-valued keys to `null`, recursively.\n *\n * `@rotorsoft/act-patch` treats `undefined` and `null` as the same delete\n * signal, but `JSON.stringify` drops `undefined`-valued keys entirely — and\n * every SSE transport serializes frames as JSON. A reducer that clears a\n * field the idiomatic way (`{ left: undefined }`) therefore produced a frame\n * with no mention of `left` at all, so a live client kept the stale value\n * while stamping the new version — believing itself caught up, and never\n * refetching (#1471).\n *\n * `null` reaches the same `delete` branch in the patch applicator and\n * survives JSON, so normalizing here makes the two spellings equivalent on\n * the wire as they already are in memory.\n *\n * Only the broadcast frame is normalized. Server-side state is applied\n * through `apply_patch`, which handles `undefined` natively.\n */\nconst wire_safe = <T>(patch: T): T => {\n if (patch === null || typeof patch !== \"object\") return patch;\n if (Array.isArray(patch)) return patch.map(wire_safe) as T;\n // A Set has exactly one sensible JSON encoding, and `JSON.stringify` gives\n // it the wrong one: `{}`. The framework's own `PresenceTracker.online()`\n // returns a Set and the presence recipe feeds it straight to `overlay()`,\n // so the documented way to broadcast presence shipped an empty object to\n // every client — and then froze, because a client holding `{}` treats\n // every later empty patch as a no-op (#1472).\n if (patch instanceof Set) return [...patch].map(wire_safe) as T;\n // Everything else non-plain (Date, Map, class instances) is left to\n // whatever the host's serializer already does with it. `Date` has a\n // defined encoding; `Map` does not have an unambiguous one (entries or\n // object?), so guessing would trade a visible bug for a silent choice.\n const proto = Object.getPrototypeOf(patch);\n if (proto !== Object.prototype && proto !== null) return patch;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(patch as Record<string, unknown>))\n out[k] = v === undefined ? null : wire_safe(v);\n return out as T;\n};\n\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n /**\n * @param options.cacheSize - Max number of stream states kept in the LRU\n * cache (default 50).\n */\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n cacheSize?: number;\n /**\n * Called when `overlay()` finds no cached baseline for the stream, so\n * the update cannot be broadcast. Live subscribers receive nothing and\n * have no way to notice — a host that cares should raise `cacheSize`,\n * re-`publish` the stream, or push the viewers to refetch. Defaults to a\n * no-op so existing behavior is unchanged apart from being observable.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher (#1423). Defaults to the\n * framework's `log()` port, so it lands wherever the host already\n * routes framework logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ??\n ((error, streamId) =>\n log().error(error, `sse subscriber threw for \"${streamId}\"`));\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n this.state_cache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to read-modify-write, so nothing is broadcast — and\n // because no frame is emitted there is nothing for a live client to\n // classify as `behind`, so it never refetches either. Unlike a domain\n // commit (which repopulates the cache via `publish`), an overlay-only\n // stream never recovers. The defaults make this reachable rather than\n // exotic: `cacheSize` is 50 while a host may hold far more live\n // subscriptions, and cache promotion happens at connect time, so a\n // busy-but-not-committing stream ages out while fully subscribed.\n //\n // Emit a resync frame so live subscribers refetch instead of silently\n // missing the update forever (#1423). `on_overlay_miss` still fires so\n // a host can count these — a steady stream of them means `cacheSize`\n // is too small for the working set.\n this.on_overlay_miss(streamId);\n const resync: PatchMessage<S> = { _resync: true } as PatchMessage<S>;\n fan_out(this.channels.get(streamId), resync, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n // Still `undefined`: no overlay state was produced, and callers use the\n // return value as \"the patch I broadcast\", which a resync is not.\n return undefined;\n }\n\n // Normalize BEFORE applying to the cache, so a reconnecting client's\n // reseed and a live client's frame agree (#1472). `apply_patch` treats\n // `null` and `undefined` as the same delete signal, so normalizing first\n // does not change the delete semantics — it only fixes the encodings\n // that would not survive JSON.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(streamId, state);\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n this.cache.delete(this.cache.keys().next().value!);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["import_act_patch","deep_merge","import_act_patch","patch","apply_patch"]}
1
+ {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // Distinguish an *absent* baseline from a real version-0 baseline (#1346).\n // Act assigns the first event of any stream `version = 0`, so a fresh client\n // (no cache) must resume from a pre-baseline watermark of -1 — otherwise the\n // `?? 0` default collides with a genuine version-0 genesis patch and drops\n // it as \"stale\". A present baseline keeps its own `_v`.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else — a resync frame carries no versions, and the empty-frame\n // branch below would otherwise report it as `stale`, which is the one\n // answer that does NOT refetch (#1423).\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply. For a client that already has a\n // baseline that is genuinely `stale` — a no-op, don't refetch. For one with\n // NO baseline there is nothing to be stale relative to, and `stale` is the\n // one answer that does not refetch, so it would strand the client (#1474).\n // Both the doc-comment below and `real-time.md` already say a fresh client\n // is never stale; this is the branch that made that false.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled EXHAUSTIVELY here — an overlay must never reach\n // the contiguous fold below, which would apply its payload as if it were\n // that version's domain patch. At exactly `cachedV + 1` that fold made the\n // client adopt presence data as version N, stamp itself caught up, and\n // never refetch — the real update for N lost permanently (#1419).\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { log } from \"@rotorsoft/act\";\nimport { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\n/**\n * Deliver a frame to every subscriber, containing each one individually.\n *\n * SSE subscribers are the least-trusted callbacks in the system — one per\n * connection, driven by network state. The unguarded loop this replaces let\n * the first thrower abort iteration, so every later subscriber lost the\n * frame and the exception escaped into the host's commit path (#1423). Same\n * containment principle the orchestrator applies to lifecycle listeners.\n * Guarding each callback rather than the loop is the point: one guard around\n * the whole loop would still let the first throw suppress the rest.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite `undefined`-valued keys to `null`, recursively.\n *\n * `@rotorsoft/act-patch` treats `undefined` and `null` as the same delete\n * signal, but `JSON.stringify` drops `undefined`-valued keys entirely — and\n * every SSE transport serializes frames as JSON. A reducer that clears a\n * field the idiomatic way (`{ left: undefined }`) therefore produced a frame\n * with no mention of `left` at all, so a live client kept the stale value\n * while stamping the new version — believing itself caught up, and never\n * refetching (#1471).\n *\n * `null` reaches the same `delete` branch in the patch applicator and\n * survives JSON, so normalizing here makes the two spellings equivalent on\n * the wire as they already are in memory.\n *\n * Only the broadcast frame is normalized. Server-side state is applied\n * through `apply_patch`, which handles `undefined` natively.\n */\nconst wire_safe = <T>(patch: T): T => {\n if (patch === null || typeof patch !== \"object\") return patch;\n if (Array.isArray(patch)) return patch.map(wire_safe) as T;\n // A Set has exactly one sensible JSON encoding, and `JSON.stringify` gives\n // it the wrong one: `{}`. The framework's own `PresenceTracker.online()`\n // returns a Set and the presence recipe feeds it straight to `overlay()`,\n // so the documented way to broadcast presence shipped an empty object to\n // every client — and then froze, because a client holding `{}` treats\n // every later empty patch as a no-op (#1472).\n if (patch instanceof Set) return [...patch].map(wire_safe) as T;\n // Everything else non-plain (Date, Map, class instances) is left to\n // whatever the host's serializer already does with it. `Date` has a\n // defined encoding; `Map` does not have an unambiguous one (entries or\n // object?), so guessing would trade a visible bug for a silent choice.\n const proto = Object.getPrototypeOf(patch);\n if (proto !== Object.prototype && proto !== null) return patch;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(patch as Record<string, unknown>))\n out[k] = v === undefined ? null : wire_safe(v);\n return out as T;\n};\n\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n /**\n * @param options.cacheSize - Max number of stream states kept in the LRU\n * cache (default 50).\n */\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n cacheSize?: number;\n /**\n * Called when `overlay()` finds no cached baseline for the stream, so\n * the update cannot be broadcast. Live subscribers receive nothing and\n * have no way to notice — a host that cares should raise `cacheSize`,\n * re-`publish` the stream, or push the viewers to refetch. Defaults to a\n * no-op so existing behavior is unchanged apart from being observable.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher (#1423). Defaults to the\n * framework's `log()` port, so it lands wherever the host already\n * routes framework logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ??\n ((error, streamId) =>\n log().error(error, `sse subscriber threw for \"${streamId}\"`));\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n this.state_cache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to read-modify-write, so nothing is broadcast — and\n // because no frame is emitted there is nothing for a live client to\n // classify as `behind`, so it never refetches either. Unlike a domain\n // commit (which repopulates the cache via `publish`), an overlay-only\n // stream never recovers. The defaults make this reachable rather than\n // exotic: `cacheSize` is 50 while a host may hold far more live\n // subscriptions, and cache promotion happens at connect time, so a\n // busy-but-not-committing stream ages out while fully subscribed.\n //\n // Emit a resync frame so live subscribers refetch instead of silently\n // missing the update forever (#1423). `on_overlay_miss` still fires so\n // a host can count these — a steady stream of them means `cacheSize`\n // is too small for the working set.\n this.on_overlay_miss(streamId);\n const resync: PatchMessage<S> = { _resync: true } as PatchMessage<S>;\n fan_out(this.channels.get(streamId), resync, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n // Still `undefined`: no overlay state was produced, and callers use the\n // return value as \"the patch I broadcast\", which a resync is not.\n return undefined;\n }\n\n // Normalize BEFORE applying to the cache, so a reconnecting client's\n // reseed and a live client's frame agree (#1472). `apply_patch` treats\n // `null` and `undefined` as the same delete signal, so normalizing first\n // does not change the delete semantics — it only fixes the encodings\n // that would not survive JSON.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(streamId, state);\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n this.cache.delete(this.cache.keys().next().value!);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["import_act_patch","deep_merge","import_act_patch","patch","apply_patch"]}
package/dist/sse/index.js CHANGED
@@ -10,7 +10,8 @@ function applyPatchMessage(msg, cached) {
10
10
  const cachedV = cached?._v ?? -1;
11
11
  const versions = Object.keys(msg).map(Number).filter((v) => Number.isInteger(v)).sort((a, b) => a - b);
12
12
  if (msg._resync) return { ok: false, reason: "behind" };
13
- if (!versions.length) return { ok: false, reason: "stale" };
13
+ if (!versions.length)
14
+ return { ok: false, reason: hasBaseline ? "stale" : "behind" };
14
15
  const minV = versions[0];
15
16
  const maxV = versions[versions.length - 1];
16
17
  if (msg._overlay) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // Distinguish an *absent* baseline from a real version-0 baseline (#1346).\n // Act assigns the first event of any stream `version = 0`, so a fresh client\n // (no cache) must resume from a pre-baseline watermark of -1 — otherwise the\n // `?? 0` default collides with a genuine version-0 genesis patch and drops\n // it as \"stale\". A present baseline keeps its own `_v`.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else — a resync frame carries no versions, and the empty-frame\n // branch below would otherwise report it as `stale`, which is the one\n // answer that does NOT refetch (#1423).\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n if (!versions.length) return { ok: false, reason: \"stale\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled EXHAUSTIVELY here — an overlay must never reach\n // the contiguous fold below, which would apply its payload as if it were\n // that version's domain patch. At exactly `cachedV + 1` that fold made the\n // client adopt presence data as version N, stamp itself caught up, and\n // never refetch — the real update for N lost permanently (#1419).\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { log } from \"@rotorsoft/act\";\nimport { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\n/**\n * Deliver a frame to every subscriber, containing each one individually.\n *\n * SSE subscribers are the least-trusted callbacks in the system — one per\n * connection, driven by network state. The unguarded loop this replaces let\n * the first thrower abort iteration, so every later subscriber lost the\n * frame and the exception escaped into the host's commit path (#1423). Same\n * containment principle the orchestrator applies to lifecycle listeners.\n * Guarding each callback rather than the loop is the point: one guard around\n * the whole loop would still let the first throw suppress the rest.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite `undefined`-valued keys to `null`, recursively.\n *\n * `@rotorsoft/act-patch` treats `undefined` and `null` as the same delete\n * signal, but `JSON.stringify` drops `undefined`-valued keys entirely — and\n * every SSE transport serializes frames as JSON. A reducer that clears a\n * field the idiomatic way (`{ left: undefined }`) therefore produced a frame\n * with no mention of `left` at all, so a live client kept the stale value\n * while stamping the new version — believing itself caught up, and never\n * refetching (#1471).\n *\n * `null` reaches the same `delete` branch in the patch applicator and\n * survives JSON, so normalizing here makes the two spellings equivalent on\n * the wire as they already are in memory.\n *\n * Only the broadcast frame is normalized. Server-side state is applied\n * through `apply_patch`, which handles `undefined` natively.\n */\nconst wire_safe = <T>(patch: T): T => {\n if (patch === null || typeof patch !== \"object\") return patch;\n if (Array.isArray(patch)) return patch.map(wire_safe) as T;\n // A Set has exactly one sensible JSON encoding, and `JSON.stringify` gives\n // it the wrong one: `{}`. The framework's own `PresenceTracker.online()`\n // returns a Set and the presence recipe feeds it straight to `overlay()`,\n // so the documented way to broadcast presence shipped an empty object to\n // every client — and then froze, because a client holding `{}` treats\n // every later empty patch as a no-op (#1472).\n if (patch instanceof Set) return [...patch].map(wire_safe) as T;\n // Everything else non-plain (Date, Map, class instances) is left to\n // whatever the host's serializer already does with it. `Date` has a\n // defined encoding; `Map` does not have an unambiguous one (entries or\n // object?), so guessing would trade a visible bug for a silent choice.\n const proto = Object.getPrototypeOf(patch);\n if (proto !== Object.prototype && proto !== null) return patch;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(patch as Record<string, unknown>))\n out[k] = v === undefined ? null : wire_safe(v);\n return out as T;\n};\n\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n /**\n * @param options.cacheSize - Max number of stream states kept in the LRU\n * cache (default 50).\n */\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n cacheSize?: number;\n /**\n * Called when `overlay()` finds no cached baseline for the stream, so\n * the update cannot be broadcast. Live subscribers receive nothing and\n * have no way to notice — a host that cares should raise `cacheSize`,\n * re-`publish` the stream, or push the viewers to refetch. Defaults to a\n * no-op so existing behavior is unchanged apart from being observable.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher (#1423). Defaults to the\n * framework's `log()` port, so it lands wherever the host already\n * routes framework logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ??\n ((error, streamId) =>\n log().error(error, `sse subscriber threw for \"${streamId}\"`));\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n this.state_cache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to read-modify-write, so nothing is broadcast — and\n // because no frame is emitted there is nothing for a live client to\n // classify as `behind`, so it never refetches either. Unlike a domain\n // commit (which repopulates the cache via `publish`), an overlay-only\n // stream never recovers. The defaults make this reachable rather than\n // exotic: `cacheSize` is 50 while a host may hold far more live\n // subscriptions, and cache promotion happens at connect time, so a\n // busy-but-not-committing stream ages out while fully subscribed.\n //\n // Emit a resync frame so live subscribers refetch instead of silently\n // missing the update forever (#1423). `on_overlay_miss` still fires so\n // a host can count these — a steady stream of them means `cacheSize`\n // is too small for the working set.\n this.on_overlay_miss(streamId);\n const resync: PatchMessage<S> = { _resync: true } as PatchMessage<S>;\n fan_out(this.channels.get(streamId), resync, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n // Still `undefined`: no overlay state was produced, and callers use the\n // return value as \"the patch I broadcast\", which a resync is not.\n return undefined;\n }\n\n // Normalize BEFORE applying to the cache, so a reconnecting client's\n // reseed and a live client's frame agree (#1472). `apply_patch` treats\n // `null` and `undefined` as the same delete signal, so normalizing first\n // does not change the delete semantics — it only fixes the encodings\n // that would not survive JSON.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(streamId, state);\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n this.cache.delete(this.cache.keys().next().value!);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":";;;AAuCA,SAAS,aAAa;;;ACvCtB,SAAS,SAAS,kBAAkB;AAuC7B,SAAS,kBACd,KACA,QACgB;AAMhB,QAAM,cAAc,UAAU;AAC9B,QAAM,UAAU,QAAQ,MAAM;AAE9B,QAAM,WAAW,OAAO,KAAK,GAAG,EAC7B,IAAI,MAAM,EACV,OAAO,CAAC,MAAM,OAAO,UAAU,CAAC,CAAC,EACjC,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC;AAOvB,MAAI,IAAI,QAAS,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAEtD,MAAI,CAAC,SAAS,OAAQ,QAAO,EAAE,IAAI,OAAO,QAAQ,QAAQ;AAE1D,QAAM,OAAO,SAAS,CAAC;AACvB,QAAM,OAAO,SAAS,SAAS,SAAS,CAAC;AAQzC,MAAI,IAAI,UAAU;AAEhB,QAAI,CAAC,OAAQ,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAElD,QAAI,SAAS;AACX,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,OAAO,EAAE,GAAG,WAAW,QAAQ,IAAI,IAAI,CAAC,GAAG,IAAI,QAAQ;AAAA,MACzD;AAEF,QAAI,OAAO,QAAS,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAEzD,WAAO,EAAE,IAAI,OAAO,QAAQ,QAAQ;AAAA,EACtC;AAKA,MAAI,eAAe,QAAQ,QAAS,QAAO,EAAE,IAAI,OAAO,QAAQ,QAAQ;AAKxE,MAAI,OAAO,UAAU,EAAG,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAG7D,MAAI,QAAS,UAAU,CAAC;AACxB,aAAW,KAAK,UAAU;AACxB,QAAI,KAAK,QAAS;AAClB,YAAQ,EAAE,GAAG,WAAW,OAAO,IAAI,CAAC,CAAC,GAAG,IAAI,EAAE;AAAA,EAChD;AACA,SAAO,EAAE,IAAI,MAAM,MAAM;AAC3B;;;AC1GA,SAAS,WAAW;AACpB,SAAS,SAAS,mBAAmB;;;ACW9B,IAAM,aAAN,MAA4D;AAAA,EACzD,QAAQ,oBAAI,IAAe;AAAA,EAC3B;AAAA,EAER,YAAY,UAAU,IAAI;AACxB,SAAK,UAAU;AAAA,EACjB;AAAA;AAAA,EAGA,IAAI,KAA4B;AAC9B,UAAM,IAAI,KAAK,MAAM,IAAI,GAAG;AAC5B,QAAI,GAAG;AACL,WAAK,MAAM,OAAO,GAAG;AACrB,WAAK,MAAM,IAAI,KAAK,CAAC;AAAA,IACvB;AACA,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,IAAI,KAAa,OAAgB;AAC/B,SAAK,MAAM,OAAO,GAAG;AACrB,SAAK,MAAM,IAAI,KAAK,KAAK;AACzB,QAAI,KAAK,MAAM,OAAO,KAAK,SAAS;AAClC,WAAK,MAAM,OAAO,KAAK,MAAM,KAAK,EAAE,KAAK,EAAE,KAAM;AAAA,IACnD;AAAA,EACF;AAAA;AAAA,EAGA,OAAO,KAAmB;AACxB,SAAK,MAAM,OAAO,GAAG;AAAA,EACvB;AAAA;AAAA,EAGA,IAAI,KAAsB;AACxB,WAAO,KAAK,MAAM,IAAI,GAAG;AAAA,EAC3B;AAAA;AAAA,EAGA,IAAI,OAAe;AACjB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA;AAAA,EAGA,UAAyC;AACvC,WAAO,KAAK,MAAM,QAAQ;AAAA,EAC5B;AACF;;;ADNA,SAAS,QACP,MACA,KACA,UACM;AACN,MAAI,CAAC,MAAM,KAAM;AACjB,aAAW,MAAM,MAAM;AACrB,QAAI;AACF,SAAG,GAAG;AAAA,IACR,SAAS,OAAO;AACd,eAAS,KAAK;AAAA,IAChB;AAAA,EACF;AACF;AAoBA,IAAM,YAAY,CAAIA,WAAgB;AACpC,MAAIA,WAAU,QAAQ,OAAOA,WAAU,SAAU,QAAOA;AACxD,MAAI,MAAM,QAAQA,MAAK,EAAG,QAAOA,OAAM,IAAI,SAAS;AAOpD,MAAIA,kBAAiB,IAAK,QAAO,CAAC,GAAGA,MAAK,EAAE,IAAI,SAAS;AAKzD,QAAM,QAAQ,OAAO,eAAeA,MAAK;AACzC,MAAI,UAAU,OAAO,aAAa,UAAU,KAAM,QAAOA;AACzD,QAAM,MAA+B,CAAC;AACtC,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQA,MAAgC;AAClE,QAAI,CAAC,IAAI,MAAM,SAAY,OAAO,UAAU,CAAC;AAC/C,SAAO;AACT;AAEO,IAAM,mBAAN,MAAkE;AAAA,EAC/D,WAAW,oBAAI,IAAgC;AAAA,EAC/C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA;AAAA,EACA;AAAA,EAER,YAAY,SAwBT;AACD,SAAK,cAAc,IAAI;AAAA,MACrB,SAAS,aAAa,SAAS,cAAc;AAAA,IAC/C;AACA,SAAK,kBAAkB,SAAS,kBAAkB,MAAM;AAAA,IAAC;AACzD,SAAK,sBACH,SAAS,sBACR,CAAC,OAAO,aACP,IAAI,EAAE,MAAM,OAAO,6BAA6B,QAAQ,GAAG;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,QACE,UACA,OACA,UAAwB,CAAC,GACR;AACjB,SAAK,YAAY,IAAI,UAAU,KAAK;AAEpC,UAAM,QAAQ,MAAM,KAAK,QAAQ;AACjC,UAAM,MAAuB,CAAC;AAC9B,YAAQ,QAAQ,CAAC,GAAG,MAAM;AACxB,UAAI,QAAQ,IAAI,CAAC,IAAI,UAAU,CAAC;AAAA,IAClC,CAAC;AAED;AAAA,MAAQ,KAAK,SAAS,IAAI,QAAQ;AAAA,MAAG;AAAA,MAAK,CAAC,UACzC,KAAK,oBAAoB,OAAO,QAAQ;AAAA,IAC1C;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,QACE,UACA,eAC6B;AAC7B,UAAM,OAAO,KAAK,YAAY,IAAI,QAAQ;AAC1C,QAAI,CAAC,MAAM;AAcT,WAAK,gBAAgB,QAAQ;AAC7B,YAAM,SAA0B,EAAE,SAAS,KAAK;AAChD;AAAA,QAAQ,KAAK,SAAS,IAAI,QAAQ;AAAA,QAAG;AAAA,QAAQ,CAAC,UAC5C,KAAK,oBAAoB,OAAO,QAAQ;AAAA,MAC1C;AAGA,aAAO;AAAA,IACT;AAOA,UAAM,aAAa,UAAU,aAAa;AAC1C,UAAM,QAAQ,YAAY,MAAM,UAAU;AAC1C,SAAK,YAAY,IAAI,UAAU,KAAK;AAKpC,UAAM,MAAuB;AAAA,MAC3B,CAAC,MAAM,EAAE,GAAG;AAAA,MACZ,UAAU;AAAA,IACZ;AACA;AAAA,MAAQ,KAAK,SAAS,IAAI,QAAQ;AAAA,MAAG;AAAA,MAAK,CAAC,UACzC,KAAK,oBAAoB,OAAO,QAAQ;AAAA,IAC1C;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,UAAU,UAAkB,IAA+B;AACzD,QAAI,CAAC,KAAK,SAAS,IAAI,QAAQ,EAAG,MAAK,SAAS,IAAI,UAAU,oBAAI,IAAI,CAAC;AACvE,SAAK,SAAS,IAAI,QAAQ,EAAG,IAAI,EAAE;AACnC,WAAO,MAAM;AACX,WAAK,SAAS,IAAI,QAAQ,GAAG,OAAO,EAAE;AACtC,UAAI,KAAK,SAAS,IAAI,QAAQ,GAAG,SAAS,GAAG;AAC3C,aAAK,SAAS,OAAO,QAAQ;AAAA,MAC/B;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,gBAAgB,UAA0B;AACxC,WAAO,KAAK,SAAS,IAAI,QAAQ,GAAG,QAAQ;AAAA,EAC9C;AAAA;AAAA,EAGA,MAAM,UAAiC;AACrC,WAAO,KAAK,YAAY,IAAI,QAAQ;AAAA,EACtC;AAAA;AAAA,EAGA,gBACE,UACA,eAC6B;AAC7B,WAAO,KAAK,QAAQ,UAAU,aAAa;AAAA,EAC7C;AAAA;AAAA,EAGA,qBAAqB,UAA0B;AAC7C,WAAO,KAAK,gBAAgB,QAAQ;AAAA,EACtC;AAAA;AAAA,EAGA,UAAU,UAAiC;AACzC,WAAO,KAAK,MAAM,QAAQ;AAAA,EAC5B;AAAA;AAAA,EAGA,IAAI,QAAuB;AACzB,WAAO,KAAK;AAAA,EACd;AACF;;;AErQO,IAAM,kBAAN,MAAsB;AAAA,EACnB,UAAU,oBAAI,IAAiC;AAAA;AAAA,EAGvD,IAAI,UAAkB,aAA2B;AAC/C,QAAI,CAAC,KAAK,QAAQ,IAAI,QAAQ,EAAG,MAAK,QAAQ,IAAI,UAAU,oBAAI,IAAI,CAAC;AACrE,UAAM,SAAS,KAAK,QAAQ,IAAI,QAAQ;AACxC,WAAO,IAAI,cAAc,OAAO,IAAI,WAAW,KAAK,KAAK,CAAC;AAAA,EAC5D;AAAA;AAAA,EAGA,OAAO,UAAkB,aAA2B;AAClD,UAAM,SAAS,KAAK,QAAQ,IAAI,QAAQ;AACxC,QAAI,CAAC,OAAQ;AACb,UAAM,KAAK,OAAO,IAAI,WAAW,KAAK,KAAK;AAC3C,QAAI,KAAK,EAAG,QAAO,OAAO,WAAW;AAAA,QAChC,QAAO,IAAI,aAAa,CAAC;AAC9B,QAAI,OAAO,SAAS,EAAG,MAAK,QAAQ,OAAO,QAAQ;AAAA,EACrD;AAAA;AAAA,EAGA,OAAO,UAA+B;AACpC,UAAM,SAAS,KAAK,QAAQ,IAAI,QAAQ;AACxC,WAAO,SAAS,IAAI,IAAI,OAAO,KAAK,CAAC,IAAI,oBAAI,IAAI;AAAA,EACnD;AAAA;AAAA,EAGA,SAAS,UAAkB,aAA8B;AACvD,YAAQ,KAAK,QAAQ,IAAI,QAAQ,GAAG,IAAI,WAAW,KAAK,KAAK;AAAA,EAC/D;AAAA;AAAA,EAGA,WAAW,UAA+B;AACxC,WAAO,KAAK,OAAO,QAAQ;AAAA,EAC7B;AAAA;AAAA,EAGA,UAAU,UAAkB,aAA8B;AACxD,WAAO,KAAK,SAAS,UAAU,WAAW;AAAA,EAC5C;AACF;","names":["patch"]}
1
+ {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // Distinguish an *absent* baseline from a real version-0 baseline (#1346).\n // Act assigns the first event of any stream `version = 0`, so a fresh client\n // (no cache) must resume from a pre-baseline watermark of -1 — otherwise the\n // `?? 0` default collides with a genuine version-0 genesis patch and drops\n // it as \"stale\". A present baseline keeps its own `_v`.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else — a resync frame carries no versions, and the empty-frame\n // branch below would otherwise report it as `stale`, which is the one\n // answer that does NOT refetch (#1423).\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply. For a client that already has a\n // baseline that is genuinely `stale` — a no-op, don't refetch. For one with\n // NO baseline there is nothing to be stale relative to, and `stale` is the\n // one answer that does not refetch, so it would strand the client (#1474).\n // Both the doc-comment below and `real-time.md` already say a fresh client\n // is never stale; this is the branch that made that false.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled EXHAUSTIVELY here — an overlay must never reach\n // the contiguous fold below, which would apply its payload as if it were\n // that version's domain patch. At exactly `cachedV + 1` that fold made the\n // client adopt presence data as version N, stamp itself caught up, and\n // never refetch — the real update for N lost permanently (#1419).\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { log } from \"@rotorsoft/act\";\nimport { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\n/**\n * Deliver a frame to every subscriber, containing each one individually.\n *\n * SSE subscribers are the least-trusted callbacks in the system — one per\n * connection, driven by network state. The unguarded loop this replaces let\n * the first thrower abort iteration, so every later subscriber lost the\n * frame and the exception escaped into the host's commit path (#1423). Same\n * containment principle the orchestrator applies to lifecycle listeners.\n * Guarding each callback rather than the loop is the point: one guard around\n * the whole loop would still let the first throw suppress the rest.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite `undefined`-valued keys to `null`, recursively.\n *\n * `@rotorsoft/act-patch` treats `undefined` and `null` as the same delete\n * signal, but `JSON.stringify` drops `undefined`-valued keys entirely — and\n * every SSE transport serializes frames as JSON. A reducer that clears a\n * field the idiomatic way (`{ left: undefined }`) therefore produced a frame\n * with no mention of `left` at all, so a live client kept the stale value\n * while stamping the new version — believing itself caught up, and never\n * refetching (#1471).\n *\n * `null` reaches the same `delete` branch in the patch applicator and\n * survives JSON, so normalizing here makes the two spellings equivalent on\n * the wire as they already are in memory.\n *\n * Only the broadcast frame is normalized. Server-side state is applied\n * through `apply_patch`, which handles `undefined` natively.\n */\nconst wire_safe = <T>(patch: T): T => {\n if (patch === null || typeof patch !== \"object\") return patch;\n if (Array.isArray(patch)) return patch.map(wire_safe) as T;\n // A Set has exactly one sensible JSON encoding, and `JSON.stringify` gives\n // it the wrong one: `{}`. The framework's own `PresenceTracker.online()`\n // returns a Set and the presence recipe feeds it straight to `overlay()`,\n // so the documented way to broadcast presence shipped an empty object to\n // every client — and then froze, because a client holding `{}` treats\n // every later empty patch as a no-op (#1472).\n if (patch instanceof Set) return [...patch].map(wire_safe) as T;\n // Everything else non-plain (Date, Map, class instances) is left to\n // whatever the host's serializer already does with it. `Date` has a\n // defined encoding; `Map` does not have an unambiguous one (entries or\n // object?), so guessing would trade a visible bug for a silent choice.\n const proto = Object.getPrototypeOf(patch);\n if (proto !== Object.prototype && proto !== null) return patch;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(patch as Record<string, unknown>))\n out[k] = v === undefined ? null : wire_safe(v);\n return out as T;\n};\n\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n /**\n * @param options.cacheSize - Max number of stream states kept in the LRU\n * cache (default 50).\n */\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n cacheSize?: number;\n /**\n * Called when `overlay()` finds no cached baseline for the stream, so\n * the update cannot be broadcast. Live subscribers receive nothing and\n * have no way to notice — a host that cares should raise `cacheSize`,\n * re-`publish` the stream, or push the viewers to refetch. Defaults to a\n * no-op so existing behavior is unchanged apart from being observable.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher (#1423). Defaults to the\n * framework's `log()` port, so it lands wherever the host already\n * routes framework logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ??\n ((error, streamId) =>\n log().error(error, `sse subscriber threw for \"${streamId}\"`));\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n this.state_cache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to read-modify-write, so nothing is broadcast — and\n // because no frame is emitted there is nothing for a live client to\n // classify as `behind`, so it never refetches either. Unlike a domain\n // commit (which repopulates the cache via `publish`), an overlay-only\n // stream never recovers. The defaults make this reachable rather than\n // exotic: `cacheSize` is 50 while a host may hold far more live\n // subscriptions, and cache promotion happens at connect time, so a\n // busy-but-not-committing stream ages out while fully subscribed.\n //\n // Emit a resync frame so live subscribers refetch instead of silently\n // missing the update forever (#1423). `on_overlay_miss` still fires so\n // a host can count these — a steady stream of them means `cacheSize`\n // is too small for the working set.\n this.on_overlay_miss(streamId);\n const resync: PatchMessage<S> = { _resync: true } as PatchMessage<S>;\n fan_out(this.channels.get(streamId), resync, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n // Still `undefined`: no overlay state was produced, and callers use the\n // return value as \"the patch I broadcast\", which a resync is not.\n return undefined;\n }\n\n // Normalize BEFORE applying to the cache, so a reconnecting client's\n // reseed and a live client's frame agree (#1472). `apply_patch` treats\n // `null` and `undefined` as the same delete signal, so normalizing first\n // does not change the delete semantics — it only fixes the encodings\n // that would not survive JSON.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(streamId, state);\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n this.cache.delete(this.cache.keys().next().value!);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":";;;AAuCA,SAAS,aAAa;;;ACvCtB,SAAS,SAAS,kBAAkB;AAuC7B,SAAS,kBACd,KACA,QACgB;AAMhB,QAAM,cAAc,UAAU;AAC9B,QAAM,UAAU,QAAQ,MAAM;AAE9B,QAAM,WAAW,OAAO,KAAK,GAAG,EAC7B,IAAI,MAAM,EACV,OAAO,CAAC,MAAM,OAAO,UAAU,CAAC,CAAC,EACjC,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC;AAOvB,MAAI,IAAI,QAAS,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAQtD,MAAI,CAAC,SAAS;AACZ,WAAO,EAAE,IAAI,OAAO,QAAQ,cAAc,UAAU,SAAS;AAE/D,QAAM,OAAO,SAAS,CAAC;AACvB,QAAM,OAAO,SAAS,SAAS,SAAS,CAAC;AAQzC,MAAI,IAAI,UAAU;AAEhB,QAAI,CAAC,OAAQ,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAElD,QAAI,SAAS;AACX,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,OAAO,EAAE,GAAG,WAAW,QAAQ,IAAI,IAAI,CAAC,GAAG,IAAI,QAAQ;AAAA,MACzD;AAEF,QAAI,OAAO,QAAS,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAEzD,WAAO,EAAE,IAAI,OAAO,QAAQ,QAAQ;AAAA,EACtC;AAKA,MAAI,eAAe,QAAQ,QAAS,QAAO,EAAE,IAAI,OAAO,QAAQ,QAAQ;AAKxE,MAAI,OAAO,UAAU,EAAG,QAAO,EAAE,IAAI,OAAO,QAAQ,SAAS;AAG7D,MAAI,QAAS,UAAU,CAAC;AACxB,aAAW,KAAK,UAAU;AACxB,QAAI,KAAK,QAAS;AAClB,YAAQ,EAAE,GAAG,WAAW,OAAO,IAAI,CAAC,CAAC,GAAG,IAAI,EAAE;AAAA,EAChD;AACA,SAAO,EAAE,IAAI,MAAM,MAAM;AAC3B;;;ACjHA,SAAS,WAAW;AACpB,SAAS,SAAS,mBAAmB;;;ACW9B,IAAM,aAAN,MAA4D;AAAA,EACzD,QAAQ,oBAAI,IAAe;AAAA,EAC3B;AAAA,EAER,YAAY,UAAU,IAAI;AACxB,SAAK,UAAU;AAAA,EACjB;AAAA;AAAA,EAGA,IAAI,KAA4B;AAC9B,UAAM,IAAI,KAAK,MAAM,IAAI,GAAG;AAC5B,QAAI,GAAG;AACL,WAAK,MAAM,OAAO,GAAG;AACrB,WAAK,MAAM,IAAI,KAAK,CAAC;AAAA,IACvB;AACA,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,IAAI,KAAa,OAAgB;AAC/B,SAAK,MAAM,OAAO,GAAG;AACrB,SAAK,MAAM,IAAI,KAAK,KAAK;AACzB,QAAI,KAAK,MAAM,OAAO,KAAK,SAAS;AAClC,WAAK,MAAM,OAAO,KAAK,MAAM,KAAK,EAAE,KAAK,EAAE,KAAM;AAAA,IACnD;AAAA,EACF;AAAA;AAAA,EAGA,OAAO,KAAmB;AACxB,SAAK,MAAM,OAAO,GAAG;AAAA,EACvB;AAAA;AAAA,EAGA,IAAI,KAAsB;AACxB,WAAO,KAAK,MAAM,IAAI,GAAG;AAAA,EAC3B;AAAA;AAAA,EAGA,IAAI,OAAe;AACjB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA;AAAA,EAGA,UAAyC;AACvC,WAAO,KAAK,MAAM,QAAQ;AAAA,EAC5B;AACF;;;ADNA,SAAS,QACP,MACA,KACA,UACM;AACN,MAAI,CAAC,MAAM,KAAM;AACjB,aAAW,MAAM,MAAM;AACrB,QAAI;AACF,SAAG,GAAG;AAAA,IACR,SAAS,OAAO;AACd,eAAS,KAAK;AAAA,IAChB;AAAA,EACF;AACF;AAoBA,IAAM,YAAY,CAAIA,WAAgB;AACpC,MAAIA,WAAU,QAAQ,OAAOA,WAAU,SAAU,QAAOA;AACxD,MAAI,MAAM,QAAQA,MAAK,EAAG,QAAOA,OAAM,IAAI,SAAS;AAOpD,MAAIA,kBAAiB,IAAK,QAAO,CAAC,GAAGA,MAAK,EAAE,IAAI,SAAS;AAKzD,QAAM,QAAQ,OAAO,eAAeA,MAAK;AACzC,MAAI,UAAU,OAAO,aAAa,UAAU,KAAM,QAAOA;AACzD,QAAM,MAA+B,CAAC;AACtC,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQA,MAAgC;AAClE,QAAI,CAAC,IAAI,MAAM,SAAY,OAAO,UAAU,CAAC;AAC/C,SAAO;AACT;AAEO,IAAM,mBAAN,MAAkE;AAAA,EAC/D,WAAW,oBAAI,IAAgC;AAAA,EAC/C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA;AAAA,EACA;AAAA,EAER,YAAY,SAwBT;AACD,SAAK,cAAc,IAAI;AAAA,MACrB,SAAS,aAAa,SAAS,cAAc;AAAA,IAC/C;AACA,SAAK,kBAAkB,SAAS,kBAAkB,MAAM;AAAA,IAAC;AACzD,SAAK,sBACH,SAAS,sBACR,CAAC,OAAO,aACP,IAAI,EAAE,MAAM,OAAO,6BAA6B,QAAQ,GAAG;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,QACE,UACA,OACA,UAAwB,CAAC,GACR;AACjB,SAAK,YAAY,IAAI,UAAU,KAAK;AAEpC,UAAM,QAAQ,MAAM,KAAK,QAAQ;AACjC,UAAM,MAAuB,CAAC;AAC9B,YAAQ,QAAQ,CAAC,GAAG,MAAM;AACxB,UAAI,QAAQ,IAAI,CAAC,IAAI,UAAU,CAAC;AAAA,IAClC,CAAC;AAED;AAAA,MAAQ,KAAK,SAAS,IAAI,QAAQ;AAAA,MAAG;AAAA,MAAK,CAAC,UACzC,KAAK,oBAAoB,OAAO,QAAQ;AAAA,IAC1C;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,QACE,UACA,eAC6B;AAC7B,UAAM,OAAO,KAAK,YAAY,IAAI,QAAQ;AAC1C,QAAI,CAAC,MAAM;AAcT,WAAK,gBAAgB,QAAQ;AAC7B,YAAM,SAA0B,EAAE,SAAS,KAAK;AAChD;AAAA,QAAQ,KAAK,SAAS,IAAI,QAAQ;AAAA,QAAG;AAAA,QAAQ,CAAC,UAC5C,KAAK,oBAAoB,OAAO,QAAQ;AAAA,MAC1C;AAGA,aAAO;AAAA,IACT;AAOA,UAAM,aAAa,UAAU,aAAa;AAC1C,UAAM,QAAQ,YAAY,MAAM,UAAU;AAC1C,SAAK,YAAY,IAAI,UAAU,KAAK;AAKpC,UAAM,MAAuB;AAAA,MAC3B,CAAC,MAAM,EAAE,GAAG;AAAA,MACZ,UAAU;AAAA,IACZ;AACA;AAAA,MAAQ,KAAK,SAAS,IAAI,QAAQ;AAAA,MAAG;AAAA,MAAK,CAAC,UACzC,KAAK,oBAAoB,OAAO,QAAQ;AAAA,IAC1C;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,UAAU,UAAkB,IAA+B;AACzD,QAAI,CAAC,KAAK,SAAS,IAAI,QAAQ,EAAG,MAAK,SAAS,IAAI,UAAU,oBAAI,IAAI,CAAC;AACvE,SAAK,SAAS,IAAI,QAAQ,EAAG,IAAI,EAAE;AACnC,WAAO,MAAM;AACX,WAAK,SAAS,IAAI,QAAQ,GAAG,OAAO,EAAE;AACtC,UAAI,KAAK,SAAS,IAAI,QAAQ,GAAG,SAAS,GAAG;AAC3C,aAAK,SAAS,OAAO,QAAQ;AAAA,MAC/B;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,gBAAgB,UAA0B;AACxC,WAAO,KAAK,SAAS,IAAI,QAAQ,GAAG,QAAQ;AAAA,EAC9C;AAAA;AAAA,EAGA,MAAM,UAAiC;AACrC,WAAO,KAAK,YAAY,IAAI,QAAQ;AAAA,EACtC;AAAA;AAAA,EAGA,gBACE,UACA,eAC6B;AAC7B,WAAO,KAAK,QAAQ,UAAU,aAAa;AAAA,EAC7C;AAAA;AAAA,EAGA,qBAAqB,UAA0B;AAC7C,WAAO,KAAK,gBAAgB,QAAQ;AAAA,EACtC;AAAA;AAAA,EAGA,UAAU,UAAiC;AACzC,WAAO,KAAK,MAAM,QAAQ;AAAA,EAC5B;AAAA;AAAA,EAGA,IAAI,QAAuB;AACzB,WAAO,KAAK;AAAA,EACd;AACF;;;AErQO,IAAM,kBAAN,MAAsB;AAAA,EACnB,UAAU,oBAAI,IAAiC;AAAA;AAAA,EAGvD,IAAI,UAAkB,aAA2B;AAC/C,QAAI,CAAC,KAAK,QAAQ,IAAI,QAAQ,EAAG,MAAK,QAAQ,IAAI,UAAU,oBAAI,IAAI,CAAC;AACrE,UAAM,SAAS,KAAK,QAAQ,IAAI,QAAQ;AACxC,WAAO,IAAI,cAAc,OAAO,IAAI,WAAW,KAAK,KAAK,CAAC;AAAA,EAC5D;AAAA;AAAA,EAGA,OAAO,UAAkB,aAA2B;AAClD,UAAM,SAAS,KAAK,QAAQ,IAAI,QAAQ;AACxC,QAAI,CAAC,OAAQ;AACb,UAAM,KAAK,OAAO,IAAI,WAAW,KAAK,KAAK;AAC3C,QAAI,KAAK,EAAG,QAAO,OAAO,WAAW;AAAA,QAChC,QAAO,IAAI,aAAa,CAAC;AAC9B,QAAI,OAAO,SAAS,EAAG,MAAK,QAAQ,OAAO,QAAQ;AAAA,EACrD;AAAA;AAAA,EAGA,OAAO,UAA+B;AACpC,UAAM,SAAS,KAAK,QAAQ,IAAI,QAAQ;AACxC,WAAO,SAAS,IAAI,IAAI,OAAO,KAAK,CAAC,IAAI,oBAAI,IAAI;AAAA,EACnD;AAAA;AAAA,EAGA,SAAS,UAAkB,aAA8B;AACvD,YAAQ,KAAK,QAAQ,IAAI,QAAQ,GAAG,IAAI,WAAW,KAAK,KAAK;AAAA,EAC/D;AAAA;AAAA,EAGA,WAAW,UAA+B;AACxC,WAAO,KAAK,OAAO,QAAQ;AAAA,EAC7B;AAAA;AAAA,EAGA,UAAU,UAAkB,aAA8B;AACxD,WAAO,KAAK,SAAS,UAAU,WAAW;AAAA,EAC5C;AACF;","names":["patch"]}