diff options
Diffstat (limited to 'src/core/protocol')
| -rw-r--r-- | src/core/protocol/index.ts | 11 | ||||
| -rw-r--r-- | src/core/protocol/reducer.test.ts | 110 | ||||
| -rw-r--r-- | src/core/protocol/reducer.ts | 101 | ||||
| -rw-r--r-- | src/core/protocol/types.ts | 19 |
4 files changed, 209 insertions, 32 deletions
diff --git a/src/core/protocol/index.ts b/src/core/protocol/index.ts index 25174ea..e7fd161 100644 --- a/src/core/protocol/index.ts +++ b/src/core/protocol/index.ts @@ -1,2 +1,9 @@ -export { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer"; -export type { ProtocolResult, ProtocolState } from "./types"; +export { + applyServerMessage, + getSurfaceSpec, + initialState, + invoke, + subscribe, + unsubscribe, +} from "./reducer"; +export type { ProtocolResult, ProtocolState, Subscription } from "./types"; diff --git a/src/core/protocol/reducer.test.ts b/src/core/protocol/reducer.test.ts index 57e12f2..c8e517a 100644 --- a/src/core/protocol/reducer.test.ts +++ b/src/core/protocol/reducer.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer"; +import { + applyServerMessage, + getSurfaceSpec, + initialState, + invoke, + subscribe, + unsubscribe, +} from "./reducer"; const makeSpec = (id: string, title = id) => ({ id, @@ -32,11 +39,10 @@ describe("applyServerMessage — catalog", () => { describe("applyServerMessage — surface", () => { it("sets the spec for a subscribed surface", () => { let s = initialState(); - const result = subscribe(s, "s1"); - s = result.state; + s = subscribe(s, "s1").state; const spec = makeSpec("s1", "Surface 1"); const next = applyServerMessage(s, { type: "surface", spec }); - expect(next.subscriptions.get("s1")).toEqual(spec); + expect(getSurfaceSpec(next, "s1")).toEqual(spec); }); it("ignores a surface message for a non-subscribed surface", () => { @@ -56,7 +62,7 @@ describe("applyServerMessage — update", () => { type: "update", update: { surfaceId: "s1", spec: makeSpec("s1", "V2") }, }); - expect(next.subscriptions.get("s1")?.title).toBe("V2"); + expect(getSurfaceSpec(next, "s1")?.title).toBe("V2"); }); it("ignores an update for a non-subscribed surface", () => { @@ -86,7 +92,7 @@ describe("applyServerMessage — error", () => { }); describe("subscribe", () => { - it("emits exactly one subscribe message", () => { + it("emits exactly one subscribe message (global, no conversationId)", () => { const s = initialState(); const result = subscribe(s, "s1"); expect(result.outgoing).toEqual([{ type: "subscribe", surfaceId: "s1" }]); @@ -96,10 +102,14 @@ describe("subscribe", () => { it("adds the surface to subscriptions with null spec", () => { const s = initialState(); const result = subscribe(s, "s1"); - expect(result.state.subscriptions.get("s1")).toBeNull(); + expect(result.state.subscriptions.get("s1")).toEqual({ + conversationId: undefined, + spec: null, + }); + expect(getSurfaceSpec(result.state, "s1")).toBeNull(); }); - it("is idempotent — second subscribe is a no-op", () => { + it("is idempotent — second subscribe with the same scope is a no-op", () => { let s = initialState(); s = subscribe(s, "s1").state; const result = subscribe(s, "s1"); @@ -108,6 +118,67 @@ describe("subscribe", () => { }); }); +describe("subscribe — conversation-scoped", () => { + it("includes conversationId in the subscribe message", () => { + const s = initialState(); + const result = subscribe(s, "cache-warming", "conv-A"); + expect(result.outgoing).toEqual([ + { type: "subscribe", surfaceId: "cache-warming", conversationId: "conv-A" }, + ]); + expect(result.state.subscriptions.get("cache-warming")?.conversationId).toBe("conv-A"); + }); + + it("re-scopes on conversation switch: unsubscribe old pair then subscribe new", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + s = applyServerMessage(s, { + type: "surface", + spec: makeSpec("cw", "A-spec"), + conversationId: "conv-A", + }); + const result = subscribe(s, "cw", "conv-B"); + expect(result.outgoing).toEqual([ + { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, + { type: "subscribe", surfaceId: "cw", conversationId: "conv-B" }, + ]); + // previous spec retained until the new one arrives (no flicker) + expect(getSurfaceSpec(result.state, "cw")?.title).toBe("A-spec"); + expect(result.state.subscriptions.get("cw")?.conversationId).toBe("conv-B"); + }); + + it("drops a stale update echoing the previous conversationId", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + s = subscribe(s, "cw", "conv-B").state; // re-scoped to B + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "cw", spec: makeSpec("cw", "STALE-A"), conversationId: "conv-A" }, + }); + expect(getSurfaceSpec(next, "cw")).toBeNull(); // stale ignored, no spec yet for B + }); + + it("accepts an update echoing the current conversationId", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-B").state; + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "cw", spec: makeSpec("cw", "B-spec"), conversationId: "conv-B" }, + }); + expect(getSurfaceSpec(next, "cw")?.title).toBe("B-spec"); + }); + + it("accepts a global (no-echo) surface message even when subscribed with a conversationId", () => { + // loaded-extensions is global: server ignores our conversationId and echoes none. + let s = initialState(); + s = subscribe(s, "loaded-extensions", "conv-A").state; + const next = applyServerMessage(s, { + type: "surface", + spec: makeSpec("loaded-extensions", "Ext"), + }); + expect(getSurfaceSpec(next, "loaded-extensions")?.title).toBe("Ext"); + }); +}); + describe("unsubscribe", () => { it("emits unsubscribe and drops the spec", () => { let s = initialState(); @@ -118,6 +189,15 @@ describe("unsubscribe", () => { expect(result.state.subscriptions.has("s1")).toBe(false); }); + it("includes conversationId for a scoped subscription", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + const result = unsubscribe(s, "cw"); + expect(result.outgoing).toEqual([ + { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, + ]); + }); + it("is a no-op if not subscribed", () => { const s = initialState(); const result = unsubscribe(s, "nope"); @@ -143,6 +223,20 @@ describe("invoke", () => { ]); }); + it("includes conversationId when provided", () => { + const s = initialState(); + const result = invoke(s, "cw", "cache-warming/set-interval", 120, "conv-A"); + expect(result.outgoing).toEqual([ + { + type: "invoke", + surfaceId: "cw", + actionId: "cache-warming/set-interval", + payload: 120, + conversationId: "conv-A", + }, + ]); + }); + it("does not mutate state", () => { const s = initialState(); const result = invoke(s, "s1", "a1"); diff --git a/src/core/protocol/reducer.ts b/src/core/protocol/reducer.ts index 992a918..3d6b1c8 100644 --- a/src/core/protocol/reducer.ts +++ b/src/core/protocol/reducer.ts @@ -2,6 +2,7 @@ import type { InvokeMessage, SubscribeMessage, SurfaceServerMessage, + SurfaceSpec, UnsubscribeMessage, } from "@dispatch/ui-contract"; import type { ProtocolResult, ProtocolState } from "./types"; @@ -15,6 +16,31 @@ export function initialState(): ProtocolState { }; } +// ── Message builders (respect exactOptionalPropertyTypes: omit `conversationId` +// entirely for a global subscription rather than setting it to `undefined`). ── + +function subMsg(surfaceId: string, conversationId: string | undefined): SubscribeMessage { + return conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId }; +} + +function unsubMsg(surfaceId: string, conversationId: string | undefined): UnsubscribeMessage { + return conversationId === undefined + ? { type: "unsubscribe", surfaceId } + : { type: "unsubscribe", surfaceId, conversationId }; +} + +/** + * Is an inbound spec/update (which echoes `echoedId`) current for the + * subscription whose desired scope is `desiredId`? A scoped surface echoes its + * conversationId, so it must match the one we last subscribed with; a GLOBAL + * surface echoes nothing (`undefined`) and is always current. + */ +function isCurrent(desiredId: string | undefined, echoedId: string | undefined): boolean { + return echoedId === undefined || echoedId === desiredId; +} + /** Fold an inbound server message into the next protocol state. */ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessage): ProtocolState { switch (msg.type) { @@ -22,18 +48,21 @@ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessa return { ...state, catalog: msg.catalog }; case "surface": { - const surfaceId = msg.spec.id; - if (!state.subscriptions.has(surfaceId)) return state; + const sub = state.subscriptions.get(msg.spec.id); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, msg.conversationId)) return state; const subs = new Map(state.subscriptions); - subs.set(surfaceId, msg.spec); + subs.set(msg.spec.id, { conversationId: sub.conversationId, spec: msg.spec }); return { ...state, subscriptions: subs }; } case "update": { - const surfaceId = msg.update.surfaceId; - if (!state.subscriptions.has(surfaceId)) return state; + const { surfaceId, spec, conversationId } = msg.update; + const sub = state.subscriptions.get(surfaceId); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, conversationId)) return state; const subs = new Map(state.subscriptions); - subs.set(surfaceId, msg.update.spec); + subs.set(surfaceId, { conversationId: sub.conversationId, spec }); return { ...state, subscriptions: subs }; } @@ -43,40 +72,72 @@ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessa } /** - * Subscribe to a surface. Idempotent: if already subscribed, returns the same - * state with no outgoing message. + * Subscribe to a surface for a given conversation (omit `conversationId` for a + * GLOBAL surface / when no conversation is focused). + * + * - Not yet subscribed → emits one `subscribe`. + * - Already subscribed with the SAME scope → idempotent no-op. + * - Already subscribed with a DIFFERENT conversation (a re-scope on conversation + * switch) → emits `unsubscribe` for the old pair then `subscribe` for the new + * one, retaining the previous spec until the new one arrives (no flicker). */ -export function subscribe(state: ProtocolState, surfaceId: string): ProtocolResult { - if (state.subscriptions.has(surfaceId)) { +export function subscribe( + state: ProtocolState, + surfaceId: string, + conversationId?: string, +): ProtocolResult { + const existing = state.subscriptions.get(surfaceId); + if (existing !== undefined && existing.conversationId === conversationId) { return { state, outgoing: [] }; } const subs = new Map(state.subscriptions); - subs.set(surfaceId, null); - const outgoing: SubscribeMessage = { type: "subscribe", surfaceId }; - return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] }; + const outgoing: (SubscribeMessage | UnsubscribeMessage)[] = []; + const priorSpec: SurfaceSpec | null = existing?.spec ?? null; + if (existing !== undefined) { + outgoing.push(unsubMsg(surfaceId, existing.conversationId)); + } + subs.set(surfaceId, { conversationId, spec: priorSpec }); + outgoing.push(subMsg(surfaceId, conversationId)); + return { state: { ...state, subscriptions: subs }, outgoing }; } /** - * Unsubscribe from a surface. Drops the local spec and emits one unsubscribe. - * If not subscribed, returns the same state with no outgoing. + * Unsubscribe from a surface. Drops the local subscription and emits one + * `unsubscribe` (for the conversation pair it was subscribed under). No-op if + * not subscribed. */ export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolResult { - if (!state.subscriptions.has(surfaceId)) { + const existing = state.subscriptions.get(surfaceId); + if (existing === undefined) { return { state, outgoing: [] }; } const subs = new Map(state.subscriptions); subs.delete(surfaceId); - const outgoing: UnsubscribeMessage = { type: "unsubscribe", surfaceId }; - return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] }; + return { + state: { ...state, subscriptions: subs }, + outgoing: [unsubMsg(surfaceId, existing.conversationId)], + }; } -/** Invoke a field's action on a surface. Emits an InvokeMessage; no state change. */ +/** + * Invoke a field's action on a surface. Emits an InvokeMessage (carrying + * `conversationId` for a scoped surface); no state change. + */ export function invoke( state: ProtocolState, surfaceId: string, actionId: string, payload?: unknown, + conversationId?: string, ): ProtocolResult { - const outgoing: InvokeMessage = { type: "invoke", surfaceId, actionId, payload }; + const outgoing: InvokeMessage = + conversationId === undefined + ? { type: "invoke", surfaceId, actionId, payload } + : { type: "invoke", surfaceId, actionId, payload, conversationId }; return { state, outgoing: [outgoing] }; } + +/** The current spec for a subscribed surface, or `null` if absent/unsubscribed. */ +export function getSurfaceSpec(state: ProtocolState, surfaceId: string): SurfaceSpec | null { + return state.subscriptions.get(surfaceId)?.spec ?? null; +} diff --git a/src/core/protocol/types.ts b/src/core/protocol/types.ts index effec0d..db8886a 100644 --- a/src/core/protocol/types.ts +++ b/src/core/protocol/types.ts @@ -5,12 +5,27 @@ import type { SurfaceSpec, } from "@dispatch/ui-contract"; +/** + * One surface subscription's local state. + * + * `conversationId` is the conversation we last subscribed this surface WITH + * (`undefined` = subscribed globally, no conversation in focus). It is the + * "desired" scope: an inbound `surface`/`update` that echoes a DIFFERENT + * conversation is stale (we have since re-scoped) and is dropped. A GLOBAL + * surface ignores the id server-side and echoes none — that (`undefined` echo) + * is always accepted. `spec` is `null` until the first `surface` arrives. + */ +export interface Subscription { + readonly conversationId: string | undefined; + readonly spec: SurfaceSpec | null; +} + /** The client-side view of the surface protocol state. */ export interface ProtocolState { /** The latest catalog received from the server (empty until first CatalogMessage). */ readonly catalog: SurfaceCatalog; - /** Surfaces the client intends to be subscribed to; null = subscribed but no spec yet. */ - readonly subscriptions: ReadonlyMap<string, SurfaceSpec | null>; + /** Surfaces the client intends to be subscribed to, keyed by surfaceId. */ + readonly subscriptions: ReadonlyMap<string, Subscription>; /** The last error received from the server, if any. */ readonly lastError: SurfaceErrorMessage | null; } |
