1import { useCallback, useEffect, useState } from "preact/hooks";2import type { RowOf, WriteHandle } from "@nzip/lofi";3import { useLiveQuery, useWrite } from "@nzip/lofi/preact";4import { s } from "@nzip/lofi/schema";5import { app } from "../app.ts";67/**8 * Author-owned domain module. Tables are the nouns of the app; verbs are what9 * happens to them. This module declares two verbs over the `incidents` table,10 * `reportIncident` with effects that run when the write's sync fate settles11 * and `setIncidentStatus` as a plain verb, then wraps them in a domain hook.12 */13const incidentsTable = app.schema.incidents;1415/** The row type comes straight from the declared schema. */16export type Incident = RowOf<typeof incidentsTable>;1718export type Severity = Incident["severity"];19export type IncidentStatus = Incident["status"];2021/** A one-line consequence or compensation surfaced to the UI. */22export type IncidentNotice = { kind: "synced" | "rejected"; text: string };2324// A tiny author-owned notice channel: effect handlers run outside any25// component, so they publish through module state and hooks subscribe.26let notice: IncidentNotice | null = null;27const noticeListeners = new Set<() => void>();2829function publishNotice(next: IncidentNotice | null): void {30 notice = next;31 for (const listener of [...noticeListeners]) listener();32}3334/**35 * The reporting verb. Its effect units are declared once, here: the36 * consequence runs when the store confirms the row, the compensation runs if37 * a stale-policy write is denied, even if the app restarted in between.38 */39export const reportIncident = s.mutation("reportIncident", s.insert(incidentsTable), {40 effects: [s.log("incident-reported")],41 onSynced: (incident) => {42 publishNotice({43 kind: "synced",44 text: `"${incident.title ?? "Incident"}" confirmed by the store`,45 });46 },47 onRejected: (incident) => {48 // The engine already rolled the denied row back out of local reads; this49 // compensates what the user was told.50 publishNotice({51 kind: "rejected",52 text: `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,53 });54 },55});5657/** Moving an incident between states is a plain verb: same lifecycle. */58export const setIncidentStatus = s.mutation("setIncidentStatus", s.update(incidentsTable));5960/** Subscribes to the latest effect notice; `null` until one is published. */61export function useIncidentNotice(): IncidentNotice | null {62 const [current, setCurrent] = useState<IncidentNotice | null>(notice);63 useEffect(() => {64 const listener = () => setCurrent(notice);65 noticeListeners.add(listener);66 listener();67 return () => void noticeListeners.delete(listener);68 }, []);69 return current;70}7172export function useIncidents() {73 const query = useLiveQuery(() => incidentsTable.orderBy("openedAt", "desc"), []);74 const [lastWrite, setLastWrite] = useState<WriteHandle<Incident> | null>(null);75 const [writeError, setWriteError] = useState<string | null>(null);76 const write = useWrite(lastWrite);7778 const report = useCallback((title: string, severity: Severity) => {79 // The verb returns a WriteHandle: awaiting it means saved on this device;80 // `.synced` and the declared effects follow the store's confirmation.81 const handle = reportIncident({82 title,83 severity,84 status: "burning",85 openedAt: new Date(),86 });87 setLastWrite(handle);88 setWriteError(null);89 handle.saved.then(undefined, (error) => {90 setWriteError(error instanceof Error ? error.message : String(error));91 });92 return handle;93 }, []);94 const setStatus = useCallback(95 (id: string, status: IncidentStatus) => setIncidentStatus(id, { status }),96 [],97 );9899 const durability = write.stage === "synced"100 ? "global" as const101 : write.stage === "saved" || write.stage === "syncing"102 ? "local" as const103 : write.stage === "rejected"104 ? "failed" as const105 : "none" as const;106107 return {108 status: query.status === "error" || writeError !== null ? "error" as const : query.status,109 error: query.error ?? writeError,110 durability,111 lastWriteStage: write.stage,112 lastWriteReason: write.reason,113 incidents: query.rows,114 failureKind: query.status === "error" ? "read" as const : "write" as const,115 report,116 setStatus,117 };118}