Implement replay-safe browser synchronization
This commit is contained in:
parent
06a7ac56b7
commit
459ffa04c6
1 changed files with 2734 additions and 25 deletions
363
web/src/sync/runtime.unit.test.ts
Normal file
363
web/src/sync/runtime.unit.test.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
EventConnection,
|
||||
EventSocket,
|
||||
EventSocketListener,
|
||||
PublicApi,
|
||||
} from '../api/client'
|
||||
import type {
|
||||
DeletionOperationDto,
|
||||
EventTicketDto,
|
||||
JobPageDto,
|
||||
JobResourceDto,
|
||||
} from '../api/contracts'
|
||||
import type { Session, SessionState } from '../auth/session'
|
||||
import { WebClientRuntime, type Scheduler } from './runtime'
|
||||
import { heartbeat, job, jobChanged, observedAt } from './test-fixtures'
|
||||
|
||||
describe('web client runtime', () => {
|
||||
test('loads a snapshot, deduplicates events, and resyncs on a gap', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toEqual(['job_00000001'])
|
||||
})
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2))
|
||||
|
||||
fixture.socket.frame(jobChanged(2, 'event_000001', 2, 20))
|
||||
await flush()
|
||||
expect(fixture.api.jobRequests).toHaveLength(1)
|
||||
|
||||
fixture.socket.frame(heartbeat(4, 'event_000004'))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.listRequests).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
test('clears state and ignores stale work on logout', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
|
||||
fixture.session.emit({ kind: 'signed_out' })
|
||||
|
||||
expect(fixture.runtime.snapshot.status).toBe('signed_out')
|
||||
expect(fixture.runtime.snapshot.jobs).toEqual([])
|
||||
expect(fixture.socket.connections[0]?.closed).toBe(true)
|
||||
})
|
||||
|
||||
test('takes a fresh snapshot when authorization material changes', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
|
||||
fixture.api.snapshot = job(5, { label: 'New authorization' })
|
||||
fixture.session.emit(authenticated('token-two'))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.status).toBe('live')
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(
|
||||
fixture.api.snapshot,
|
||||
)
|
||||
})
|
||||
expect(fixture.api.tokens).toEqual(['token-one', 'token-two'])
|
||||
expect(fixture.socket.connections[0]?.closed).toBe(true)
|
||||
})
|
||||
|
||||
test('does not serialize a new session behind stale event work', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
fixture.api.nextJob = new Promise(() => {})
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toEqual(['job_00000001'])
|
||||
})
|
||||
|
||||
fixture.session.emit({ kind: 'signed_out' })
|
||||
fixture.api.snapshot = job(5)
|
||||
fixture.session.emit(authenticated('token-two'))
|
||||
await live(fixture)
|
||||
fixture.api.nextJob = Promise.resolve(job(6))
|
||||
fixture.socket.frame(jobChanged(1, 'event_000006', 6, 60))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(6))
|
||||
})
|
||||
})
|
||||
|
||||
test('refetches deletion invalidations and reconnects from the latest cursor', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
fixture.socket.frame({
|
||||
schema_version: 1,
|
||||
kind: 'deletion_changed',
|
||||
event_id: 'event_000001',
|
||||
sequence: 1,
|
||||
cursor: 'cursor-event-0001',
|
||||
occurred_at: observedAt,
|
||||
job_id: 'job_00000001',
|
||||
operation_id: 'deletion_001',
|
||||
state: 'running',
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.deletions).toEqual([fixture.api.deletion])
|
||||
})
|
||||
fixture.socket.frame(heartbeat(2, 'event_000002'))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.cursor).toBe('cursor-event-0002')
|
||||
})
|
||||
fixture.socket.disconnect()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.socket.cursors).toEqual([
|
||||
'cursor-snapshot-0001',
|
||||
'cursor-event-0002',
|
||||
])
|
||||
})
|
||||
expect(fixture.api.ticketRequests).toBe(2)
|
||||
})
|
||||
|
||||
test('replays an invalidation whose refetch was interrupted by reconnect', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
const staleRequest = deferred<JobResourceDto | null>()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
fixture.api.nextJob = staleRequest.promise
|
||||
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toEqual(['job_00000001'])
|
||||
})
|
||||
fixture.socket.disconnect()
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.socket.cursors).toEqual([
|
||||
'cursor-snapshot-0001',
|
||||
'cursor-snapshot-0001',
|
||||
])
|
||||
})
|
||||
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toHaveLength(2)
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2))
|
||||
expect(fixture.runtime.snapshot.cursor).toBe('cursor-event-0001')
|
||||
})
|
||||
staleRequest.resolve(job(9))
|
||||
await flush()
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2))
|
||||
})
|
||||
|
||||
test('does not serialize a reconnected socket behind a hung refetch', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
fixture.api.nextJob = new Promise(() => {})
|
||||
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toEqual(['job_00000001'])
|
||||
})
|
||||
fixture.socket.disconnect()
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.socket.connections).toHaveLength(2)
|
||||
})
|
||||
fixture.socket.frame({
|
||||
schema_version: 1,
|
||||
kind: 'resync_required',
|
||||
event_id: 'event_000002',
|
||||
sequence: 1,
|
||||
cursor: 'cursor-event-0002',
|
||||
occurred_at: observedAt,
|
||||
reason: 'history_unavailable',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.listRequests).toBe(2)
|
||||
expect(fixture.runtime.snapshot.status).toBe('live')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function runtimeFixture(): {
|
||||
runtime: WebClientRuntime
|
||||
session: FakeSession
|
||||
api: FakeApi
|
||||
socket: FakeSocket
|
||||
} {
|
||||
const session = new FakeSession()
|
||||
const api = new FakeApi()
|
||||
const socket = new FakeSocket()
|
||||
return {
|
||||
runtime: new WebClientRuntime(
|
||||
session,
|
||||
api,
|
||||
socket,
|
||||
new ImmediateScheduler(),
|
||||
),
|
||||
session,
|
||||
api,
|
||||
socket,
|
||||
}
|
||||
}
|
||||
|
||||
async function live(fixture: ReturnType<typeof runtimeFixture>): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.status).toBe('live')
|
||||
})
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function authenticated(accessToken: string): SessionState {
|
||||
return {
|
||||
kind: 'authenticated',
|
||||
session: { accessToken, identity: 'issuer\u0000subject' },
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSession implements Session {
|
||||
state: SessionState = { kind: 'loading' }
|
||||
readonly #listeners = new Set<(state: SessionState) => void>()
|
||||
|
||||
async start(): Promise<void> {}
|
||||
async signIn(): Promise<void> {}
|
||||
async signOut(): Promise<void> {}
|
||||
|
||||
subscribe(listener: (state: SessionState) => void): () => void {
|
||||
this.#listeners.add(listener)
|
||||
listener(this.state)
|
||||
return () => {
|
||||
this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
emit(state: SessionState): void {
|
||||
this.state = state
|
||||
for (const listener of this.#listeners) listener(state)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeApi implements PublicApi {
|
||||
snapshot = job(1)
|
||||
readonly deletion: DeletionOperationDto = {
|
||||
id: 'deletion_001',
|
||||
job_id: 'job_00000001',
|
||||
state: 'running',
|
||||
problem: null,
|
||||
created_at: observedAt,
|
||||
updated_at: observedAt,
|
||||
}
|
||||
nextJob: Promise<JobResourceDto | null> | null = null
|
||||
listRequests = 0
|
||||
ticketRequests = 0
|
||||
readonly tokens: string[] = []
|
||||
readonly jobRequests: string[] = []
|
||||
|
||||
async listJobs(accessToken: string): Promise<JobPageDto> {
|
||||
this.listRequests += 1
|
||||
this.tokens.push(accessToken)
|
||||
return {
|
||||
items: [this.snapshot],
|
||||
next_cursor: null,
|
||||
event_cursor: 'cursor-snapshot-0001',
|
||||
}
|
||||
}
|
||||
|
||||
async getJob(
|
||||
_accessToken: string,
|
||||
jobId: string,
|
||||
): Promise<JobResourceDto | null> {
|
||||
this.jobRequests.push(jobId)
|
||||
if (this.nextJob !== null) {
|
||||
const response = this.nextJob
|
||||
this.nextJob = null
|
||||
return response
|
||||
}
|
||||
this.snapshot = job(2)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
async getDeletion(
|
||||
_accessToken: string,
|
||||
_operationId: string,
|
||||
): Promise<DeletionOperationDto | null> {
|
||||
return this.deletion
|
||||
}
|
||||
|
||||
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
|
||||
this.ticketRequests += 1
|
||||
return {
|
||||
ticket: 'ticket_00001',
|
||||
expires_at: '2026-07-17T00:01:00Z',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeConnection extends EventConnection {
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
class FakeSocket implements EventSocket {
|
||||
readonly connections: FakeConnection[] = []
|
||||
readonly cursors: string[] = []
|
||||
#listener: EventSocketListener | null = null
|
||||
|
||||
connect(
|
||||
_ticket: string,
|
||||
cursor: string,
|
||||
listener: EventSocketListener,
|
||||
): EventConnection {
|
||||
this.#listener = listener
|
||||
const connection: FakeConnection = {
|
||||
closed: false,
|
||||
close(): void {
|
||||
connection.closed = true
|
||||
},
|
||||
}
|
||||
this.connections.push(connection)
|
||||
this.cursors.push(cursor)
|
||||
listener.opened()
|
||||
return connection
|
||||
}
|
||||
|
||||
frame(frame: Parameters<EventSocketListener['frame']>[0]): void {
|
||||
this.#listener?.frame(frame)
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.#listener?.closed()
|
||||
}
|
||||
}
|
||||
|
||||
class ImmediateScheduler implements Scheduler {
|
||||
schedule(callback: () => void): () => void {
|
||||
queueMicrotask(callback)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue