Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -23,6 +23,8 @@ interface ChannelOptions<In, Out> {
|
|||
/** Ключ безопасного идемпотентного события, которое можно повторить после
|
||||
* краткого разрыва. null запрещает буферизацию команды. */
|
||||
queueKey?: (event: Out) => string | null;
|
||||
/** Подтверждение, что серверный durable snapshot уже содержит команду. */
|
||||
isAcknowledged?: (incoming: In, pending: Out) => boolean;
|
||||
}
|
||||
|
||||
const RECONNECT_MIN_MS = 500;
|
||||
|
|
@ -30,7 +32,7 @@ const RECONNECT_MAX_MS = 5_000;
|
|||
const BUFFER_MAX_AGE_MS = 35_000;
|
||||
const BUFFER_MAX_ITEMS = 100;
|
||||
|
||||
interface Buffered<Out> { key: string; event: Out; at: number }
|
||||
interface Buffered<Out> { key: string; commandId: string; event: Out; at: number }
|
||||
|
||||
export class Channel<In extends { type: string }, Out extends { type: string }> {
|
||||
private socket: WebSocket | null = null;
|
||||
|
|
@ -61,15 +63,25 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
this.options.onStatus?.("open");
|
||||
const now = Date.now();
|
||||
const pending = this.buffered.filter((item) => now - item.at <= BUFFER_MAX_AGE_MS);
|
||||
this.buffered = [];
|
||||
for (const item of pending) socket.send(JSON.stringify(item.event));
|
||||
this.buffered = pending;
|
||||
for (const item of pending) socket.send(this.encode(item));
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
if (typeof message.data !== "string") {
|
||||
this.options.onBinary?.(message.data as ArrayBuffer);
|
||||
return;
|
||||
}
|
||||
this.options.onEvent?.(JSON.parse(message.data) as In);
|
||||
const event = JSON.parse(message.data) as In;
|
||||
if (event.type === "command.ack" && typeof (event as { command_id?: unknown }).command_id === "string") {
|
||||
const commandId = (event as unknown as { command_id: string }).command_id;
|
||||
this.buffered = this.buffered.filter((item) => item.commandId !== commandId);
|
||||
}
|
||||
if (this.options.isAcknowledged) {
|
||||
this.buffered = this.buffered.filter(
|
||||
(item) => !this.options.isAcknowledged!(event, item.event),
|
||||
);
|
||||
}
|
||||
this.options.onEvent?.(event);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
this.socket = null;
|
||||
|
|
@ -91,13 +103,14 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
}
|
||||
|
||||
send(event: Out): boolean {
|
||||
if (this.isOpen) {
|
||||
const key = this.options.queueKey?.(event) ?? null;
|
||||
if (!key) {
|
||||
if (!this.isOpen) return false;
|
||||
this.socket!.send(JSON.stringify(event));
|
||||
return true;
|
||||
}
|
||||
const key = this.options.queueKey?.(event) ?? null;
|
||||
if (!key) return false;
|
||||
const existing = this.buffered.find((item) => item.key === key);
|
||||
let pending: Buffered<Out>;
|
||||
if (existing) {
|
||||
// Частые kio.patch объединяем: последнее значение каждого поля важнее,
|
||||
// но правки разных полей не должны затереть друг друга.
|
||||
|
|
@ -112,13 +125,24 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
existing.event = event;
|
||||
}
|
||||
existing.at = Date.now();
|
||||
pending = existing;
|
||||
} else {
|
||||
this.buffered.push({ key, event, at: Date.now() });
|
||||
pending = { key, commandId: crypto.randomUUID(), event, at: Date.now() };
|
||||
this.buffered.push(pending);
|
||||
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
|
||||
}
|
||||
// Keep a replay-safe command (for at most BUFFER_MAX_AGE_MS) until an event
|
||||
// emitted after the server's durable checkpoint proves it was applied. A
|
||||
// send() on an open socket is not itself an acknowledgement: the connection
|
||||
// may die before the server receives it or the response reaches this browser.
|
||||
if (this.isOpen) this.socket!.send(this.encode(pending));
|
||||
return true;
|
||||
}
|
||||
|
||||
private encode(item: Buffered<Out>): string {
|
||||
return JSON.stringify({ ...item.event, _command_id: item.commandId });
|
||||
}
|
||||
|
||||
sendBinary(frame: ArrayBuffer): boolean {
|
||||
if (!this.isOpen) return false;
|
||||
this.socket!.send(frame);
|
||||
|
|
@ -139,6 +163,13 @@ export const callChannel = (
|
|||
) => new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, {
|
||||
...options,
|
||||
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
||||
isAcknowledged: (incoming, pending) => {
|
||||
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|
||||
|| incoming.source !== "operator") return false;
|
||||
return Object.entries(pending.fields).every(([key, value]) =>
|
||||
sameJsonValue(value, incoming.fields[key]),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
|
||||
|
|
@ -154,12 +185,34 @@ export const stationChannel = (
|
|||
...options,
|
||||
queueKey: (event) => {
|
||||
if (event.type === "card.reply") return `card.reply:${event.card_id}`;
|
||||
if (event.type === "crew.select") return "crew.select";
|
||||
if (event.type === "zone.decision") return "zone.decision";
|
||||
return null;
|
||||
// Non-replacement station actions are each their own replayable command.
|
||||
// The server deduplicates by this ID inside its persisted session snapshot.
|
||||
return `command:${crypto.randomUUID()}`;
|
||||
},
|
||||
isAcknowledged: (incoming, pending) => incoming.type === "station.state"
|
||||
&& pending.type === "card.reply"
|
||||
&& incoming.snapshot.card_id === pending.card_id
|
||||
&& incoming.snapshot.reply_text === pending.text,
|
||||
});
|
||||
|
||||
/** Преподаватель: только передача, входящих в этом канале нет. */
|
||||
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
|
||||
new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options);
|
||||
|
||||
function sameJsonValue(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right)
|
||||
&& left.length === right.length
|
||||
&& left.every((item, index) => sameJsonValue(item, right[index]));
|
||||
}
|
||||
if (left && right && typeof left === "object" && typeof right === "object") {
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const keys = Object.keys(leftRecord);
|
||||
return keys.length === Object.keys(rightRecord).length
|
||||
&& keys.every((key) => key in rightRecord
|
||||
&& sameJsonValue(leftRecord[key], rightRecord[key]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue