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
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.
234 lines
7.7 KiB
TypeScript
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();
|
|
}
|