// Wraps the order submit pipeline (`sync/submit.ts`) with the Phase // 25 transport semantics: // // - **offline detection** via `navigator.onLine` and the browser // `online` / `offline` events. While offline, `send()` returns an // `offline` outcome immediately and the caller is expected to // leave the in-flight commands in their pre-submit state. // - **single retry on reconnect** is realised at the consumer // level: when the browser fires `online`, the queue invokes the // `onOnline` callback the consumer supplied at `start()`. The // consumer (`OrderDraftStore`) decides whether to schedule a // fresh `runSync()` — that single attempt is the retry budget. // - **conflict / paused classification**: a non-`ok` SubmitResult // whose `resultCode` or `code` is `turn_already_closed` becomes // a `conflict` outcome; `game_paused` becomes a `paused` // outcome. Any other non-`ok` reply stays a `rejected` outcome // and the consumer keeps the existing per-command behaviour. // // The class is dependency-injected so Vitest can drive the // `online` / `offline` listeners without touching the JSDOM // globals; production code falls back to `window`/`navigator`. import type { SubmitFailure, SubmitResult, SubmitSuccess } from "./submit"; /** * QueueOutcome is the discriminated union the draft store consumes * after asking the queue to submit a snapshot. Each variant tells * the consumer exactly which side-effect to apply to the * per-command statuses and the banner state. */ export type QueueOutcome = | { kind: "success"; result: SubmitSuccess } | { kind: "rejected"; failure: SubmitFailure } | { kind: "conflict"; code: string; message: string } | { kind: "paused"; code: string; message: string } | { kind: "offline" } | { kind: "failed"; reason: string }; /** * OrderQueueStartOptions carries the live primitives the queue * cannot resolve on its own. Tests inject deterministic stubs; * production passes `undefined` for everything except `onOnline`. */ export interface OrderQueueStartOptions { /** * onOnline is invoked when the browser flips from offline to * online (or when `start()` is called while already online and * the consumer wants an opportunistic flush). The consumer * decides whether a fresh `send()` is appropriate. */ onOnline: () => void; /** * onlineProbe returns the current online state. Defaults to * `navigator.onLine`; tests inject a closure over a mutable flag. */ onlineProbe?: () => boolean; /** * addEventListener / removeEventListener are the hooks the queue * uses to subscribe to the global `online` / `offline` events. * Defaults to `window.addEventListener` / `window.removeEventListener`; * tests inject manual emitters. */ addEventListener?: (event: string, handler: () => void) => void; removeEventListener?: (event: string, handler: () => void) => void; } const CODE_TURN_ALREADY_CLOSED = "turn_already_closed"; const CODE_GAME_PAUSED = "game_paused"; /** * OrderQueue holds the transport-side policy for the order draft * store. One instance per draft store; lifecycle is bound to the * store's `init` / `dispose`. */ export class OrderQueue { /** * online mirrors the latest browser online signal. Tests assert * on this rune to drive their state machine; production code * uses it via the draft store's `syncStatus` projection. */ online: boolean = $state(true); private onlineProbe: () => boolean = defaultOnlineProbe; private addEventListener: (event: string, handler: () => void) => void = defaultAddEventListener; private removeEventListener: (event: string, handler: () => void) => void = defaultRemoveEventListener; private onOnlineCallback: (() => void) | null = null; private handleOnline: (() => void) | null = null; private handleOffline: (() => void) | null = null; /** * start subscribes to the browser online/offline events and * primes `online` from the current probe value. Calling start a * second time without `stop()` between them is a no-op so the * draft store's `init` stays idempotent under double mount. */ start(opts: OrderQueueStartOptions): void { if (this.onOnlineCallback !== null) return; this.onOnlineCallback = opts.onOnline; if (opts.onlineProbe !== undefined) { this.onlineProbe = opts.onlineProbe; } if (opts.addEventListener !== undefined) { this.addEventListener = opts.addEventListener; } if (opts.removeEventListener !== undefined) { this.removeEventListener = opts.removeEventListener; } this.online = this.onlineProbe(); this.handleOnline = () => { this.online = true; this.onOnlineCallback?.(); }; this.handleOffline = () => { this.online = false; }; this.addEventListener("online", this.handleOnline); this.addEventListener("offline", this.handleOffline); } /** * stop unsubscribes from the browser events and forgets the * consumer callback. Subsequent `send()` calls still classify * an injected `SubmitResult` correctly, but no online flips will * be propagated until `start()` runs again. */ stop(): void { if (this.handleOnline !== null) { this.removeEventListener("online", this.handleOnline); this.handleOnline = null; } if (this.handleOffline !== null) { this.removeEventListener("offline", this.handleOffline); this.handleOffline = null; } this.onOnlineCallback = null; } /** * send drives one submit attempt: * * - If the queue is currently offline, returns `{kind:"offline"}` * without invoking submitFn. The consumer is expected to * leave the in-flight commands in their pre-submit state and * wait for the `onOnline` callback. * - Otherwise invokes submitFn. Any throw is reclassified: * a fresh `onlineProbe()` returning false collapses into * `offline`; otherwise the throw becomes `failed`. * - A successful `SubmitResult` is classified into `success`, * `rejected`, `conflict`, or `paused` depending on the * non-`ok` `resultCode` / `code` fields. * * The queue intentionally does NOT retry inline. The plan's * "retry once on reconnect" budget is realised by the consumer * (the draft store) hooking the `onOnline` callback to * `scheduleSync()` — at most one fresh `send()` per online flip. */ async send(submitFn: () => Promise): Promise { if (!this.online) { return { kind: "offline" }; } let result: SubmitResult; try { result = await submitFn(); } catch (err) { if (!this.onlineProbe()) { this.online = false; return { kind: "offline" }; } return { kind: "failed", reason: errorMessage(err) }; } return classifyResult(result); } } /** * classifyResult maps a `SubmitResult` onto the queue outcome the * draft store consumes. Exported for unit-tests; the inline path * uses it through `OrderQueue.send`. */ export function classifyResult(result: SubmitResult): QueueOutcome { if (result.ok) { return { kind: "success", result }; } const code = pickCode(result); if (code === CODE_TURN_ALREADY_CLOSED) { return { kind: "conflict", code, message: result.message }; } if (code === CODE_GAME_PAUSED) { return { kind: "paused", code, message: result.message }; } return { kind: "rejected", failure: result }; } function pickCode(failure: SubmitFailure): string { // The gateway sets `resultCode = backendError.Code` for non-ok // replies (see `gateway/internal/backendclient/user_commands.go` // `projectUserBackendError`). The FBS-encoded payload body is // parsed by `submit.ts.decodeError`, which falls back to the // `resultCode` when the body cannot be decoded; we therefore // prefer `code` only when it differs from the result code, but // either field carries the same authoritative value. if (failure.code && failure.code !== failure.resultCode) { return failure.code; } return failure.resultCode; } function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; if (typeof err === "string") return err; return "submit failed"; } function defaultOnlineProbe(): boolean { if (typeof navigator === "undefined") { return true; } return navigator.onLine !== false; } function defaultAddEventListener(event: string, handler: () => void): void { if (typeof window === "undefined") return; window.addEventListener(event, handler); } function defaultRemoveEventListener(event: string, handler: () => void): void { if (typeof window === "undefined") return; window.removeEventListener(event, handler); }