summaryrefslogtreecommitdiffhomepage
path: root/src/core/protocol/reducer.ts
blob: 3d6b1c80fcb69a85c98513389651e9ab0a9536fb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import type {
	InvokeMessage,
	SubscribeMessage,
	SurfaceServerMessage,
	SurfaceSpec,
	UnsubscribeMessage,
} from "@dispatch/ui-contract";
import type { ProtocolResult, ProtocolState } from "./types";

/** The initial protocol state: empty catalog, no subscriptions, no error. */
export function initialState(): ProtocolState {
	return {
		catalog: [],
		subscriptions: new Map(),
		lastError: null,
	};
}

// ── 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) {
		case "catalog":
			return { ...state, catalog: msg.catalog };

		case "surface": {
			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(msg.spec.id, { conversationId: sub.conversationId, spec: msg.spec });
			return { ...state, subscriptions: subs };
		}

		case "update": {
			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, { conversationId: sub.conversationId, spec });
			return { ...state, subscriptions: subs };
		}

		case "error":
			return { ...state, lastError: msg };
	}
}

/**
 * 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,
	conversationId?: string,
): ProtocolResult {
	const existing = state.subscriptions.get(surfaceId);
	if (existing !== undefined && existing.conversationId === conversationId) {
		return { state, outgoing: [] };
	}
	const subs = new Map(state.subscriptions);
	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 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 {
	const existing = state.subscriptions.get(surfaceId);
	if (existing === undefined) {
		return { state, outgoing: [] };
	}
	const subs = new Map(state.subscriptions);
	subs.delete(surfaceId);
	return {
		state: { ...state, subscriptions: subs },
		outgoing: [unsubMsg(surfaceId, existing.conversationId)],
	};
}

/**
 * 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 =
		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;
}