Files
0pt/lib/order-notes-store.ts
admin 04813c34b0
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 7s
Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 18s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / quality (push) Failing after 33s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / integration (pull_request) Successful in 31s
CI (SIAX Cloud) / security (pull_request) Successful in 23s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / quality (pull_request) Failing after 21s
feat(collab): order-anteckningar — @siax/collaboration-kernelns första produktkonsument (spår C)
Kernel 0.3.0 används som capability, inte som paket-dekor:
- lib/order-notes-store.ts: varje note ÄR en CollaborationOperation;
  nextLamportClock för nya klockor, mergeOperationLog/compareOperations
  för deterministisk listning; optimistisk version -> 409 vid stale skrivning.
- lib/order-notes-client.ts: kernel-CollaborationTransport över host-API;
  offline = medvetet no-op + varning (ingen tyst förlust), 409 -> ConflictError.
- app/api/admin/order-notes: requireRouteAuth (fail-closed 401),
  server-authoritative tenant-guard (403), AUD0-audit via hostens klient.
- app/admin/notes: admin-yta med offline-växel, konflikt-banner + reload.
- sql/016_order_notes.sql: persistens (DB-tabell, version + Lamport-klocka).

Tester: API (happy/conflict 409/empty/400/401/403/404), klient-adapter
(offline no-op utan fetch, 409-mappning, presence), SSR-render. 18 nya test.
2026-09-30 17:07:21 +02:00

234 lines
7.7 KiB
TypeScript

// 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<string, OrderNote>();
/** 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>): 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<string, unknown>): 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<ReturnType<typeof getSqlForDurableStore>>;
async function maxClock(sql: NotesSql, orderId: string): Promise<number> {
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<OrderNote[]> {
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<OrderNote> {
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<number> {
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<OrderNote> {
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<OrderNote | null> {
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();
}