// Order notes — SIAX collaboration-kernelns första produktkonsument (spår C). // // Server-authoritative store över 0pts data-lager (Postgres när DATABASE_URL // är satt, annars samma dokumenterade in-memory-fallback som orders-store). // Kernel-semantik (@siax/collaboration 0.3.0): // - varje note ÄR en CollaborationOperation (id/actorId/clock/type/payload) // - nya operationer får sin klocka via nextLamportClock (max lokal/observerad + 1) // - listning återställs deterministiskt via mergeOperationLog/compareOperations // (klocka -> actorId -> id), så två repliker listar i SAMMA ordning // - samtidighet: optimistisk version; stale skrivning kastar // NoteVersionConflictError -> routen svarar 409 (conflict behavior). import { mergeOperationLog, nextLamportClock, type CollaborationOperation, } from "@siax/collaboration"; import { getSqlForDurableStore } from "@/lib/db"; export interface OrderNote { note_id: string; order_id: string; tenant_id: string | null; author_id: string; body: string; version: number; clock: number; created_at: string; updated_at: string; } export class NoteVersionConflictError extends Error { constructor( public readonly noteId: string, public readonly expectedVersion: number, public readonly currentVersion: number, ) { super( `Note "${noteId}" was updated by someone else: expected version ${expectedVersion}, current is ${currentVersion}`, ); this.name = "NoteVersionConflictError"; } } export class NoteNotFoundError extends Error { constructor(public readonly noteId: string) { super(`Note "${noteId}" does not exist`); this.name = "NoteNotFoundError"; } } export class OrderNotFoundError extends Error { constructor(public readonly orderId: string) { super(`Order "${orderId}" does not exist`); this.name = "OrderNotFoundError"; } } // Non-durable fallback used only until a database is provisioned (same // decision as lib/orders-store.ts — resolveStoreMode fails hard in production // without a URL, so this path cannot silently swallow production writes). const mem = new Map(); /** Kernel-projection: a note row IS a collaboration operation. */ function toOperation(note: OrderNote): CollaborationOperation<{ body: string }> { return { id: note.note_id, actorId: note.author_id, clock: note.clock, type: "note.upsert", payload: { body: note.body }, }; } /** Deterministic snapshot: kernel merge wins, never insertion order. */ function snapshot(notes: Iterable): OrderNote[] { const log = mergeOperationLog([...notes].map(toOperation)); const byId = new Map([...notes].map((n) => [n.note_id, n])); return log .map((op) => byId.get(op.id)) .filter((n): n is OrderNote => Boolean(n)); } function rowToNote(row: Record): OrderNote { return { note_id: String(row.note_id), order_id: String(row.order_id), tenant_id: row.tenant_id === null || row.tenant_id === undefined ? null : String(row.tenant_id), author_id: String(row.author_id), body: String(row.body), version: Number(row.version), clock: Number(row.clock), created_at: new Date(row.created_at as string).toISOString(), updated_at: new Date(row.updated_at as string).toISOString(), }; } type NotesSql = NonNullable>; async function maxClock(sql: NotesSql, orderId: string): Promise { const rows = await sql` SELECT COALESCE(MAX(clock), 0) AS max_clock FROM order_notes WHERE order_id = ${orderId} `; return Number(rows[0]?.max_clock ?? 0); } export async function listNotes(orderId: string): Promise { const sql = getSqlForDurableStore("order-notes"); if (!sql) { return snapshot( [...mem.values()].filter((n) => n.order_id === orderId), ); } const rows = await sql` SELECT * FROM order_notes WHERE order_id = ${orderId} `; return snapshot(rows.map(rowToNote)); } export interface AddNoteInput { order_id: string; tenant_id: string | null; author_id: string; body: string; } export async function addNote(input: AddNoteInput): Promise { const body = input.body.trim(); if (!body) throw new TypeError("note body must be non-empty"); const note: OrderNote = { note_id: `note_${crypto.randomUUID()}`, order_id: input.order_id, tenant_id: input.tenant_id, author_id: input.author_id, body, version: 1, // Kernel: Lamport-klockan = max(lokal, observerad) + 1. clock: 1, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; note.clock = nextLamportClock(await peekMaxClock(input.order_id)); const sql = getSqlForDurableStore("order-notes"); if (!sql) { mem.set(note.note_id, note); return note; } const rows = await sql` INSERT INTO order_notes (note_id, order_id, tenant_id, author_id, body, version, clock, created_at, updated_at) VALUES (${note.note_id}, ${note.order_id}, ${note.tenant_id}, ${note.author_id}, ${note.body}, ${note.version}, ${note.clock}, ${note.created_at}, ${note.updated_at}) RETURNING * `; return rowToNote(rows[0]); } async function peekMaxClock(orderId: string): Promise { const sql = getSqlForDurableStore("order-notes"); if (!sql) { let max = 0; for (const n of mem.values()) if (n.order_id === orderId && n.clock > max) max = n.clock; return max; } return maxClock(sql, orderId); } export interface UpdateNoteInput { note_id: string; author_id: string; body: string; /** Optimistic concurrency: must equal the caller's seen version, else 409. */ expected_version: number; } export async function updateNote(input: UpdateNoteInput): Promise { const body = input.body.trim(); if (!body) throw new TypeError("note body must be non-empty"); if (!Number.isInteger(input.expected_version) || input.expected_version < 1) { throw new TypeError("expected_version must be a positive integer"); } const sql = getSqlForDurableStore("order-notes"); if (!sql) { const current = mem.get(input.note_id); if (!current) throw new NoteNotFoundError(input.note_id); if (current.version !== input.expected_version) { throw new NoteVersionConflictError(input.note_id, input.expected_version, current.version); } const updated: OrderNote = { ...current, body, version: current.version + 1, clock: nextLamportClock(current.clock), updated_at: new Date().toISOString(), }; mem.set(updated.note_id, updated); return updated; } const rows = await sql` UPDATE order_notes SET body = ${body}, version = version + 1, clock = clock + 1, updated_at = ${new Date().toISOString()} WHERE note_id = ${input.note_id} AND version = ${input.expected_version} RETURNING * `; if (rows.length === 0) { const existing = await sql`SELECT * FROM order_notes WHERE note_id = ${input.note_id}`; if (existing.length === 0) throw new NoteNotFoundError(input.note_id); throw new NoteVersionConflictError( input.note_id, input.expected_version, Number(existing[0].version), ); } return rowToNote(rows[0]); } /** Direct read by note id — used by the route for server-authoritative tenant checks. */ export async function findNote(noteId: string): Promise { const sql = getSqlForDurableStore("order-notes"); if (!sql) return mem.get(noteId) ?? null; const rows = await sql`SELECT * FROM order_notes WHERE note_id = ${noteId}`; return rows.length > 0 ? rowToNote(rows[0]) : null; } /** Delete for tests: the in-memory fallback needs isolation between tests. */ export function resetOrderNotesMemoryForTests(): void { mem.clear(); }