Step 14.4: mutation queue and tab lock
CI / check (push) Successful in 7m24s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-09 19:19:03 -04:00
parent b100e8664b
commit fcc3047af8
6 changed files with 359 additions and 1 deletions

View File

@ -1360,3 +1360,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Platform objects are handed in** (`IDBFactory`, the Capacitor Filesystem plugin through a five-method interface), so the engine has no browser or Capacitor imports and the backends are tested with `fake-indexeddb` and a fake plugin. The real plugin is wired up in the web app (14.7).
- **The engine's TypeScript config adds the DOM library** for the IndexedDB and Web Locks types; it still only touches them through what it's given.
- **Pruning old resolved dispatches belongs to the save document**, not storage; it happens when a command saves (14.4).
## Step 14.4
- **Copy, change, validate, save, adopt**: a command works on a `structuredClone` of the save and the engine only adopts the result after it validates and is written. That replaces row locks and transactions (Step 6.14): with one queue there are no concurrent writers, and a failed command has nothing to roll back.
- **Reads go through the same queue**, so a read never sees a half-applied command.
- **The tab lock gates writes, not reads**: a tab that lost the game can still show what it had, but every command is refused with `GameLockedError` until it takes the game back. After taking over, a tab drops its cached save and reloads from storage, so it continues from the other tab's last save.
- **Where Web Locks don't exist** (native apps have one window; very old browsers) the tab simply holds the game.
- **The race test uses a minimal purchase command** here; 14.5 repeats it with the real `buyShip`.

View File

@ -344,7 +344,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
- [x] **Step 14.1 — Engine package and clock.** New `packages/engine` (depends on content, schema, sim; picked up as a Vitest project). `Clock` interface (`now()`) and `DeviceClock`: device time (injected, `Date.now` by default) plus a developer offset, clamped to the last time it handed out; `lastTimeSeen` for the save, `restore(lastTimeSeen)` after loading one, `advance(ms)` for the developer tools (forward only). Tests (`packages/engine/test/clock.test.ts`): offset applied; device clock set back returns the last time seen; a forward move shows at once; a save's last time seen wins over a device that is behind; the offset can't go backward.
- [x] **Step 14.2 — Save document schema and migrations.** `packages/schema/src/save.ts`: the save document (every database table for one company, without player ids, plus `saveVersion`, `saveId`, `lastTimeSeen`), with compile-time checks that its contract, dispatch record and resolution match the sim types. Migration framework: `SAVE_MIGRATIONS` (ordered version-to-version steps; none yet, version 1 is the first format), `applyMigrations` (steps a save up one version at a time), `migrateSave` (migrate, then validate; never throws, gives a readable error). Tests (`packages/schema/test/save.test.ts`, fixture `save-fixture.ts` reused later): a fully populated save validates; a wrong field is rejected naming `company.credits`; non-saves and saves from a newer build are refused; a version-1 save round-trips through JSON and migration unchanged; migrations chain in order and a missing step is reported.
- [x] **Step 14.3 — Storage layer.** `packages/engine/src/storage.ts`: `SaveStorage` (load, save with rotation, list backups, load a backup, clear) over a `SaveBackend` that stores text in slots `main`, `backup-1..3`, each write all-or-nothing. Saving moves the previous main save to backup 1 (and 1→2, 2→3), so the last three saves are kept. Loading: no main save → empty; an unreadable or invalid one → the newest backup that validates, reported as `restored-from-backup` with the problem; nothing valid → `unreadable`. Backends: `MemoryBackend`, `IndexedDbBackend` (one object store, handed an `IDBFactory`), `FilesystemBackend` (one JSON file per slot in the app data directory, written to a `.tmp` file then renamed; handed the Capacitor Filesystem plugin through a small interface). Tests (`packages/engine/test/storage.test.ts`): one suite run on all three (IndexedDB via `fake-indexeddb`, the filesystem via a fake plugin): empty, round trip, exactly three backups newest first, corrupted main save falls back past a broken backup to the next valid one, unreadable, clear; and a native write interrupted mid-file or before the rename leaves the previous save loading.
- [ ] Step 14.4 — Mutation queue and tab lock
- [x] **Step 14.4 — Mutation queue and tab lock.** `packages/engine/src/core.ts`: `EngineCore` runs every `read`/`write` through one promise queue. A write gets a copy of the cached save (loaded from storage on first use, restoring the clock's last time seen), returns the new save, which is stamped with `lastTimeSeen`, validated against the save schema, saved and only then adopted; a throw or an invalid result leaves storage and cache unchanged. `canWrite` (the tab lock) refuses writes with `GameLockedError`; `reload()` drops the cache after another tab had the game; `cryptoRandom` gives ids (`randomUUID`) and 32-bit world seeds (`getRandomValues`). `src/lock.ts`: `TabLock` over `navigator.locks` (`ifAvailable` to take a free game, `steal` for "Use here", `onLost` when another tab takes over; always held where Web Locks don't exist). Tests (`test/core.test.ts`): two rapid purchases with credits for one give exactly one purchase and no negative balance; a throwing command and an invalid result leave the save and cache as they were; `lastTimeSeen` follows the clock but never drops below the save's; with a Web Locks fake, a second tab is refused until it takes over, the first tab is told and refused, and the second sees the first's saved state after `reload`. The "open in another tab" screen is built with the client switch (14.7).
- [ ] Step 14.5 — Port the game logic
- [ ] Step 14.6 — Resolution loop
- [ ] Step 14.7 — Switch the client to the engine

127
packages/engine/src/core.ts Normal file
View File

@ -0,0 +1,127 @@
import { saveDocumentSchema, type SaveDocument } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim";
import type { Clock } from "./clock";
import type { LoadResult, SaveStorage } from "./storage";
/** Ids and seeds (CR-014 §3): the platform's cryptographic random source, outside the sim. */
export interface RandomSource {
id(): string;
/** A 32-bit unsigned world seed. */
seed(): number;
}
export const cryptoRandom: RandomSource = {
id: () => globalThis.crypto.randomUUID(),
seed: () => globalThis.crypto.getRandomValues(new Uint32Array(1))[0]!,
};
/** Thrown when this tab doesn't hold the game (decision 0.5.90). */
export class GameLockedError extends Error {
constructor() {
super("Deadhead is open in another tab.");
this.name = "GameLockedError";
}
}
export interface CoreDeps {
storage: SaveStorage;
clock: Clock & { restore?(lastTimeSeen: RealTime): void };
random?: RandomSource;
/** False while another tab holds the game; commands are refused. */
canWrite?: () => boolean;
}
/**
* The single writer (decision 0.5.90): every command runs alone, in order, on
* a copy of the save. Only when it finishes and the result validates is the
* copy saved and adopted; a command that throws leaves both untouched.
*/
export class EngineCore {
#queue: Promise<unknown> = Promise.resolve();
#doc: SaveDocument | null = null;
#loaded = false;
#lastLoad: LoadResult | null = null;
readonly random: RandomSource;
constructor(private readonly deps: CoreDeps) {
this.random = deps.random ?? cryptoRandom;
}
/** The time now, on the engine's clock. */
now(): RealTime {
return this.deps.clock.now();
}
/** How the save was last loaded (for "a backup was used" and debug info). */
get lastLoad(): LoadResult | null {
return this.#lastLoad;
}
get storageKind(): string {
return this.deps.storage.kind;
}
/** Forget the cached save (another tab may have changed it); the next command reloads. */
reload(): void {
this.#loaded = false;
this.#doc = null;
}
async #current(): Promise<SaveDocument | null> {
if (!this.#loaded) {
const result = await this.deps.storage.load();
this.#lastLoad = result;
this.#doc =
result.status === "loaded" || result.status === "restored-from-backup" ? result.save : null;
if (this.#doc) this.deps.clock.restore?.(this.#doc.lastTimeSeen);
this.#loaded = true;
}
return this.#doc;
}
#enqueue<T>(work: () => Promise<T>): Promise<T> {
const next = this.#queue.then(work, work);
this.#queue = next.catch(() => undefined);
return next;
}
/** Reads the save (null before a company exists) without changing it. */
read<T>(fn: (save: SaveDocument | null, now: RealTime) => T | Promise<T>): Promise<T> {
return this.#enqueue(async () => fn(await this.#current(), this.now()));
}
/**
* Changes the save. `fn` gets a copy (or null before a company exists) and
* returns the new save, plus a result for the caller.
*/
write<T>(
fn: (
save: SaveDocument | null,
now: RealTime,
) => { save: SaveDocument; result: T } | Promise<{ save: SaveDocument; result: T }>,
): Promise<T> {
return this.#enqueue(async () => {
if (this.deps.canWrite && !this.deps.canWrite()) throw new GameLockedError();
const current = await this.#current();
const now = this.now();
const out = await fn(current ? structuredClone(current) : null, now);
const next = saveDocumentSchema.parse({
...out.save,
lastTimeSeen: Math.max(out.save.lastTimeSeen, now),
});
await this.deps.storage.save(next);
this.#doc = next;
return out.result;
});
}
/** Removes the save entirely (new company, import). */
clear(): Promise<void> {
return this.#enqueue(async () => {
if (this.deps.canWrite && !this.deps.canWrite()) throw new GameLockedError();
await this.deps.storage.clear();
this.#doc = null;
this.#loaded = true;
});
}
}

View File

@ -13,3 +13,11 @@ export {
type SaveBackend,
type Slot,
} from "./storage";
export {
cryptoRandom,
EngineCore,
GameLockedError,
type CoreDeps,
type RandomSource,
} from "./core";
export { GAME_LOCK, TabLock, type LockManagerLike } from "./lock";

View File

@ -0,0 +1,76 @@
/**
* One tab runs the game (decision 0.5.90), using the Web Locks API: the tab
* that holds the lock runs commands; another tab can take it over ("Use here"),
* and the tab that loses it stops.
*/
/** The parts of `navigator.locks` this uses, so tests can pass a fake. */
export interface LockManagerLike {
request(
name: string,
options: { ifAvailable?: boolean; steal?: boolean },
callback: (lock: unknown) => Promise<unknown> | unknown,
): Promise<unknown>;
}
export const GAME_LOCK = "deadhead-game";
export class TabLock {
#held = false;
#release: (() => void) | null = null;
#onLost: (() => void) | null = null;
constructor(
/** `navigator.locks`; absent (older browsers, native) means this tab always holds the game. */
private readonly locks: LockManagerLike | undefined,
private readonly name = GAME_LOCK,
) {}
get held(): boolean {
return this.locks === undefined || this.#held;
}
/** Called when another tab takes the game over. */
onLost(callback: () => void): void {
this.#onLost = callback;
}
/**
* Takes the game if no other tab has it (or, with `takeOver`, from the tab
* that does). Resolves true when this tab now holds it.
*/
acquire(options: { takeOver?: boolean } = {}): Promise<boolean> {
if (this.locks === undefined) return Promise.resolve(true);
if (this.#held) return Promise.resolve(true);
return new Promise((resolve) => {
const request = this.locks!.request(
this.name,
options.takeOver ? { steal: true } : { ifAvailable: true },
(lock) => {
if (lock === null) {
resolve(false);
return undefined;
}
this.#held = true;
resolve(true);
// Hold the lock until released here or stolen by another tab.
return new Promise<void>((release) => (this.#release = release));
},
);
request.catch(() => {
// Another tab stole the lock.
if (this.#held) {
this.#held = false;
this.#release = null;
this.#onLost?.();
}
});
});
}
release(): void {
this.#held = false;
this.#release?.();
this.#release = null;
}
}

View File

@ -0,0 +1,139 @@
import type { SaveDocument } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { fullSave } from "../../schema/test/save-fixture";
import {
EngineCore,
GameLockedError,
MemoryBackend,
SaveStorage,
TabLock,
type LockManagerLike,
} from "../src/index";
const fixedClock = (t = 5_000_000) => ({ now: () => t });
async function engineWith(save: SaveDocument, backend = new MemoryBackend()) {
const storage = new SaveStorage(backend);
await storage.save(save);
return { engine: new EngineCore({ storage, clock: fixedClock() }), storage, backend };
}
/** A purchase: refuses unless the credits cover it (as the real command will). */
const buy = (engine: EngineCore, price: number) =>
engine.write((save) => {
if (!save) throw new Error("no company");
if (save.company.credits < price) throw new Error("insufficient-funds");
save.company.credits -= price;
save.bays.push({ id: `bay-${save.bays.length}`, locationId: "moon", createdAt: 0 });
return { save, result: save.company.credits };
});
describe("mutation queue (Step 14.4, decision 0.5.90)", () => {
it("runs two rapid purchases one at a time: only the affordable one happens", async () => {
const start = { ...fullSave(), company: { ...fullSave().company, credits: 1_500 } };
const { engine, storage } = await engineWith(start);
const results = await Promise.allSettled([buy(engine, 1_000), buy(engine, 1_000)]);
expect(results.map((r) => r.status)).toEqual(["fulfilled", "rejected"]);
const saved = await storage.load();
expect(saved.status === "loaded" && saved.save.company.credits).toBe(500);
expect(saved.status === "loaded" && saved.save.bays.length).toBe(start.bays.length + 1);
});
it("leaves the save and the engine's copy unchanged when a command throws", async () => {
const { engine, backend } = await engineWith(fullSave());
const before = backend.slots.get("main");
await expect(
engine.write((save) => {
save!.company.credits = 0;
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(backend.slots.get("main")).toBe(before);
expect(await engine.read((save) => save!.company.credits)).toBe(fullSave().company.credits);
});
it("refuses to save a state that doesn't validate", async () => {
const { engine, backend } = await engineWith(fullSave());
const before = backend.slots.get("main");
await expect(
engine.write((save) => ({ save: { ...save!, company: null as never }, result: 0 })),
).rejects.toThrow();
expect(backend.slots.get("main")).toBe(before);
});
it("records the time it last saw, never going below the save's", async () => {
const lastSeen = fullSave().lastTimeSeen;
for (const [clockAt, expected] of [
[lastSeen + 60_000, lastSeen + 60_000],
[lastSeen - 60_000, lastSeen],
]) {
const storage = new SaveStorage(new MemoryBackend());
await storage.save(fullSave());
const engine = new EngineCore({ storage, clock: fixedClock(clockAt) });
await engine.write((save) => ({ save: save!, result: null }));
const saved = await storage.load();
expect(saved.status === "loaded" && saved.save.lastTimeSeen).toBe(expected);
}
});
});
/** Web Locks semantics: one holder; ifAvailable gets null when held; steal aborts the holder. */
class FakeLocks implements LockManagerLike {
#holder: { reject: (e: Error) => void } | null = null;
request(
_name: string,
options: { ifAvailable?: boolean; steal?: boolean },
callback: (lock: unknown) => unknown,
): Promise<unknown> {
if (this.#holder && options.ifAvailable) return Promise.resolve(callback(null));
if (this.#holder && options.steal) {
this.#holder.reject(new DOMException("Lock broken by another request", "AbortError"));
this.#holder = null;
}
return new Promise((resolve, reject) => {
const holder = { reject };
this.#holder = holder;
Promise.resolve(callback({ name: "deadhead-game" })).then((value) => {
if (this.#holder === holder) this.#holder = null;
resolve(value);
});
});
}
}
describe("one tab runs the game (Step 14.4)", () => {
it("lets a second tab run commands only after it takes over", async () => {
const backend = new MemoryBackend();
const storage = new SaveStorage(backend);
await storage.save(fullSave());
const locks = new FakeLocks();
const lockA = new TabLock(locks);
const lockB = new TabLock(locks);
const tabA = new EngineCore({ storage, clock: fixedClock(), canWrite: () => lockA.held });
const tabB = new EngineCore({ storage, clock: fixedClock(), canWrite: () => lockB.held });
let aLost = false;
lockA.onLost(() => (aLost = true));
expect(await lockA.acquire()).toBe(true);
expect(await lockB.acquire()).toBe(false);
await buy(tabA, 100);
await expect(buy(tabB, 100)).rejects.toBeInstanceOf(GameLockedError);
// "Use here" in tab B.
expect(await lockB.acquire({ takeOver: true })).toBe(true);
await Promise.resolve();
expect(aLost).toBe(true);
expect(lockA.held).toBe(false);
await expect(buy(tabA, 100)).rejects.toBeInstanceOf(GameLockedError);
// Tab B picks up what tab A saved.
tabB.reload();
expect(await buy(tabB, 100)).toBe(fullSave().company.credits - 200);
});
it("always holds the game where Web Locks don't exist", async () => {
const lock = new TabLock(undefined);
expect(lock.held).toBe(true);
expect(await lock.acquire()).toBe(true);
});
});