// @vitest-environment jsdom // // mutation lifecycle across combineZeroClients, with two real Zero instances // mounted as soot mounts them: control OUTER, project INNER. binding the // acknowledgement helpers to ONE instance made that instance's recovery cancel // every other instance's in-flight and queued mutations (soot incident: a // control remint threw StaleGenerationError out of finishTurn and stalled // scheduling for 8 minutes). acknowledgement has to follow the instance that // issued the mutation. import { createSchema, string, table } from '@rocicorp/zero' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, test } from 'vitest' import { createZeroClient } from './createZeroClient' import { isStaleGenerationError } from './helpers/mutationLifecycle' import { onMutationError } from './helpers/useMutation' import { combineZeroClients, createZeroClientWithDirectQueries } from './multi' import type { MutatorContext } from './types' import type { ReactNode } from 'react' declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined } globalThis.IS_REACT_ACT_ENVIRONMENT = true const userTable = table('user').columns({ id: string(), name: string() }).primaryKey('id') const taskTable = table('task') .columns({ id: string(), title: string() }) .primaryKey('id') const schema = createSchema({ tables: [userTable, taskTable] }) // every mutator body appends here as it runs on the optimistic client pass, so // a test can tell "the write ran" from "the write was cancelled" const writes: string[] = [] const control = createZeroClientWithDirectQueries({ schema, models: { mutCtlUser: { mutate: { seed: async (ctx: MutatorContext, row?: { id: string; name: string }) => { writes.push(`control:${row!.id}`) await (ctx.tx.mutate as any).user.upsert(row) }, }, }, }, groupedQueries: {}, instanceName: 'mutations-control', }) const project = createZeroClient({ schema, models: { mutPrjTask: { mutate: { seed: async (ctx: MutatorContext, row?: { id: string; title: string }) => { writes.push(`project:${row!.id}`) await (ctx.tx.mutate as any).task.upsert(row) }, }, }, }, groupedQueries: {}, instanceName: 'mutations-project', }) const combined = combineZeroClients(control, project) const controlWrite = (id: string) => (combined.zero.mutate as any).mutCtlUser.seed({ id, name: id }) const projectWrite = (id: string) => (combined.zero.mutate as any).mutPrjTask.seed({ id, title: id }) let root: Root let container: HTMLElement let stopErrorSink: () => void beforeEach(() => { writes.length = 0 // closing an instance rejects its in-flight server promise; the app-level // sink is what would toast it, and these tests assert on the lifecycle stopErrorSink = onMutationError(() => {}) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) }) afterEach(async () => { await act(async () => root.unmount()) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) }) container.remove() stopErrorSink() }) const render = (ui: ReactNode) => act(async () => root.render(ui)) const tick = () => act(async () => new Promise((resolve) => setTimeout(resolve, 0))) const App = ({ controlUser, projectUser, }: { controlUser: string projectUser: string }) => (
) async function mount(controlUser: string, projectUser: string) { await render() await act(async () => { await Promise.all([control.waitForZero(), project.waitForZero()]) }) } function deferred() { let resolve!: (value: T | PromiseLike) => void const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) return { promise, resolve } } // settlement state without awaiting: a pending acknowledgement is the point function track(promise: Promise) { const state: { status: 'pending' | 'resolved' | 'rejected'; error?: unknown } = { status: 'pending', } promise.then( () => { state.status = 'resolved' }, (error: unknown) => { state.status = 'rejected' state.error = error } ) return state } test('recovering one instance cancels only that instance mutation acknowledgement', async () => { await mount('ctl-1', 'prj-1') // no server is configured, so both server acknowledgements stay in flight const controlAck = track( combined.awaitMutationServer(controlWrite('c1'), 'control write', 2000) ) const projectAck = track( combined.awaitMutationServer(projectWrite('p1'), 'project write', 2000) ) await tick() expect(controlAck.status).toBe('pending') expect(projectAck.status).toBe('pending') // control rotates its instance (recovery / remint / identity change) await mount('ctl-2', 'prj-1') await tick() expect(controlAck.status).toBe('rejected') expect(isStaleGenerationError(controlAck.error)).toBe(true) expect(projectAck.status).toBe('pending') // and the project acknowledgement is still fenced by its OWN instance await mount('ctl-2', 'prj-2') await tick() expect(projectAck.status).toBe('rejected') expect(isStaleGenerationError(projectAck.error)).toBe(true) }) test('a mutation issued before a rotation never settles on the replacement', async () => { await mount('ctl-3', 'prj-3') const mutation = controlWrite('c2') await mount('ctl-4', 'prj-3') // the client that could settle this call is gone; waiting out the timeout on // the replacement instance is the failure this replaces await expect( combined.awaitMutationClient(mutation, 'control write', 2000) ).rejects.toSatisfy(isStaleGenerationError) }) test('a per-instance helper hands a foreign mutation to its owner', async () => { await mount('ctl-13', 'prj-13') // the wrong client to settle a project write: it must pass it to project // rather than fence it on its own generation const ack = track( control.awaitMutationServer(projectWrite('p6'), 'project write', 5000) ) await tick() await mount('ctl-14', 'prj-13') await tick() expect(ack.status).toBe('pending') await mount('ctl-14', 'prj-14') await tick() expect(ack.status).toBe('rejected') expect(isStaleGenerationError(ack.error)).toBe(true) }) test('a create that awaits before writing is not pinned to its queued generation', async () => { await mount('ctl-11', 'prj-11') const release = deferred() const queued = combined.enqueueBackgroundMutation( 'control read then write', async () => { await release.promise return controlWrite('c7') } ) // documented boundary: the pin covers the synchronous window in which // create() calls zero.mutate, so a create that awaits first writes on // whichever instance is live when it fires and is acknowledged there await mount('ctl-12', 'prj-11') release.resolve() await act(async () => { await queued }) expect(writes).toEqual(['control:c7']) }) test('a fenced instance drops only its own queued background writes', async () => { await mount('ctl-5', 'prj-5') const blocker = deferred() const blocking = combined.enqueueBackgroundMutation('blocker', () => blocker.promise) const queuedControl = combined.enqueueBackgroundMutation('control queued', () => controlWrite('c3') ) const queuedProject = combined.enqueueBackgroundMutation('project queued', () => projectWrite('p3') ) // control recovers while both writes are still behind the blocker await mount('ctl-6', 'prj-5') blocker.resolve() await act(async () => { await Promise.all([blocking, queuedControl, queuedProject]) }) // the control write is dropped rather than replayed onto the fresh control // client, and the project write is untouched by control's recovery expect(writes).toEqual(['project:p3']) await expect(queuedControl).resolves.toBeUndefined() await expect(queuedProject).resolves.toBeUndefined() }) test('queued writes survive a rotation of an instance they do not touch', async () => { await mount('ctl-7', 'prj-7') const blocker = deferred() const blocking = combined.enqueueBackgroundMutation('blocker', () => blocker.promise) const queuedProject = combined.enqueueBackgroundMutation( 'project queued', () => projectWrite('p4'), { settle: 'client' } ) await mount('ctl-8', 'prj-7') blocker.resolve() await act(async () => { await Promise.all([blocking, queuedProject]) }) expect(writes).toEqual(['project:p4']) }) test('the combined queue stays one serial queue across instances', async () => { await mount('ctl-9', 'prj-9') const blocker = deferred() const blocking = combined.enqueueBackgroundMutation('blocker', () => blocker.promise) // same coalesce key from two different instances: one queue means the older // write is superseded, two queues would run both const superseded = combined.enqueueBackgroundMutation( 'control coalesced', () => controlWrite('c5'), { coalesceKey: 'row-1' } ) const latest = combined.enqueueBackgroundMutation( 'project coalesced', () => projectWrite('p5'), { coalesceKey: 'row-1' } ) const trailing = combined.enqueueBackgroundMutation('control trailing', () => controlWrite('c6') ) blocker.resolve() await act(async () => { await Promise.all([blocking, superseded, latest, trailing]) }) expect(writes).toEqual(['project:p5', 'control:c6']) })