clone

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

feat/141-demo-review-fixes
lofi / package / schema / effects_test.ts
effects_test.ts 6.0K
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 dispatchChained() {23 return Promise.resolve();24 },25 recordLog(label) {26 logs.push(label);27 },28 });29 return { dispatched, logs };30}3132Deno.test("verbs carry their declared units and dispatch call-site arguments", () => {33 clearEffectDeclarations();34 const recorder = installRecorder();35 const chargeCard = s.effect("chargeCard", app.orders, {36 onSynced: (order: Partial<Order> & { id: string }) => void order.id,37 });38 const placeOrder = s.mutation("placeOrder", s.insert(app.orders), {39 effects: [chargeCard, s.log("order-placed")],40 expiresAfterMs: 60_000,41 });42 placeOrder({ item: "tea", qty: 2 });43 assertCount(recorder.dispatched.length, 1, "the verb call must reach the dispatcher");44 const { descriptor, args } = recorder.dispatched[0];45 assert(descriptor.verbName === "placeOrder", "the verb name must reach the runtime");46 assert(descriptor.op.kind === "insert", "the operation kind must reach the runtime");47 assert(48 descriptor.units.map((unit) => unit.effectName).join(",") === "chargeCard,log:order-placed",49 "declared units must ride the descriptor in declaration order",50 );51 assert(52 (args[0] as { item: string }).item === "tea",53 "call-site values must be forwarded untouched",54 );55 assert(descriptor.expiresAfterMs === 60_000, "the intent lifespan must ride the descriptor");56 clearEffectDeclarations();57});5859Deno.test("inline handlers are sugar for an implicit unit named after the verb", () => {60 clearEffectDeclarations();61 const recorder = installRecorder();62 const completeTask = s.mutation("completeTask", s.update(app.orders), {63 onSynced: () => undefined,64 });65 completeTask("row-1", { qty: 3 });66 const descriptor = recorder.dispatched[0].descriptor;67 assertCount(descriptor.units.length, 1, "inline handlers must produce one implicit unit");68 assert(69 descriptor.units[0].effectName === "completeTask",70 "the implicit unit must be named after the verb",71 );72 assert(73 s.log("noise") !== descriptor.units[0],74 "the implicit unit is registered like any other named unit",75 );76 clearEffectDeclarations();77});7879Deno.test("effect names are unique per app and collide fast", () => {80 clearEffectDeclarations();81 s.effect("chargeCard", app.orders, {});82 let thrown = false;83 try {84 s.effect("chargeCard", app.orders, {});85 } catch {86 thrown = true;87 }88 assert(thrown, "a duplicate effect name must fail at declaration");89 clearEffectDeclarations();90});9192Deno.test("verb names are unique per app and collide fast", () => {93 clearEffectDeclarations();94 s.mutation("placeOrder", s.insert(app.orders));95 let thrown = false;96 try {97 s.mutation("placeOrder", s.insert(app.orders));98 } catch {99 thrown = true;100 }101 assert(thrown, "a duplicate verb name must fail at declaration");102 clearEffectDeclarations();103});104105Deno.test("one anonymous unit object cannot occupy two mutation positions", () => {106 clearEffectDeclarations();107 const unit = s.notice({ synced: "Saved." });108 let thrown = false;109 try {110 s.mutation("duplicateNotice", s.insert(app.orders), { effects: [unit, unit] });111 } catch {112 thrown = true;113 }114 assert(thrown, "reusing one anonymous declaration must fail before it gains two identities");115 clearEffectDeclarations();116});117118Deno.test("s.log reuses one unit per label and records through the runtime", () => {119 clearEffectDeclarations();120 const recorder = installRecorder();121 const first = s.log("order-placed");122 const second = s.log("order-placed");123 assert(first === second, "one label must resolve to one shared unit");124 const context: EffectContext = {125 journalId: "w1:log:order-placed",126 writeId: "w1",127 verb: "placeOrder",128 table: "orders",129 op: "insert",130 rowId: "row-1",131 writeCreatedAt: 0,132 fate: "synced",133 cause: null,134 code: null,135 reason: null,136 };137 first.handlers.onSynced?.({ id: "row-1" }, context);138 first.handlers.onRejected?.({ id: "row-1" }, context);139 assertCount(recorder.logs.length, 2, "both fates must record a structured entry");140 clearEffectDeclarations();141});142143Deno.test("verb declarations without an installed runtime fail only when called", () => {144 clearEffectDeclarations();145 setMutationRuntime(null as unknown as Parameters<typeof setMutationRuntime>[0]);146 const orphan = s.mutation("orphanVerb", s.remove(app.orders));147 let thrown = false;148 try {149 orphan("row-1");150 } catch (error) {151 thrown = (error as Error).message.includes("runtime is not installed");152 }153 assert(thrown, "calling a verb before the runtime installs must explain itself");154 clearEffectDeclarations();155});156157Deno.test("verb call shapes follow the declared operation", () => {158 clearEffectDeclarations();159 const recorder = installRecorder();160 const add = s.mutation("addOrder", s.insert(app.orders));161 const edit = s.mutation("editOrder", s.update(app.orders));162 const drop = s.mutation("dropOrder", s.remove(app.orders));163 add({ item: "tea", qty: 1 });164 edit("row-1", { qty: 2 });165 drop("row-1");166 assertCount(recorder.dispatched.length, 3, "each verb call must dispatch once");167 assert(recorder.dispatched[1].args[0] === "row-1", "update must forward the row id first");168 assert(recorder.dispatched[2].args[0] === "row-1", "remove must forward the row id");169 clearEffectDeclarations();170});