From 4062c2c9302eca2a24c710c1a1437a7ed581c153 Mon Sep 17 00:00:00 2001 From: dannyy2000 Date: Sun, 27 Sep 2026 16:24:17 +0100 Subject: [PATCH] fix(world): normalize vector clocks and enforce phase-105 conflict check on POST /api/world (#275) checkWorldConflict was imported by POST /api/world but never called, so concurrent world edits still overwrote each other. Worlds now carry a per-author vector_clock; clocks arriving as a Map, plain object, or [node, counter] entries array are normalized before comparison so equal clocks no longer register as divergent. Stale or concurrent saves return 409 WORLD_VERSION_CONFLICT with the server version, clock, and order. --- PROJECT_ARCHITECTURE.md | 1 + app/api/world/route.ts | 58 +++++++++++++++- docs/TECHNICAL.md | 2 + lib/__tests__/world-conflict.test.ts | 77 ++++++++++++++++++++- lib/narrative-world-store.ts | 9 ++- lib/world-conflict.ts | 100 ++++++++++++++++++++++++--- 6 files changed, 233 insertions(+), 14 deletions(-) diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 9ed78d5b..f2948526 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -148,6 +148,7 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | Flag | Env | Purpose | Default | Rollback | |------|-----|---------|---------|----------| +| `phase-105` | `NEXT_PUBLIC_FEATURE_PHASE_105` / `FEATURE_PHASE_105` | Co-authored world conflict detection: `POST /api/world` compares `expected_version` / `expected_vector_clock` (object, `Map` or `[node, counter]` entries — normalized before comparison) with the stored world and returns `409 WORLD_VERSION_CONFLICT` with `order` (`before`/`after`/`concurrent`) | off | Unset var, restart — saves overwrite unconditionally; stored `vector_clock` fields are ignored | | `phase-109` | `NEXT_PUBLIC_FEATURE_PHASE_109` / `FEATURE_PHASE_109` | Collaborative world permissions: owner-enforced role assignment (`editor`/`viewer`) per wallet via `GET/POST /api/world/[collection_id]/roles`; POST requires `X-Wallet-Signature` | off | Unset var, restart — roles endpoint returns 404; existing role data on disk is unaffected | | `phase-110` | `NEXT_PUBLIC_FEATURE_PHASE_110` / `FEATURE_PHASE_110` | Full-text narrative search across world collections: filter by entity ID, location, or free text via `GET /api/world/search` | off | Unset var, restart — search route returns 404 | | `phase-112` | `NEXT_PUBLIC_FEATURE_PHASE_112` / `FEATURE_PHASE_112` | World export to portable formats (`json`, `markdown`) via `GET /api/world/[collection_id]/export?format=` with `Content-Disposition` download headers | off | Unset var, restart — export route returns 404 | diff --git a/app/api/world/route.ts b/app/api/world/route.ts index 2fdbbdbd..005e904d 100644 --- a/app/api/world/route.ts +++ b/app/api/world/route.ts @@ -11,7 +11,7 @@ import { type NarratorTone, } from "@/lib/narrative-world-store" import { checkAndUnlock } from "@/lib/achievement-store" -import { checkWorldConflict } from "@/lib/world-conflict" +import { checkWorldConflict, normalizeVectorClock, type VectorClock } from "@/lib/world-conflict" export type WorldsListItem = { collectionId: number @@ -23,6 +23,8 @@ export type WorldsListItem = { narrator_tone?: NarratorTone /** Current revision — pass as `expected_version` on the next save (phase-105). */ version?: number + /** Per-author vector clock — pass as `expected_vector_clock` on the next save (phase-105). */ + vector_clock?: VectorClock } export type WorldsGlobalStats = { @@ -46,6 +48,7 @@ export async function GET() { latestNarrative: narratives[0]?.narrative ?? null, narrator_tone: data.narrator_tone, version: data.version, + vector_clock: normalizeVectorClock(data.vector_clock) ?? undefined, } }), ) @@ -80,6 +83,8 @@ type WorldSaveBody = { creator_wallet?: unknown /** Client's last-known world version — only checked when phase-105 is enabled. */ expected_version?: unknown + /** Client's last-known vector clock (object or `[node, counter]` entries) — phase-105. */ + expected_vector_clock?: unknown } function isNonEmptyString(v: unknown): v is string { @@ -129,7 +134,49 @@ export async function POST(request: NextRequest) { ? body.creator_wallet : undefined - await saveWorldForCollection(collectionId, { + let expectedVersion: number | undefined + if (body.expected_version !== undefined) { + if (!Number.isSafeInteger(body.expected_version) || (body.expected_version as number) < 0) { + return NextResponse.json( + { error: "expected_version debe ser un entero no negativo" }, + { status: 400 }, + ) + } + expectedVersion = body.expected_version as number + } + + let expectedVectorClock: VectorClock | undefined + if (body.expected_vector_clock !== undefined) { + const normalized = normalizeVectorClock(body.expected_vector_clock) + if (!normalized) { + return NextResponse.json( + { error: "expected_vector_clock inválido: se espera un objeto o una lista de pares [nodo, contador]" }, + { status: 400 }, + ) + } + expectedVectorClock = normalized + } + + const conflict = checkWorldConflict( + await getWorldForCollection(collectionId), + expectedVersion, + expectedVectorClock, + ) + if (conflict.conflict) { + return NextResponse.json( + { + error: "WORLD_VERSION_CONFLICT", + order: conflict.order, + server_version: conflict.serverVersion, + client_version: conflict.clientVersion, + server_vector_clock: conflict.serverVectorClock, + current: conflict.current, + }, + { status: 409 }, + ) + } + + const saved = await saveWorldForCollection(collectionId, { world_name: body.world_name.trim(), world_prompt: body.world_prompt.trim(), narrator_tone: isValidTone(body.narrator_tone) ? body.narrator_tone : undefined, @@ -143,5 +190,10 @@ export async function POST(request: NextRequest) { void checkAndUnlock(creatorWallet, { has_world: true }).catch(() => { /* silent */ }) } - return NextResponse.json({ ok: true, collection_id: collectionId }) + return NextResponse.json({ + ok: true, + collection_id: collectionId, + version: saved.version, + vector_clock: saved.vector_clock, + }) } diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 961506a5..4beacb84 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -72,6 +72,7 @@ flowchart TB | `lib/phase-copy.ts` | Centralized i18n dictionary (EN/ES) | | `lib/server-data-paths.ts` | Writable data location abstraction | | `lib/feature-flags.ts` | Flag registry (phase-107,111,113,114 + 116,117,119,120 + 121..124, env resolution, rollback notes) | +| `lib/world-conflict.ts` | World save conflict detection (phase-105): version check + per-author vector clock normalization (object / `Map` / entries array) and ordering | | `lib/story-arc-continuity.ts` | AI story-arc continuity check against recent world narratives (phase-107) | | `lib/narrative-world-store.ts` | World/narrative JSON store + localized per-(tokenId,lang) narrative cache (phase-111) + world export snapshot builder and markdown renderer (phase-112) + collaborative role store with ownership enforcement (phase-109) + lore link store with back-reference index (phase-115) | | `lib/ipfs-upload-retry.ts` | IPFS upload retry w/ exponential backoff + sha256 checksum (phase-120) | @@ -147,6 +148,7 @@ settlement verifier and must not accept unsigned base64 payloads. | Flag | Route | Extension | Flag off | |------|-------|-----------|----------| +| `phase-105` | `POST /api/world` | Accepts `expected_version` and `expected_vector_clock`; `409 WORLD_VERSION_CONFLICT` with `order`, `server_version`, `server_vector_clock`, `current` when the client's view is stale or concurrent. Success returns the new `version` and `vector_clock` | Unconditional overwrite | | `phase-109` | `GET /api/world/[collection_id]/roles` | Returns current role map (`editor`/`viewer`) for the world collection | `404` disabled | | `phase-109` | `POST /api/world/[collection_id]/roles` | Assigns a role to a target wallet; requires `X-Wallet-Signature` header and acting wallet must be the world owner (403 otherwise) | `404` disabled | | `phase-110` | `GET /api/world/search` | Full-text narrative search across all world collections; supports `?entity=`, `?location=`, `?q=` | `404` disabled | diff --git a/lib/__tests__/world-conflict.test.ts b/lib/__tests__/world-conflict.test.ts index d33e3e3b..f873405d 100644 --- a/lib/__tests__/world-conflict.test.ts +++ b/lib/__tests__/world-conflict.test.ts @@ -1,6 +1,11 @@ import { describe, it, before, after } from "node:test" import * as assert from "node:assert/strict" -import { checkWorldConflict } from "@/lib/world-conflict" +import { + checkWorldConflict, + compareVectorClocks, + incrementVectorClock, + normalizeVectorClock, +} from "@/lib/world-conflict" import type { WorldCollectionData } from "@/lib/narrative-world-store" describe("phase-105 world conflict detection", () => { @@ -49,4 +54,74 @@ describe("phase-105 world conflict detection", () => { assert.equal(res.conflict, false) process.env.FEATURE_PHASE_105 = "1" }) + + const clocked: WorldCollectionData = { ...world, vector_clock: { alice: 2, bob: 1 } } + + it("normalizes Map, object and entries-array clocks to the same shape", () => { + const fromObject = normalizeVectorClock({ alice: 2, bob: 1 }) + const fromMap = normalizeVectorClock(new Map([["bob", 1], ["alice", 2]])) + const fromArray = normalizeVectorClock([["alice", 2], ["bob", 1]]) + assert.deepEqual(fromMap, fromObject) + assert.deepEqual(fromArray, fromObject) + }) + + it("drops zero counters and keeps the max of duplicate entries", () => { + assert.deepEqual(normalizeVectorClock({ alice: 0 }), {}) + assert.deepEqual(normalizeVectorClock([["alice", 1], ["alice", 3]]), { alice: 3 }) + }) + + it("rejects malformed clocks", () => { + assert.equal(normalizeVectorClock(null), null) + assert.equal(normalizeVectorClock("alice:1"), null) + assert.equal(normalizeVectorClock({ alice: -1 }), null) + assert.equal(normalizeVectorClock({ alice: 1.5 }), null) + assert.equal(normalizeVectorClock([["alice"]]), null) + assert.equal(normalizeVectorClock([[1, 1]]), null) + }) + + it("orders vector clocks", () => { + assert.equal(compareVectorClocks({ a: 1 }, { a: 1 }), "equal") + assert.equal(compareVectorClocks({ a: 1 }, { a: 2 }), "before") + assert.equal(compareVectorClocks({ a: 2, b: 1 }, { a: 2 }), "after") + assert.equal(compareVectorClocks({ a: 2 }, { b: 1 }), "concurrent") + }) + + it("increments a clock stored as an entries array", () => { + assert.deepEqual(incrementVectorClock([["alice", 2]], "alice"), { alice: 3 }) + assert.deepEqual(incrementVectorClock(undefined, "bob"), { bob: 1 }) + }) + + it("no conflict when an entries-array clock matches a stored object clock", () => { + const expected = normalizeVectorClock([["bob", 1], ["alice", 2]])! + const res = checkWorldConflict(clocked, 3, expected) + assert.equal(res.conflict, false) + }) + + it("no conflict when a Map clock matches a stored entries-array clock", () => { + const stored = { ...world, vector_clock: [["alice", 2], ["bob", 1]] } as unknown as WorldCollectionData + const expected = normalizeVectorClock(new Map([["alice", 2], ["bob", 1]]))! + const res = checkWorldConflict(stored, undefined, expected) + assert.equal(res.conflict, false) + }) + + it("reports a stale clock as 'before'", () => { + const res = checkWorldConflict(clocked, undefined, { alice: 2 }) + assert.equal(res.conflict, true) + if (res.conflict) { + assert.equal(res.order, "before") + assert.deepEqual(res.serverVectorClock, { alice: 2, bob: 1 }) + } + }) + + it("reports a diverged clock as 'concurrent'", () => { + const res = checkWorldConflict(clocked, undefined, { alice: 3 }) + assert.equal(res.conflict, true) + if (res.conflict) assert.equal(res.order, "concurrent") + }) + + it("still checks version for worlds saved before vector clocks existed", () => { + const res = checkWorldConflict(world, 2, {}) + assert.equal(res.conflict, true) + if (res.conflict) assert.equal(res.order, "before") + }) }) diff --git a/lib/narrative-world-store.ts b/lib/narrative-world-store.ts index 43ad2111..669b78b7 100644 --- a/lib/narrative-world-store.ts +++ b/lib/narrative-world-store.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" import { serverDataJsonPath } from "@/lib/server-data-paths" +import { incrementVectorClock, type VectorClock } from "@/lib/world-conflict" export type NarratorTone = "enigmatic" | "epic" | "scientific" | "folkloric" @@ -12,6 +13,7 @@ export type WorldCollectionData = { narrator_tone?: NarratorTone creator_wallet?: string version?: number + vector_clock?: VectorClock } export type WorldNarrativeData = { @@ -50,11 +52,11 @@ export async function saveWorldForCollection( data: Pick & { creator_wallet?: string }, -): Promise { +): Promise { const filePath = serverDataJsonPath("worldCollections") const store = await readJsonStore(filePath) const existing = store[String(collectionId)] - store[String(collectionId)] = { + const saved: WorldCollectionData = { ...existing, world_name: data.world_name, world_prompt: data.world_prompt, @@ -62,8 +64,11 @@ export async function saveWorldForCollection( ...(data.creator_wallet !== undefined ? { creator_wallet: data.creator_wallet } : {}), created_at: existing?.created_at ?? Date.now(), version: (existing?.version ?? 0) + 1, + vector_clock: incrementVectorClock(existing?.vector_clock, data.creator_wallet ?? "anonymous"), } + store[String(collectionId)] = saved await writeJsonStore(filePath, store) + return saved } export async function getAllWorldCollections(): Promise { diff --git a/lib/world-conflict.ts b/lib/world-conflict.ts index e840530b..c81514b9 100644 --- a/lib/world-conflict.ts +++ b/lib/world-conflict.ts @@ -6,7 +6,14 @@ * author's changes are lost ("co-authored worlds overwrite each other * blindly"). This module implements the scoped mechanism the issue * describes: optimistic concurrency control keyed on a monotonically - * increasing `version` field on `WorldCollectionData`. + * increasing `version` field on `WorldCollectionData`, plus a per-author + * `vector_clock` that distinguishes stale writes from truly concurrent ones. + * + * Vector clocks reach this module in more than one shape: a plain JSON + * object (`{ "G...A": 2 }`), a `Map`, or an entries array + * (`[["G...A", 2]]`, which is what `JSON.stringify([...map])` produces). + * Comparing those shapes directly always reports divergence, so every clock + * is normalized to a canonical `Record` before comparison. * * This is intentionally narrow — a "detect divergence, surface it to the * client" strategy for a single shared resource (a world's name/prompt/tone) @@ -19,12 +26,18 @@ import { isFeatureEnabled } from "@/lib/feature-flags" import type { WorldCollectionData } from "@/lib/narrative-world-store" +export type VectorClock = Record + +export type VectorClockOrder = "equal" | "before" | "after" | "concurrent" + export type WorldConflictResult = | { conflict: false } | { conflict: true serverVersion: number - clientVersion: number + clientVersion: number | undefined + serverVectorClock: VectorClock + order: VectorClockOrder current: Pick } @@ -32,30 +45,101 @@ export function isWorldConflictCheckEnabled(): boolean { return isFeatureEnabled("phase-105") } +function isCounter(v: unknown): v is number { + return typeof v === "number" && Number.isSafeInteger(v) && v >= 0 +} + /** - * Detects a concurrent-edit conflict between the client's expected version - * and the currently stored version of a world. + * Canonicalizes a vector clock received as a `Map`, a plain object, or an + * array of `[node, counter]` entries. Zero counters are dropped so `{}` and + * `{ a: 0 }` compare equal; duplicate entries keep the highest counter. + * Returns `null` for anything malformed. + */ +export function normalizeVectorClock(input: unknown): VectorClock | null { + let entries: unknown[] + if (input instanceof Map) { + entries = [...input.entries()] + } else if (Array.isArray(input)) { + entries = input + } else if (input !== null && typeof input === "object") { + entries = Object.entries(input) + } else { + return null + } + + const clock: VectorClock = {} + for (const entry of entries) { + if (!Array.isArray(entry) || entry.length !== 2) return null + const [node, counter] = entry + if (typeof node !== "string" || node.length === 0 || !isCounter(counter)) return null + if (counter === 0) continue + clock[node] = Math.max(clock[node] ?? 0, counter) + } + return clock +} + +export function compareVectorClocks(a: VectorClock, b: VectorClock): VectorClockOrder { + let aAhead = false + let bAhead = false + for (const node of new Set([...Object.keys(a), ...Object.keys(b)])) { + const av = a[node] ?? 0 + const bv = b[node] ?? 0 + if (av > bv) aAhead = true + else if (bv > av) bAhead = true + } + if (aAhead && bAhead) return "concurrent" + if (aAhead) return "after" + if (bAhead) return "before" + return "equal" +} + +export function incrementVectorClock(clock: unknown, node: string): VectorClock { + const next = normalizeVectorClock(clock) ?? {} + next[node] = (next[node] ?? 0) + 1 + return next +} + +/** + * Detects a concurrent-edit conflict between what the client last saw and + * the currently stored world. * * - Flag off → never reports a conflict (legacy overwrite behavior). * - World doesn't exist yet → nothing to diverge from. - * - Client omitted `expectedVersion` → opts out of the check (keeps - * backward compatibility for older callers). + * - Client sent `expectedVectorClock` → conflict unless it equals the + * server's clock after normalization; `order` tells the client whether it + * is merely stale (`before`) or diverged (`concurrent`). + * - `expectedVersion` is still checked when the clocks agree (worlds saved + * before vector clocks existed); omitting both opts out of the check + * (keeps backward compatibility for older callers). */ export function checkWorldConflict( existing: WorldCollectionData | null, expectedVersion: number | undefined, + expectedVectorClock?: VectorClock, ): WorldConflictResult { if (!isWorldConflictCheckEnabled()) return { conflict: false } if (!existing) return { conflict: false } - if (expectedVersion === undefined) return { conflict: false } + if (expectedVersion === undefined && expectedVectorClock === undefined) return { conflict: false } const serverVersion = existing.version ?? 0 - if (expectedVersion === serverVersion) return { conflict: false } + const serverVectorClock = normalizeVectorClock(existing.vector_clock) ?? {} + + let order: VectorClockOrder = + expectedVectorClock !== undefined + ? compareVectorClocks(expectedVectorClock, serverVectorClock) + : "equal" + // Worlds saved before vector clocks existed only carry `version`. + if (order === "equal" && expectedVersion !== undefined && expectedVersion !== serverVersion) { + order = expectedVersion < serverVersion ? "before" : "after" + } + if (order === "equal") return { conflict: false } return { conflict: true, serverVersion, clientVersion: expectedVersion, + serverVectorClock, + order, current: { world_name: existing.world_name, world_prompt: existing.world_prompt,