1import { schema } from "jazz-tools";2import { assert, assertCount } from "../runtime/test-assert.ts";3import type { WriteHandle } from "../runtime/write-handle.ts";4import { type EffectContext, s } from "./mod.ts";5import { clearEffectDeclarations, type MutationDescriptor, setMutationRuntime } from "./effects.ts";67const app = schema.defineApp({8 orders: schema.table({ item: schema.string(), qty: schema.int() }),9});10type Order = schema.RowOf<typeof app.orders>;1112type Dispatched = { descriptor: MutationDescriptor; args: readonly unknown[] };1314function installRecorder(): { dispatched: Dispatched[]; logs: string[] } {15 const dispatched: Dispatched[] = [];16 const logs: string[] = [];17 setMutationRuntime({18 dispatch(descriptor, args) {19 dispatched.push({ descriptor, args });20 return { stage: "saving" } as unknown as WriteHandle<unknown>;21 },22 recordLog(label) {23 logs.push(label);24 },25 });26 return { dispatched, logs };27}2829Deno.test("verbs carry their declared units and dispatch call-site arguments", () => {30 clearEffectDeclarations();31 const recorder = installRecorder();32 const chargeCard = s.effect("chargeCard", app.orders, {33 onSynced: (order: Partial<Order> & { id: string }) => void order.id,34 });35 const placeOrder = s.mutation("placeOrder", s.insert(app.orders), {36 effects: [chargeCard, s.log("order-placed")],37 expiresAfterMs: 60_000,38 });39 placeOrder({ item: "tea", qty: 2 });40 assertCount(recorder.dispatched.length, 1, "the verb call must reach the dispatcher");41 const { descriptor, args } = recorder.dispatched[0];42 assert(descriptor.verbName === "placeOrder", "the verb name must reach the runtime");43 assert(descriptor.op.kind === "insert", "the operation kind must reach the runtime");44 assert(45 descriptor.units.map((unit) => unit.effectName).join(",") === "chargeCard,log:order-placed",46 "declared units must ride the descriptor in declaration order",47 );48 assert(49 (args[0] as { item: string }).item === "tea",50 "call-site values must be forwarded untouched",51 );52 assert(descriptor.expiresAfterMs === 60_000, "the intent lifespan must ride the descriptor");53 clearEffectDeclarations();54});5556Deno.test("inline handlers are sugar for an implicit unit named after the verb", () => {57 clearEffectDeclarations();58 const recorder = installRecorder();59 const completeTask = s.mutation("completeTask", s.update(app.orders), {60 onSynced: () => undefined,61 });62 completeTask("row-1", { qty: 3 });63 const descriptor = recorder.dispatched[0].descriptor;64 assertCount(descriptor.units.length, 1, "inline handlers must produce one implicit unit");65 assert(66 descriptor.units[0].effectName === "completeTask",67 "the implicit unit must be named after the verb",68 );69 assert(70 s.log("noise") !== descriptor.units[0],71 "the implicit unit is registered like any other named unit",72 );73 clearEffectDeclarations();74});7576Deno.test("effect names are unique per app and collide fast", () => {77 clearEffectDeclarations();78 s.effect("chargeCard", app.orders, {});79 let thrown = false;80 try {81 s.effect("chargeCard", app.orders, {});82 } catch {83 thrown = true;84 }85 assert(thrown, "a duplicate effect name must fail at declaration");86 clearEffectDeclarations();87});8889Deno.test("verb names are unique per app and collide fast", () => {90 clearEffectDeclarations();91 s.mutation("placeOrder", s.insert(app.orders));92 let thrown = false;93 try {94 s.mutation("placeOrder", s.insert(app.orders));95 } catch {96 thrown = true;97 }98 assert(thrown, "a duplicate verb name must fail at declaration");99 clearEffectDeclarations();100});101102Deno.test("s.log reuses one unit per label and records through the runtime", () => {103 clearEffectDeclarations();104 const recorder = installRecorder();105 const first = s.log("order-placed");106 const second = s.log("order-placed");107 assert(first === second, "one label must resolve to one shared unit");108 const context: EffectContext = {109 journalId: "w1:log:order-placed",110 writeId: "w1",111 verb: "placeOrder",112 table: "orders",113 rowId: "row-1",114 fate: "synced",115 cause: null,116 code: null,117 reason: null,118 };119 first.handlers.onSynced?.({ id: "row-1" }, context);120 first.handlers.onRejected?.({ id: "row-1" }, context);121 assertCount(recorder.logs.length, 2, "both fates must record a structured entry");122 clearEffectDeclarations();123});124125Deno.test("verb declarations without an installed runtime fail only when called", () => {126 clearEffectDeclarations();127 setMutationRuntime(null as unknown as Parameters<typeof setMutationRuntime>[0]);128 const orphan = s.mutation("orphanVerb", s.remove(app.orders));129 let thrown = false;130 try {131 orphan("row-1");132 } catch (error) {133 thrown = (error as Error).message.includes("runtime is not installed");134 }135 assert(thrown, "calling a verb before the runtime installs must explain itself");136 clearEffectDeclarations();137});138139Deno.test("verb call shapes follow the declared operation", () => {140 clearEffectDeclarations();141 const recorder = installRecorder();142 const add = s.mutation("addOrder", s.insert(app.orders));143 const edit = s.mutation("editOrder", s.update(app.orders));144 const drop = s.mutation("dropOrder", s.remove(app.orders));145 add({ item: "tea", qty: 1 });146 edit("row-1", { qty: 2 });147 drop("row-1");148 assertCount(recorder.dispatched.length, 3, "each verb call must dispatch once");149 assert(recorder.dispatched[1].args[0] === "row-1", "update must forward the row id first");150 assert(recorder.dispatched[2].args[0] === "row-1", "remove must forward the row id");151 clearEffectDeclarations();152});