clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/184-task-notice-migration
lofi / package / schema / effect_library_test.ts
effect_library_test.ts 13.1K
1import { schema } from "jazz-tools";2import { assert, assertCount } from "../runtime/test-assert.ts";3import type { WriteHandle } from "../runtime/write-handle.ts";4import { chain, mark, notice, trace, webhook } from "./effect-library.ts";5import {6 clearEffectDeclarations,7 effect,8 type EffectContext,9 insert,10 mutation,11 type MutationRuntime,12 type NoticeInput,13 resolveEffectUnit,14 setMutationRuntime,15} from "./effects.ts";1617const app = schema.defineApp({18 claims: schema.table({ title: schema.string(), status: schema.string() }),19});20type Claim = schema.RowOf<typeof app.claims>;2122type Recorder = {23 traces: Array<{ label: string | null; context: EffectContext }>;24 notices: Array<{ input: NoticeInput; context: EffectContext }>;25 marks: Array<{ rowId: string; patch: Record<string, unknown> }>;26 dispatched: Array<{27 descriptor: unknown;28 args: readonly unknown[];29 parentJournalId?: string;30 }>;31};3233function install(): Recorder {34 const recorder: Recorder = { traces: [], notices: [], marks: [], dispatched: [] };35 const runtime: MutationRuntime = {36 dispatch(descriptor, args) {37 recorder.dispatched.push({ descriptor, args });38 return { saved: Promise.resolve() } as unknown as WriteHandle<unknown>;39 },40 dispatchChained(descriptor, args, parentJournalId) {41 recorder.dispatched.push({ descriptor, args, parentJournalId });42 return Promise.resolve();43 },44 recordLog() {},45 recordTrace(label, context) {46 recorder.traces.push({ label, context });47 },48 enqueueNotice(input, context) {49 recorder.notices.push({ input, context });50 return Promise.resolve();51 },52 applyMark(_table, rowId, patch) {53 recorder.marks.push({ rowId, patch });54 return Promise.resolve();55 },56 };57 setMutationRuntime(runtime);58 return recorder;59}6061function context(overrides: Partial<EffectContext> = {}): EffectContext {62 return {63 journalId: "w1:unit#1",64 writeId: "w1",65 verb: "submit",66 table: "claims",67 op: "update",68 rowId: "row-1",69 writeCreatedAt: 500,70 fate: "synced",71 cause: null,72 code: null,73 reason: null,74 ...overrides,75 };76}7778// The identity guarantee: an anonymous built-in takes its durable name from79// the verb it is attached to and its position in that verb's effects, NOT from80// a global declaration counter. This is what makes a journaled notice/mark/81// chain obligation re-arm against the same logical unit regardless of which82// module evaluated first on the re-arming boot.83Deno.test("anonymous units are named <verb>#<index>, stable and verb-scoped", () => {84 clearEffectDeclarations();85 install();86 const orders = schema.defineApp({ orders: schema.table({ item: schema.string() }) }).orders;87 mutation("placeOrder", insert(orders), {88 effects: [notice({ synced: "Placed." }), mark(orders, { synced: { item: "x" } })],89 });90 mutation("reorder", insert(orders), {91 effects: [notice({ synced: "Reordered." })],92 });93 // Position within the verb, not a global counter: reorder's notice is #0,94 // not #2, so adding placeOrder above it cannot shift reorder's identity.95 assert(resolveEffectUnit("placeOrder#0") !== null, "the first unit is <verb>#0");96 assert(resolveEffectUnit("placeOrder#1") !== null, "the second unit is <verb>#1");97 assert(resolveEffectUnit("reorder#0") !== null, "a second verb restarts the index at 0");98 assert(resolveEffectUnit("reorder#1") === null, "reorder declares only one anonymous unit");99 clearEffectDeclarations();100});101102Deno.test("trace records a span with the saved-to-fate latency on either fate", () => {103 clearEffectDeclarations();104 const recorder = install();105 const unit = trace("checkout");106 unit.handlers.onSynced?.({ id: "row-1" }, context({ writeCreatedAt: 500 }));107 unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));108 assertCount(recorder.traces.length, 2, "both fates must record a span");109 assert(recorder.traces[0].label === "checkout", "the span must carry the author label");110 clearEffectDeclarations();111});112113// The content-named built-ins share one unit per identity instead of throwing114// a duplicate-name error when reused across verbs.115Deno.test("trace and webhook share one unit per durable identity", () => {116 clearEffectDeclarations();117 install();118 assert(trace("checkout") === trace("checkout"), "one label shares one trace unit");119 assert(trace() === trace(), "the unlabeled trace shares one unit");120 const url = "https://hooks.example.com/x";121 assert(webhook("orders", url) === webhook("orders", url), "one name+config shares one unit");122 let mismatched = false;123 try {124 webhook("orders", url, { maxAttempts: 9 });125 } catch (error) {126 mismatched = !(error as Error).message.includes(url);127 }128 assert(mismatched, "reusing a name with different config must fail without leaking config");129 assert(130 webhook("orders-v2", url, { maxAttempts: 9 }) !== webhook("orders", url),131 "a distinct author name creates a distinct durable unit",132 );133 clearEffectDeclarations();134});135136Deno.test("notice enqueues a message keyed by the obligation journal id", async () => {137 clearEffectDeclarations();138 const recorder = install();139 const unit = notice<Claim>({140 synced: "Submitted.",141 rejected: (claim) => `Could not submit "${claim.title ?? "claim"}".`,142 });143 await unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:notice#1" }));144 await unit.handlers.onRejected?.(145 { id: "row-1", title: "roof" } as Claim & { id: string },146 context({ fate: "rejected", journalId: "w2:notice#1" }),147 );148 assertCount(recorder.notices.length, 2, "each fate with a message enqueues one notice");149 assert(150 recorder.notices[0].input.message === "Submitted.",151 "the static message must pass through",152 );153 assert(recorder.notices[0].input.tone === "success", "a synced notice is a success tone");154 assert(155 recorder.notices[1].input.message === 'Could not submit "roof".',156 "the resolver must see the settled row",157 );158 assert(recorder.notices[1].input.tone === "error", "a rejected notice is an error tone");159 clearEffectDeclarations();160});161162Deno.test("notice fires only the configured fate", async () => {163 clearEffectDeclarations();164 const recorder = install();165 const unit = notice({ rejected: "Failed." });166 await unit.handlers.onSynced?.({ id: "row-1" }, context());167 assertCount(recorder.notices.length, 0, "an unconfigured synced fate enqueues nothing");168 await unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));169 assertCount(recorder.notices.length, 1, "the configured rejected fate enqueues");170 clearEffectDeclarations();171});172173Deno.test("mark patches the row on synced and on a rejected update", async () => {174 clearEffectDeclarations();175 const recorder = install();176 const unit = mark(app.claims, {177 synced: { status: "confirmed" },178 rejected: { status: "failed" },179 });180 await unit.handlers.onSynced?.({ id: "row-1" }, context({ op: "update" }));181 await unit.handlers.onRejected?.({ id: "row-1" }, context({ op: "update", fate: "rejected" }));182 assertCount(recorder.marks.length, 2, "both fates patch the surviving row");183 assert(recorder.marks[0].patch.status === "confirmed", "synced must apply its patch");184 assert(recorder.marks[1].patch.status === "failed", "a rejected update must apply its patch");185 clearEffectDeclarations();186});187188Deno.test("mark skips a rejected insert, which left no row to patch", async () => {189 clearEffectDeclarations();190 const recorder = install();191 const unit = mark(app.claims, { rejected: { status: "failed" } });192 await unit.handlers.onRejected?.({ id: "row-1" }, context({ op: "insert", fate: "rejected" }));193 assertCount(recorder.marks.length, 0, "a rolled-back insert has no row, so mark must skip it");194 clearEffectDeclarations();195});196197Deno.test("chain issues the follow-up verb only on synced", async () => {198 clearEffectDeclarations();199 const recorder = install();200 const charge = mutation("chargeClaim", insert(app.claims));201 const unit = chain(charge, (row: { id: string }) => ({202 title: row.id,203 status: "charging",204 }));205 await unit.handlers.onRejected?.({ id: "row-1" }, context({ fate: "rejected" }));206 assertCount(recorder.dispatched.length, 0, "a rejected write starts no chain");207 await unit.handlers.onSynced?.({ id: "row-1" }, context());208 assertCount(recorder.dispatched.length, 1, "a synced write issues the follow-up verb once");209 assert(210 (recorder.dispatched[0].args[0] as { title: string }).title === "row-1",211 "the mapper must receive the settled row",212 );213 assert(214 recorder.dispatched[0].parentJournalId === "w1:unit#1",215 "the child must be tied to its parent obligation identity",216 );217 clearEffectDeclarations();218});219220Deno.test("webhook injects the journal id as the idempotency key and posts the fate", async () => {221 clearEffectDeclarations();222 install();223 const requests: Array<{ url: string; init: RequestInit }> = [];224 const originalFetch = globalThis.fetch;225 globalThis.fetch = ((url: string | URL, init?: RequestInit) => {226 requests.push({ url: String(url), init: init ?? {} });227 return Promise.resolve(new Response(null, { status: 200 }));228 }) as typeof fetch;229 try {230 const unit = webhook("claims", "https://hooks.example.com/claims", {231 headers: { authorization: "secret", "idempotency-key": "override" },232 });233 assert(unit.effectName === "webhook:claims", "durable identity must contain only the name");234 assert(!unit.effectName.includes("secret"), "header secrets must not enter durable identity");235 await unit.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:webhook#1" }));236 assertCount(requests.length, 1, "a synced write posts once");237 const key = new Headers(requests[0].init.headers as HeadersInit).get("idempotency-key") ?? "";238 assert(key === "w1:webhook#1", "the journal id must ride as the idempotency key");239 const body = JSON.parse(String(requests[0].init.body));240 assert(body.fate === "synced", "the payload must carry the settled fate");241 } finally {242 globalThis.fetch = originalFetch;243 }244 clearEffectDeclarations();245});246247Deno.test("webhook treats a 4xx as permanent and a 5xx as retryable", async () => {248 clearEffectDeclarations();249 install();250 const originalFetch = globalThis.fetch;251 const respond = (status: number) => {252 globalThis.fetch = (() => Promise.resolve(new Response(null, { status }))) as typeof fetch;253 };254 try {255 const unit = webhook("claims", "https://hooks.example.com/claims");256 respond(400);257 let permanent = false;258 try {259 await unit.handlers.onSynced?.({ id: "row-1" }, context());260 } catch (error) {261 permanent = (error as Error).name === "PermanentEffectError";262 }263 assert(permanent, "a 4xx must throw a PermanentEffectError so the ledger retires it");264265 respond(503);266 let retryable = false;267 try {268 await unit.handlers.onSynced?.({ id: "row-1" }, context());269 } catch (error) {270 retryable = (error as Error).name !== "PermanentEffectError";271 }272 assert(retryable, "a 5xx must throw an ordinary error so the ledger retries it");273 } finally {274 globalThis.fetch = originalFetch;275 }276 clearEffectDeclarations();277});278279// The contract's proof: a custom unit written with nothing but the public280// authoring surface — s.effect, the context's journal id as the idempotency281// key, and PermanentEffectError for a hopeless failure — behaves like a282// built-in. This is also the authoring guide's worked example.283Deno.test("a custom unit on the public contract is idempotent by journal id", async () => {284 clearEffectDeclarations();285 install();286 const seen = new Set<string>();287 let sends = 0;288 const sendOnce = effect("sendReceipt", app.claims, {289 onSynced: (_row, context) => {290 // The at-least-once duty: dedupe on the journal id so a re-delivered291 // handler produces one external effect.292 if (seen.has(context.journalId)) return;293 seen.add(context.journalId);294 sends += 1;295 },296 });297 await sendOnce.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:sendReceipt" }));298 await sendOnce.handlers.onSynced?.({ id: "row-1" }, context({ journalId: "w1:sendReceipt" }));299 assertCount(sends, 1, "a re-delivery under one journal id must produce one effect");300 clearEffectDeclarations();301});302303Deno.test("webhook defaults to a finite 24-hour delivery window", () => {304 clearEffectDeclarations();305 install();306 const unit = webhook("claims", "https://hooks.example.com/claims");307 assert(unit.expiresAfterMs === 24 * 60 * 60 * 1000, "external delivery must default to 24h");308 const forever = webhook("claims-forever", "https://hooks.example.com/claims2", {309 expiresAfterMs: null,310 });311 assert(312 forever.expiresAfterMs === null || forever.expiresAfterMs === undefined,313 "null must opt into no expiry",314 );315 clearEffectDeclarations();316});317318Deno.test("webhook default expiry and explicit no-expiry do not alias", () => {319 clearEffectDeclarations();320 install();321 const url = "https://hooks.example.com/expiry";322 webhook("expiry", url);323 let thrown = false;324 try {325 webhook("expiry", url, { expiresAfterMs: null });326 } catch {327 thrown = true;328 }329 assert(thrown, "explicit null must not reuse the default 24-hour configuration");330 clearEffectDeclarations();331});