Implement replay-safe browser synchronization

This commit is contained in:
ms2k 2026-07-18 00:16:21 +09:00
commit 459ffa04c6
16 changed files with 2734 additions and 25 deletions

View file

@ -23,6 +23,7 @@
"test:e2e:full": "playwright test"
},
"dependencies": {
"oidc-client-ts": "3.5.0",
"svelte": "5.56.5"
},
"devDependencies": {

23
web/pnpm-lock.yaml generated
View file

@ -7,6 +7,9 @@ settings:
importers:
.:
dependencies:
oidc-client-ts:
specifier: 3.5.0
version: 3.5.0
svelte:
specifier: 5.56.5
version: 5.56.5(@typescript-eslint/[email protected])
@ -1592,6 +1595,13 @@ packages:
}
engines: { node: '>=0.10.0' }
[email protected]:
resolution:
{
integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==,
}
engines: { node: '>=18' }
[email protected]:
resolution:
{
@ -1924,6 +1934,13 @@ packages:
}
engines: { node: '>=12.20.0' }
[email protected]:
resolution:
{
integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==,
}
engines: { node: '>=18' }
[email protected]:
resolution:
{
@ -3514,6 +3531,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@ -3670,6 +3689,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
jwt-decode: 4.0.0
[email protected]:
dependencies:
'@types/json-schema': 7.0.15

View file

@ -1,12 +1,182 @@
import { expect, test } from 'vitest'
import { expect, test, vi } from 'vitest'
import { render } from 'vitest-browser-svelte'
import type {
EventConnection,
EventSocket,
EventSocketListener,
PublicApi,
} from './api/client'
import type {
DeletionOperationDto,
EventFrameDto,
EventTicketDto,
JobPageDto,
JobResourceDto,
} from './api/contracts'
import App from './App.svelte'
import type { Session, SessionState } from './auth/session'
import { WebClientRuntime, type Scheduler } from './sync/runtime'
import { heartbeat, job, jobChanged } from './sync/test-fixtures'
test('renders the application shell', async () => {
const screen = await render(App)
test('renders deterministic synchronization across replay and gaps', async () => {
const fixture = runtimeFixture()
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await expect
.element(screen.getByRole('heading', { name: 'Remora' }))
.toBeVisible()
await expect.element(screen.getByText('live', { exact: true })).toBeVisible()
await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
fixture.api.refetched = job(2, { label: 'Invalidation refetch' })
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
await expect.element(screen.getByText('Invalidation refetch')).toBeVisible()
fixture.socket.frame(jobChanged(2, 'event_000001', 2, 20))
await vi.waitFor(() => {
expect(fixture.api.jobRequests).toBe(1)
})
fixture.api.snapshot = job(3, { label: 'Gap resnapshot' })
fixture.socket.frame(heartbeat(4, 'event_000004'))
await expect.element(screen.getByText('Gap resnapshot')).toBeVisible()
expect(fixture.api.listRequests).toBe(2)
expect(fixture.api.jobRequests).toBe(1)
})
test('clears rendered authority on logout and reloads it on token change', async () => {
const fixture = runtimeFixture()
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
fixture.session.emit({ kind: 'signed_out' })
await expect
.element(screen.getByText('Sign in to view authorized jobs.'))
.toBeVisible()
await expect
.element(screen.getByRole('button', { name: 'Sign in' }))
.toBeVisible()
fixture.api.snapshot = job(4, { label: 'Changed authorization' })
fixture.session.emit(authenticated('token-two'))
await expect.element(screen.getByText('Changed authorization')).toBeVisible()
expect(fixture.api.tokens).toEqual(['token-one', 'token-two'])
})
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,
}
}
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> {
this.emit({ kind: 'signed_out' })
}
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, { label: 'Initial snapshot' })
refetched = job(2)
listRequests = 0
jobRequests = 0
readonly tokens: 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 += 1
return this.refetched
}
async getDeletion(
_accessToken: string,
_operationId: string,
): Promise<DeletionOperationDto | null> {
return null
}
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
return {
ticket: 'ticket_00001',
expires_at: '2026-07-17T00:01:00Z',
}
}
}
class FakeSocket implements EventSocket {
#listener: EventSocketListener | null = null
connect(
_ticket: string,
_cursor: string,
listener: EventSocketListener,
): EventConnection {
this.#listener = listener
listener.opened()
return { close(): void {} }
}
frame(frame: EventFrameDto): void {
this.#listener?.frame(frame)
}
}
class ImmediateScheduler implements Scheduler {
schedule(callback: () => void): () => void {
queueMicrotask(callback)
return () => {}
}
}

View file

@ -1,5 +1,23 @@
<script lang="ts">
import { applicationName } from './lib/application'
import { createBrowserRuntime } from './runtime'
import type { WebClientRuntime } from './sync/runtime'
import type { SynchronizationSnapshot } from './sync/state'
let { runtime = createBrowserRuntime() }: { runtime?: WebClientRuntime } =
$props()
let snapshot = $state<SynchronizationSnapshot>()
$effect(() => {
const unsubscribe = runtime.subscribe((next) => {
snapshot = next
})
void runtime.start()
return () => {
unsubscribe()
runtime.stop()
}
})
</script>
<svelte:head>
@ -7,8 +25,59 @@
</svelte:head>
<main>
<p>Durable transfer service</p>
<h1>{applicationName}</h1>
<header>
<div>
<p>Durable transfer service</p>
<h1>{applicationName}</h1>
</div>
{#if snapshot !== undefined}
{#if snapshot.status === 'signed_out'}
<button type="button" onclick={() => runtime.signIn()}>Sign in</button>
{:else if snapshot.status !== 'loading'}
<button type="button" onclick={() => runtime.signOut()}>Sign out</button
>
{/if}
{/if}
</header>
{#if snapshot !== undefined}
<section aria-labelledby="synchronization-heading">
<div class="section-heading">
<h2 id="synchronization-heading">Synchronization</h2>
<output data-status={snapshot.status}>{snapshot.status}</output>
</div>
{#if snapshot.message !== null}
<p role="alert">{snapshot.message}</p>
{/if}
{#if snapshot.status === 'signed_out'}
<p>Sign in to view authorized jobs.</p>
{:else if snapshot.jobs.length === 0}
<p>No authorized jobs in the current snapshot.</p>
{:else}
<ul aria-label="Authorized jobs">
{#each snapshot.jobs as cached (cached.resource.id)}
<li>
<strong>
{cached.resource.resource_kind === 'job'
? (cached.resource.label ?? cached.resource.id)
: cached.resource.id}
</strong>
<span>
{cached.resource.resource_kind === 'job'
? cached.resource.phase
: 'Deleted'}
</span>
{#if cached.provisionalProgress !== null}
<span>
{cached.provisionalProgress.completed_bytes} bytes observed
</span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</main>
<style>
@ -32,11 +101,17 @@
}
main {
display: grid;
min-height: 100vh;
place-content: center;
padding: 2rem;
text-align: center;
padding: clamp(1.5rem, 5vw, 4rem);
}
header,
.section-heading,
li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
p {
@ -49,7 +124,50 @@
h1 {
margin: 0.5rem 0 0;
font-size: clamp(3rem, 12vw, 7rem);
font-size: clamp(2.5rem, 8vw, 5rem);
letter-spacing: -0.06em;
}
section {
max-width: 60rem;
margin-top: 4rem;
padding: 1.5rem;
border: 1px solid #d9dee8;
border-radius: 1rem;
background: #fff;
}
h2 {
margin: 0;
}
output {
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: #e9f1fb;
font-size: 0.875rem;
}
button {
padding: 0.7rem 1rem;
border: 0;
border-radius: 0.5rem;
color: white;
background: #14213d;
cursor: pointer;
}
ul {
display: grid;
gap: 0.75rem;
margin: 1.5rem 0 0;
padding: 0;
list-style: none;
}
li {
padding: 0.85rem;
border-radius: 0.5rem;
background: #f8f7f2;
}
</style>

180
web/src/api/client.ts Normal file
View file

@ -0,0 +1,180 @@
import {
decodeDeletionOperation,
decodeEventFrame,
decodeEventTicket,
decodeJobPage,
decodeJobResource,
} from './decode'
import type {
DeletionOperationDto,
EventFrameDto,
EventTicketDto,
JobPageDto,
JobResourceDto,
} from './contracts'
export interface PublicApi {
listJobs(accessToken: string, cursor?: string): Promise<JobPageDto>
getJob(accessToken: string, jobId: string): Promise<JobResourceDto | null>
getDeletion(
accessToken: string,
operationId: string,
): Promise<DeletionOperationDto | null>
createEventTicket(accessToken: string): Promise<EventTicketDto>
}
export class ApiResponseError extends Error {
constructor(readonly status: number) {
super(`Remora API returned HTTP ${status}`)
}
}
export class FetchPublicApi implements PublicApi {
readonly #baseUrl: URL
readonly #fetch: typeof fetch
constructor(
baseUrl: string | URL,
fetchImplementation: typeof fetch = fetch,
) {
this.#baseUrl = new URL(baseUrl, 'http://localhost/')
this.#fetch = fetchImplementation
}
async listJobs(accessToken: string, cursor?: string): Promise<JobPageDto> {
const url = this.#url('jobs')
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
return decodeJobPage(await this.#request(url, accessToken))
}
async getJob(
accessToken: string,
jobId: string,
): Promise<JobResourceDto | null> {
const response = await this.#requestOrMissing(
this.#url(`jobs/${encodeURIComponent(jobId)}`),
accessToken,
)
return response === null ? null : decodeJobResource(response)
}
async getDeletion(
accessToken: string,
operationId: string,
): Promise<DeletionOperationDto | null> {
const response = await this.#requestOrMissing(
this.#url(`deletions/${encodeURIComponent(operationId)}`),
accessToken,
)
return response === null ? null : decodeDeletionOperation(response)
}
async createEventTicket(accessToken: string): Promise<EventTicketDto> {
return decodeEventTicket(
await this.#request(this.#url('event-tickets'), accessToken, 'POST'),
)
}
#url(path: string): URL {
return new URL(path, withTrailingSlash(this.#baseUrl))
}
async #request(
url: URL,
accessToken: string,
method = 'GET',
): Promise<unknown> {
const response = await this.#fetch(url, {
method,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
cache: 'no-store',
credentials: 'omit',
})
if (!response.ok) throw new ApiResponseError(response.status)
return response.json()
}
async #requestOrMissing(url: URL, accessToken: string): Promise<unknown> {
try {
return await this.#request(url, accessToken)
} catch (error) {
if (error instanceof ApiResponseError && error.status === 404) return null
throw error
}
}
}
export interface EventConnection {
close(): void
}
export interface EventSocket {
connect(
ticket: string,
cursor: string,
listener: EventSocketListener,
): EventConnection
}
export interface EventSocketListener {
opened(): void
frame(frame: EventFrameDto): void
closed(): void
failed(error: Error): void
}
export class BrowserEventSocket implements EventSocket {
readonly #eventsUrl: URL
constructor(eventsUrl: string | URL) {
this.#eventsUrl = new URL(eventsUrl, 'http://localhost/')
}
connect(
ticket: string,
cursor: string,
listener: EventSocketListener,
): EventConnection {
const url = new URL(this.#eventsUrl)
url.searchParams.set('ticket', ticket)
url.searchParams.set('cursor', cursor)
const socket = new WebSocket(url)
let closed = false
socket.addEventListener('open', () => {
listener.opened()
})
socket.addEventListener('message', (event) => {
try {
listener.frame(decodeEventFrame(JSON.parse(String(event.data))))
} catch (error) {
socket.close(1002, 'invalid event frame')
listener.failed(asError(error))
}
})
socket.addEventListener('error', () => {
listener.failed(new Error('Live event connection failed'))
})
socket.addEventListener('close', () => {
if (!closed) listener.closed()
})
return {
close(): void {
closed = true
socket.close(1000)
},
}
}
}
function withTrailingSlash(url: URL): URL {
const copy = new URL(url)
if (!copy.pathname.endsWith('/')) copy.pathname += '/'
return copy
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error('Invalid live event frame')
}

View file

@ -0,0 +1,177 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { heartbeat, job } from '../sync/test-fixtures'
import type { EventFrameDto } from './contracts'
import { ApiResponseError, BrowserEventSocket, FetchPublicApi } from './client'
import { decodeEventTicket, decodeJobPage } from './decode'
describe('adapted public API client', () => {
test('authenticates, encodes cursors, and decodes snapshots', async () => {
const request = vi.fn<typeof fetch>(async () =>
Response.json({
items: [job(1)],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
}),
)
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
const page = await api.listJobs('access-token', 'cursor page/one')
expect(page.items).toEqual([job(1)])
const [url, init] = request.mock.calls[0]
if (!(url instanceof URL)) throw new Error('expected URL request')
expect(url.href).toBe(
'https://remora.invalid/v1/jobs?cursor=cursor+page%2Fone',
)
expect(init).toMatchObject({
cache: 'no-store',
credentials: 'omit',
headers: {
Accept: 'application/json',
Authorization: 'Bearer access-token',
},
})
})
test('maps an indistinguishable missing resource without swallowing faults', async () => {
const missing = new FetchPublicApi(
'https://remora.invalid/v1/',
async () => new Response(null, { status: 404 }),
)
await expect(missing.getJob('token', 'job_00000001')).resolves.toBeNull()
const unavailable = new FetchPublicApi(
'https://remora.invalid/v1/',
async () => new Response(null, { status: 503 }),
)
await expect(unavailable.getJob('token', 'job_00000001')).rejects.toEqual(
new ApiResponseError(503),
)
})
test('creates a one-time event ticket through authenticated HTTP', async () => {
const request = vi.fn<typeof fetch>(async () =>
Response.json(
{
ticket: 'ticket_00001',
expires_at: '2026-07-17T00:01:00Z',
},
{ status: 201 },
),
)
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
await expect(api.createEventTicket('token')).resolves.toEqual({
ticket: 'ticket_00001',
expires_at: '2026-07-17T00:01:00Z',
})
expect(request.mock.calls[0]?.[1]?.method).toBe('POST')
})
})
describe('snapshot wire decoding', () => {
test('rejects unknown and malformed resource fields', () => {
expect(() =>
decodeJobPage({
items: [{ ...job(1), secret: 'must not pass through' }],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
}),
).toThrow('unknown DTO field')
expect(() =>
decodeEventTicket({
ticket: 'short',
expires_at: '2026-07-17T00:01:00Z',
}),
).toThrow('invalid opaque identifier')
})
})
describe('browser event adapter', () => {
afterEach(() => {
FakeWebSocket.instances.length = 0
vi.unstubAllGlobals()
})
test('uses a ticket and cursor and decodes server frames', () => {
vi.stubGlobal('WebSocket', FakeWebSocket)
const opened = vi.fn<() => void>()
const frame = vi.fn<(event: EventFrameDto) => void>()
const closed = vi.fn<() => void>()
const failed = vi.fn<(error: Error) => void>()
const socket = new BrowserEventSocket('wss://remora.invalid/api/v1/events')
socket.connect('ticket_00001', 'cursor-snapshot-0001', {
opened,
frame,
closed,
failed,
})
const browser = FakeWebSocket.instances[0]
if (browser === undefined) throw new Error('missing WebSocket')
expect(browser.url instanceof URL ? browser.url.href : browser.url).toBe(
'wss://remora.invalid/api/v1/events?ticket=ticket_00001&cursor=cursor-snapshot-0001',
)
browser.emit('open', {})
browser.emit('message', {
data: JSON.stringify(heartbeat(1, 'event_000001')),
})
expect(opened).toHaveBeenCalledOnce()
expect(frame).toHaveBeenCalledWith(heartbeat(1, 'event_000001'))
expect(closed).not.toHaveBeenCalled()
expect(failed).not.toHaveBeenCalled()
})
test('closes a connection that sends an invalid frame', () => {
vi.stubGlobal('WebSocket', FakeWebSocket)
const failed = vi.fn<(error: Error) => void>()
const socket = new BrowserEventSocket('wss://remora.invalid/api/v1/events')
socket.connect('ticket_00001', 'cursor-snapshot-0001', {
opened: vi.fn<() => void>(),
frame: vi.fn<(event: EventFrameDto) => void>(),
closed: vi.fn<() => void>(),
failed,
})
const browser = FakeWebSocket.instances[0]
if (browser === undefined) throw new Error('missing WebSocket')
browser.emit('message', { data: '{}' })
expect(browser.closeCode).toBe(1002)
expect(failed).toHaveBeenCalledOnce()
})
})
class FakeWebSocket {
static readonly instances: FakeWebSocket[] = []
readonly #listeners = new Map<string, ((event: FakeSocketEvent) => void)[]>()
closeCode: number | null = null
constructor(readonly url: string | URL) {
FakeWebSocket.instances.push(this)
}
addEventListener(
type: string,
listener: (event: FakeSocketEvent) => void,
): void {
const listeners = this.#listeners.get(type) ?? []
listeners.push(listener)
this.#listeners.set(type, listeners)
}
close(code?: number): void {
this.closeCode = code ?? 1000
}
emit(type: string, event: FakeSocketEvent): void {
for (const listener of this.#listeners.get(type) ?? []) listener(event)
}
}
interface FakeSocketEvent {
data?: unknown
}

View file

@ -177,6 +177,11 @@ export interface HeartbeatDto extends EventBaseDto {
kind: 'heartbeat'
}
export interface EventTicketDto {
ticket: OpaqueIdDto
expires_at: string
}
export type SourceCredentialKindDto =
| 'basic_auth'
| 'bearer_token'

View file

@ -2,14 +2,22 @@ import { runtimeFailureClass, type JobPhase } from '../domain/vocabulary'
import { opaqueId } from '../domain/validation'
import type {
AttemptOutcomeRequestDto,
CheckpointProjectionDto,
DeletionOperationDto,
EventFrameDto,
EventProgressDto,
EventTicketDto,
HuggingFaceSelectionDto,
JobFailureDto,
JobPageDto,
JobResourceDto,
JobSubmissionDto,
ObjectIdentityDto,
ProgressDto,
ResultDescriptorDto,
SourceProjectionDto,
SourceSubmissionDto,
TargetDto,
TargetOverrideDto,
} from './contracts'
@ -118,6 +126,132 @@ export function decodeEventFrame(value: unknown): EventFrameDto {
}
}
export function decodeEventTicket(value: unknown): EventTicketDto {
const object = record(value)
exactKeys(object, ['ticket', 'expires_at'])
return {
ticket: opaqueId(string(object.ticket)),
expires_at: dateTime(object.expires_at),
}
}
export function decodeJobPage(value: unknown): JobPageDto {
const object = record(value)
exactKeys(object, ['items', 'next_cursor', 'event_cursor'])
if (!Array.isArray(object.items)) throw new Error('expected job array')
return {
items: object.items.map(decodeJobResource),
next_cursor:
object.next_cursor === null ? null : cursor(object.next_cursor),
event_cursor: cursor(object.event_cursor),
}
}
export function decodeJobResource(value: unknown): JobResourceDto {
const object = record(value)
if (object.resource_kind === 'job') {
exactKeys(object, [
'resource_kind',
'id',
'owner',
'label',
'revision',
'phase',
'lifecycle',
'source',
'target',
'authoritative_attempt',
'progress',
'checkpoint',
'result',
'failure',
'created_at',
'updated_at',
'event_cursor',
])
return {
resource_kind: 'job',
id: opaqueId(string(object.id)),
owner: opaqueId(string(object.owner)),
label: object.label === null ? null : string(object.label),
revision: positiveInteger(object.revision),
phase: jobPhase(object.phase),
lifecycle: lifecycle(object.lifecycle),
source: decodeSourceProjection(object.source),
target: decodeTarget(object.target),
authoritative_attempt:
object.authoritative_attempt === null
? null
: decodeAttemptAuthority(object.authoritative_attempt),
progress:
object.progress === null ? null : decodeProgress(object.progress),
checkpoint:
object.checkpoint === null ? null : decodeCheckpoint(object.checkpoint),
result: object.result === null ? null : decodeResult(object.result),
failure:
object.failure === null ? null : decodeJobFailure(object.failure),
created_at: dateTime(object.created_at),
updated_at: dateTime(object.updated_at),
event_cursor: cursor(object.event_cursor),
}
}
if (object.resource_kind === 'tombstone') {
exactKeys(object, [
'resource_kind',
'id',
'owner',
'target',
'terminal_phase',
'deletion_operation_id',
'created_at',
'hard_delete_started_at',
'updated_at',
'event_cursor',
])
const terminalPhase = jobPhase(object.terminal_phase)
if (
terminalPhase !== 'Succeeded' &&
terminalPhase !== 'Failed' &&
terminalPhase !== 'Cancelled'
)
throw new Error('invalid tombstone terminal phase')
return {
resource_kind: 'tombstone',
id: opaqueId(string(object.id)),
owner: opaqueId(string(object.owner)),
target: decodeTarget(object.target),
terminal_phase: terminalPhase,
deletion_operation_id: opaqueId(string(object.deletion_operation_id)),
created_at: dateTime(object.created_at),
hard_delete_started_at: dateTime(object.hard_delete_started_at),
updated_at: dateTime(object.updated_at),
event_cursor: cursor(object.event_cursor),
}
}
throw new Error('unknown job resource kind')
}
export function decodeDeletionOperation(value: unknown): DeletionOperationDto {
const object = record(value)
exactKeys(object, [
'id',
'job_id',
'state',
'problem',
'created_at',
'updated_at',
])
return {
id: opaqueId(string(object.id)),
job_id: opaqueId(string(object.job_id)),
state: deletionState(object.state),
problem:
object.problem === null ? null : decodeDeletionProblem(object.problem),
created_at: dateTime(object.created_at),
updated_at: dateTime(object.updated_at),
}
}
export function decodeAttemptOutcome(value: unknown): AttemptOutcomeRequestDto {
const object = record(value)
exactKeys(object, ['operation_id', 'fence', 'worker_incarnation', 'outcome'])
@ -155,6 +289,144 @@ export function decodeAttemptOutcome(value: unknown): AttemptOutcomeRequestDto {
throw new Error('unknown attempt outcome kind')
}
function decodeSourceProjection(value: unknown): SourceProjectionDto {
const object = record(value)
const credential =
object.credential_id === undefined
? {}
: { credential_id: opaqueId(string(object.credential_id)) }
switch (object.kind) {
case 'http':
exactKeys(object, ['kind', 'display_url', 'result_path', 'credential_id'])
return {
kind: 'http',
display_url: string(object.display_url),
result_path: string(object.result_path),
...credential,
}
case 'object_store':
exactKeys(object, [
'kind',
'store',
'bucket',
'key',
'version_id',
'result_path',
'credential_id',
])
return {
kind: 'object_store',
store: string(object.store),
bucket: string(object.bucket),
key: string(object.key),
...(object.version_id === undefined
? {}
: { version_id: string(object.version_id) }),
result_path: string(object.result_path),
...credential,
}
case 'hugging_face':
exactKeys(object, [
'kind',
'repository_type',
'repository_id',
'revision',
'selection',
'credential_id',
])
return {
kind: 'hugging_face',
repository_type: repositoryType(object.repository_type),
repository_id: string(object.repository_id),
revision: string(object.revision),
...(object.selection === undefined
? {}
: { selection: decodeSelection(object.selection) }),
...credential,
}
default:
throw new Error('unknown source projection kind')
}
}
function decodeTarget(value: unknown): TargetDto {
const object = record(value)
exactKeys(object, ['store', 'bucket', 'prefix'])
return {
store: string(object.store),
bucket: string(object.bucket),
prefix: string(object.prefix),
}
}
function decodeAttemptAuthority(value: unknown): {
attempt_id: string
fence: number
worker_incarnation: string
} {
const object = record(value)
exactKeys(object, ['attempt_id', 'fence', 'worker_incarnation'])
return {
attempt_id: opaqueId(string(object.attempt_id)),
fence: positiveInteger(object.fence),
worker_incarnation: opaqueId(string(object.worker_incarnation)),
}
}
function decodeProgress(value: unknown): ProgressDto {
const object = record(value)
exactKeys(object, [
'attempt_id',
'fence',
'completed_bytes',
'total_bytes',
'completed_members',
'total_members',
'observed_at',
])
return {
attempt_id: opaqueId(string(object.attempt_id)),
fence: positiveInteger(object.fence),
completed_bytes: nonnegativeInteger(object.completed_bytes),
...(object.total_bytes === undefined
? {}
: { total_bytes: nullableNonnegativeInteger(object.total_bytes) }),
completed_members: nonnegativeInteger(object.completed_members),
...(object.total_members === undefined
? {}
: { total_members: nullableNonnegativeInteger(object.total_members) }),
observed_at: dateTime(object.observed_at),
}
}
function decodeCheckpoint(value: unknown): CheckpointProjectionDto {
const object = record(value)
exactKeys(object, ['reusable', 'validated_bytes', 'validated_members'])
if (typeof object.reusable !== 'boolean')
throw new Error('invalid checkpoint reusable flag')
return {
reusable: object.reusable,
validated_bytes: nonnegativeInteger(object.validated_bytes),
validated_members: nonnegativeInteger(object.validated_members),
}
}
function decodeDeletionProblem(value: unknown): {
class: 'authorization' | 'target_unavailable' | 'configuration' | 'internal'
message: string
} {
const object = record(value)
exactKeys(object, ['class', 'message'])
if (
object.class !== 'authorization' &&
object.class !== 'target_unavailable' &&
object.class !== 'configuration' &&
object.class !== 'internal'
)
throw new Error('unknown deletion problem class')
return { class: object.class, message: string(object.message) }
}
function decodeSourceSubmission(value: unknown): SourceSubmissionDto {
const object = record(value)
const credential =

View file

@ -0,0 +1,129 @@
import { User, UserManager } from 'oidc-client-ts'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { BrowserOidcSession, type BrowserOidcConfig } from './session'
const originalUrl = window.location.href
const config: BrowserOidcConfig = {
authority: 'https://identity.invalid',
clientId: 'remora-web',
redirectUri: window.location.origin + '/',
postLogoutRedirectUri: window.location.origin + '/',
scope: 'openid profile',
}
afterEach(() => {
window.history.replaceState({}, document.title, originalUrl)
})
describe('browser OIDC session', () => {
test('processes the authorization-code callback and removes its parameters', async () => {
const manager = oidcManager()
const authenticated = user('access-token')
const callback = vi
.spyOn(manager, 'signinCallback')
.mockResolvedValue(authenticated)
window.history.replaceState(
{},
document.title,
`${window.location.pathname}?code=authorization-code&state=oidc-state`,
)
const session = new BrowserOidcSession(
config,
window.sessionStorage,
manager,
)
await session.start()
expect(callback).toHaveBeenCalledOnce()
expect(session.state).toEqual({
kind: 'authenticated',
session: {
accessToken: 'access-token',
identity: 'https://identity.invalid\u0000subject-1',
},
})
expect(window.location.search).toBe('')
})
test('processes logout callbacks and fails closed on expired users', async () => {
const manager = oidcManager()
const callback = vi
.spyOn(manager, 'signoutCallback')
.mockResolvedValue(undefined)
window.history.replaceState(
{},
document.title,
`${window.location.pathname}?state=logout-state`,
)
const logout = new BrowserOidcSession(
config,
window.sessionStorage,
manager,
)
await logout.start()
expect(callback).toHaveBeenCalledOnce()
expect(logout.state).toEqual({ kind: 'signed_out' })
const expiredManager = oidcManager()
vi.spyOn(expiredManager, 'getUser').mockResolvedValue(
user('expired-token', 1),
)
const expired = new BrowserOidcSession(
config,
window.sessionStorage,
expiredManager,
)
await expired.start()
expect(expired.state).toEqual({ kind: 'signed_out' })
})
test('clears the local session when provider logout cannot start', async () => {
const manager = oidcManager()
vi.spyOn(manager, 'signoutRedirect').mockRejectedValue(
new Error('provider unavailable'),
)
const remove = vi.spyOn(manager, 'removeUser').mockResolvedValue()
const session = new BrowserOidcSession(
config,
window.sessionStorage,
manager,
)
await session.signOut()
expect(remove).toHaveBeenCalledOnce()
expect(session.state).toEqual({ kind: 'signed_out' })
})
})
function oidcManager(): UserManager {
return new UserManager({
authority: config.authority,
client_id: config.clientId,
redirect_uri: config.redirectUri,
post_logout_redirect_uri: config.postLogoutRedirectUri,
response_type: 'code',
scope: config.scope,
})
}
function user(accessToken: string, expiresAt?: number): User {
return new User({
session_state: null,
access_token: accessToken,
token_type: 'Bearer',
scope: 'openid profile',
profile: {
iss: 'https://identity.invalid',
sub: 'subject-1',
aud: 'remora-web',
exp: expiresAt ?? Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
},
...(expiresAt === undefined ? {} : { expires_at: expiresAt }),
})
}

175
web/src/auth/session.ts Normal file
View file

@ -0,0 +1,175 @@
import {
UserManager,
WebStorageStateStore,
type User,
type UserManagerSettings,
} from 'oidc-client-ts'
export interface AuthenticatedSession {
accessToken: string
identity: string
}
export type SessionState =
| { kind: 'loading' }
| { kind: 'signed_out' }
| { kind: 'authenticated'; session: AuthenticatedSession }
| { kind: 'error'; message: string }
export interface Session {
readonly state: SessionState
start(): Promise<void>
signIn(): Promise<void>
signOut(): Promise<void>
subscribe(listener: (state: SessionState) => void): () => void
}
export interface BrowserOidcConfig {
authority: string
clientId: string
redirectUri: string
postLogoutRedirectUri: string
scope: string
}
export type OidcManager = Pick<
UserManager,
| 'events'
| 'getUser'
| 'removeUser'
| 'signinCallback'
| 'signinRedirect'
| 'signoutCallback'
| 'signoutRedirect'
>
export class BrowserOidcSession implements Session {
#state: SessionState = { kind: 'loading' }
readonly #listeners = new Set<(state: SessionState) => void>()
readonly #manager: OidcManager
constructor(
config: BrowserOidcConfig,
storage: Storage = window.sessionStorage,
manager?: OidcManager,
) {
this.#manager = manager ?? createManager(config, storage)
this.#manager.events.addUserLoaded((user) => {
this.#setState(authenticated(user))
})
this.#manager.events.addUserUnloaded(() => {
this.#setState({ kind: 'signed_out' })
})
this.#manager.events.addAccessTokenExpired(() => {
this.#setState({ kind: 'signed_out' })
})
this.#manager.events.addSilentRenewError(() => {
this.#setState({ kind: 'error', message: 'Session renewal failed' })
})
}
get state(): SessionState {
return this.#state
}
async start(): Promise<void> {
this.#setState({ kind: 'loading' })
const parameters = new URLSearchParams(window.location.search)
const hasState = parameters.has('state')
const signinCallback =
hasState && (parameters.has('code') || parameters.has('error'))
try {
let user: User | null | undefined
if (signinCallback) user = await this.#manager.signinCallback()
else if (hasState) {
await this.#manager.signoutCallback()
user = null
} else user = await this.#manager.getUser()
this.#setState(
user === null || user === undefined
? { kind: 'signed_out' }
: authenticated(user),
)
} catch {
this.#setState({
kind: 'error',
message: 'Unable to restore the session',
})
} finally {
if (hasState)
window.history.replaceState(
{},
document.title,
window.location.pathname + window.location.hash,
)
}
}
async signIn(): Promise<void> {
try {
await this.#manager.signinRedirect()
} catch {
this.#setState({ kind: 'error', message: 'Unable to start sign in' })
}
}
async signOut(): Promise<void> {
try {
await this.#manager.signoutRedirect()
} catch {
await this.#manager.removeUser().catch(() => undefined)
this.#setState({ kind: 'signed_out' })
}
}
subscribe(listener: (state: SessionState) => void): () => void {
this.#listeners.add(listener)
listener(this.#state)
return () => {
this.#listeners.delete(listener)
}
}
#setState(state: SessionState): void {
this.#state = state
for (const listener of this.#listeners) listener(state)
}
}
function authenticated(user: User): SessionState {
const issuer = user.profile.iss
const subject = user.profile.sub
if (
issuer === undefined ||
subject === undefined ||
user.access_token === '' ||
user.expired
)
return { kind: 'signed_out' }
return {
kind: 'authenticated',
session: {
accessToken: user.access_token,
identity: `${issuer}\u0000${subject}`,
},
}
}
function createManager(
config: BrowserOidcConfig,
storage: Storage,
): UserManager {
const settings: UserManagerSettings = {
authority: config.authority,
client_id: config.clientId,
redirect_uri: config.redirectUri,
post_logout_redirect_uri: config.postLogoutRedirectUri,
response_type: 'code',
scope: config.scope,
automaticSilentRenew: true,
monitorSession: true,
userStore: new WebStorageStateStore({ store: storage }),
stateStore: new WebStorageStateStore({ store: storage }),
}
return new UserManager(settings)
}

57
web/src/runtime.ts Normal file
View file

@ -0,0 +1,57 @@
import { BrowserEventSocket, FetchPublicApi } from './api/client'
import { BrowserOidcSession } from './auth/session'
import { WebClientRuntime } from './sync/runtime'
interface RemoraRuntimeConfig {
apiUrl: string
eventsUrl: string
oidcAuthority: string
oidcClientId: string
oidcRedirectUri: string
oidcPostLogoutRedirectUri: string
oidcScope: string
}
export function createBrowserRuntime(): WebClientRuntime {
const config = runtimeConfig()
const session = new BrowserOidcSession({
authority: config.oidcAuthority,
clientId: config.oidcClientId,
redirectUri: config.oidcRedirectUri,
postLogoutRedirectUri: config.oidcPostLogoutRedirectUri,
scope: config.oidcScope,
})
return new WebClientRuntime(
session,
new FetchPublicApi(config.apiUrl),
new BrowserEventSocket(config.eventsUrl),
)
}
function runtimeConfig(): RemoraRuntimeConfig {
const apiUrl = import.meta.env.VITE_REMORA_API_URL ?? '/api/v1/'
return {
apiUrl,
eventsUrl:
import.meta.env.VITE_REMORA_EVENTS_URL ??
new URL('events', websocketBase(apiUrl)).toString(),
oidcAuthority:
import.meta.env.VITE_REMORA_OIDC_AUTHORITY ??
new URL('/oidc', window.location.origin).toString(),
oidcClientId: import.meta.env.VITE_REMORA_OIDC_CLIENT_ID ?? 'remora-web',
oidcRedirectUri:
import.meta.env.VITE_REMORA_OIDC_REDIRECT_URI ??
new URL('/', window.location.origin).toString(),
oidcPostLogoutRedirectUri:
import.meta.env.VITE_REMORA_OIDC_POST_LOGOUT_REDIRECT_URI ??
new URL('/', window.location.origin).toString(),
oidcScope: import.meta.env.VITE_REMORA_OIDC_SCOPE ?? 'openid profile',
}
}
function websocketBase(apiUrl: string): URL {
const url = new URL(apiUrl, window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
if (!url.pathname.endsWith('/')) url.pathname += '/'
return url
}

316
web/src/sync/runtime.ts Normal file
View file

@ -0,0 +1,316 @@
import type {
EventConnection,
EventSocket,
EventSocketListener,
PublicApi,
} from '../api/client'
import type { EventFrameDto, JobResourceDto } from '../api/contracts'
import type {
AuthenticatedSession,
Session,
SessionState,
} from '../auth/session'
import {
SynchronizationState,
type SynchronizationEffect,
type SynchronizationSnapshot,
} from './state'
export interface Scheduler {
schedule(callback: () => void, delayMilliseconds: number): () => void
}
export class BrowserScheduler implements Scheduler {
schedule(callback: () => void, delayMilliseconds: number): () => void {
const timeout = window.setTimeout(callback, delayMilliseconds)
return () => {
window.clearTimeout(timeout)
}
}
}
export class WebClientRuntime {
readonly #state = new SynchronizationState()
readonly #listeners = new Set<(state: SynchronizationSnapshot) => void>()
readonly #session: Session
readonly #api: PublicApi
readonly #socket: EventSocket
readonly #scheduler: Scheduler
#sessionKey: string | null = null
#accessToken: string | null = null
#generation = 0
#connectionEpoch = 0
#connection: EventConnection | null = null
#cancelRetry: (() => void) | null = null
#unsubscribeSession: (() => void) | null = null
#frames = Promise.resolve()
constructor(
session: Session,
api: PublicApi,
socket: EventSocket,
scheduler: Scheduler = new BrowserScheduler(),
) {
this.#session = session
this.#api = api
this.#socket = socket
this.#scheduler = scheduler
}
get snapshot(): SynchronizationSnapshot {
return this.#state.snapshot()
}
async start(): Promise<void> {
if (this.#unsubscribeSession === null)
this.#unsubscribeSession = this.#session.subscribe((state) => {
this.#sessionChanged(state)
})
await this.#session.start()
}
stop(): void {
this.#generation += 1
this.#frames = Promise.resolve()
this.#closeConnection()
this.#cancelScheduledRetry()
this.#unsubscribeSession?.()
this.#unsubscribeSession = null
}
signIn(): Promise<void> {
return this.#session.signIn()
}
signOut(): Promise<void> {
return this.#session.signOut()
}
subscribe(listener: (state: SynchronizationSnapshot) => void): () => void {
this.#listeners.add(listener)
listener(this.snapshot)
return () => {
this.#listeners.delete(listener)
}
}
#sessionChanged(state: SessionState): void {
if (state.kind === 'authenticated') {
this.#authenticated(state.session)
return
}
this.#sessionKey = null
this.#accessToken = null
this.#generation += 1
this.#frames = Promise.resolve()
this.#closeConnection()
this.#cancelScheduledRetry()
this.#state.clear(state.kind === 'loading' ? 'loading' : 'signed_out')
if (state.kind === 'error') this.#state.setStatus('error', state.message)
this.#publish()
}
#authenticated(session: AuthenticatedSession): void {
const sessionKey = `${session.identity}\u0000${session.accessToken}`
if (sessionKey === this.#sessionKey) return
this.#sessionKey = sessionKey
this.#accessToken = session.accessToken
this.#generation += 1
this.#frames = Promise.resolve()
this.#closeConnection()
this.#cancelScheduledRetry()
this.#state.clear('loading')
this.#publish()
void this.#loadSnapshot(this.#generation, 'loading')
}
async #loadSnapshot(
generation: number,
status: 'loading' | 'resyncing',
): Promise<void> {
const token = this.#accessToken
if (token === null || generation !== this.#generation) return
this.#connectionEpoch += 1
this.#frames = Promise.resolve()
this.#closeConnection()
this.#cancelScheduledRetry()
this.#state.setStatus(status)
this.#publish()
try {
const jobs: JobResourceDto[] = []
const pageCursors = new Set<string>()
let pageCursor: string | undefined
let eventCursor: string | null = null
do {
const page = await this.#api.listJobs(token, pageCursor)
if (generation !== this.#generation) return
eventCursor ??= page.event_cursor
jobs.push(...page.items)
pageCursor = page.next_cursor ?? undefined
if (pageCursor !== undefined && !pageCursors.add(pageCursor))
throw new Error('Job pagination cursor repeated')
} while (pageCursor !== undefined)
if (eventCursor === null)
throw new Error('Job snapshot omitted its cursor')
this.#state.installSnapshot(jobs, eventCursor)
this.#publish()
await this.#connect(generation)
} catch (error) {
if (generation !== this.#generation) return
this.#state.setStatus('error', message(error))
this.#publish()
this.#scheduleSnapshotRetry(generation)
}
}
async #connect(generation: number): Promise<void> {
const token = this.#accessToken
const cursor = this.#state.snapshot().cursor
if (token === null || cursor === null || generation !== this.#generation)
return
this.#state.setStatus('reconnecting')
this.#publish()
try {
const ticket = await this.#api.createEventTicket(token)
if (generation !== this.#generation) return
const epoch = ++this.#connectionEpoch
this.#state.beginConnection()
const listener = this.#listener(generation, epoch)
this.#connection = this.#socket.connect(ticket.ticket, cursor, listener)
} catch {
if (generation === this.#generation) this.#scheduleReconnect(generation)
}
}
#listener(generation: number, epoch: number): EventSocketListener {
const current = (): boolean =>
generation === this.#generation && epoch === this.#connectionEpoch
return {
opened: () => {
if (!current()) return
this.#state.setStatus('live')
this.#publish()
},
frame: (frame) => {
if (!current()) return
this.#frames = this.#frames.then(async () => {
if (!current()) return
await this.#handleFrame(generation, epoch, frame)
})
},
closed: () => {
if (current()) this.#scheduleReconnect(generation)
},
failed: () => {
if (current()) this.#scheduleReconnect(generation)
},
}
}
async #handleFrame(
generation: number,
epoch: number,
frame: EventFrameDto,
): Promise<void> {
const effect = this.#state.apply(frame)
this.#publish()
if (await this.#performEffect(generation, epoch, effect)) {
this.#state.commit(frame)
this.#publish()
}
}
async #performEffect(
generation: number,
epoch: number,
effect: SynchronizationEffect,
): Promise<boolean> {
if (effect.kind === 'none')
return generation === this.#generation && epoch === this.#connectionEpoch
if (effect.kind === 'resync') {
await this.#resync(generation, epoch)
return false
}
const token = this.#accessToken
if (
token === null ||
generation !== this.#generation ||
epoch !== this.#connectionEpoch
)
return false
try {
if (effect.kind === 'refetch_job') {
const resource = await this.#api.getJob(token, effect.jobId)
if (generation !== this.#generation || epoch !== this.#connectionEpoch)
return false
if (
!this.#state.replaceJob(
effect.jobId,
effect.minimumRevision,
resource,
)
) {
await this.#resync(generation, epoch)
return false
}
} else {
const operation = await this.#api.getDeletion(token, effect.operationId)
if (generation !== this.#generation || epoch !== this.#connectionEpoch)
return false
this.#state.replaceDeletion(effect.operationId, operation)
}
return true
} catch {
await this.#resync(generation, epoch)
return false
}
}
async #resync(generation: number, epoch: number): Promise<void> {
if (generation !== this.#generation || epoch !== this.#connectionEpoch)
return
this.#connectionEpoch += 1
await this.#loadSnapshot(generation, 'resyncing')
}
#scheduleReconnect(generation: number): void {
if (generation !== this.#generation || this.#cancelRetry !== null) return
this.#connectionEpoch += 1
this.#frames = Promise.resolve()
this.#closeConnection()
this.#state.setStatus('reconnecting')
this.#publish()
this.#cancelRetry = this.#scheduler.schedule(() => {
this.#cancelRetry = null
void this.#connect(generation)
}, 500)
}
#scheduleSnapshotRetry(generation: number): void {
if (generation !== this.#generation || this.#cancelRetry !== null) return
this.#cancelRetry = this.#scheduler.schedule(() => {
this.#cancelRetry = null
void this.#loadSnapshot(generation, 'resyncing')
}, 1000)
}
#closeConnection(): void {
const connection = this.#connection
this.#connection = null
connection?.close()
}
#cancelScheduledRetry(): void {
this.#cancelRetry?.()
this.#cancelRetry = null
}
#publish(): void {
const snapshot = this.snapshot
for (const listener of this.#listeners) listener(snapshot)
}
}
function message(error: unknown): string {
return error instanceof Error ? error.message : 'Synchronization failed'
}

View 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 () => {}
}
}

198
web/src/sync/state.ts Normal file
View file

@ -0,0 +1,198 @@
import type {
DeletionOperationDto,
EventFrameDto,
EventProgressDto,
JobResourceDto,
} from '../api/contracts'
export type SynchronizationStatus =
'signed_out' | 'loading' | 'live' | 'reconnecting' | 'resyncing' | 'error'
export interface CachedJob {
resource: JobResourceDto
provisionalProgress: EventProgressDto | null
}
export interface SynchronizationSnapshot {
status: SynchronizationStatus
jobs: CachedJob[]
deletions: DeletionOperationDto[]
cursor: string | null
message: string | null
}
export type SynchronizationEffect =
| { kind: 'none' }
| { kind: 'resync' }
| { kind: 'refetch_job'; jobId: string; minimumRevision: number }
| { kind: 'refetch_deletion'; operationId: string }
export class SynchronizationState {
#status: SynchronizationStatus = 'signed_out'
#message: string | null = null
readonly #jobs = new Map<string, JobResourceDto>()
readonly #deletions = new Map<string, DeletionOperationDto>()
readonly #progress = new Map<string, EventProgressDto>()
#cursor: string | null = null
#nextSequence = 1
readonly #eventIds = new BoundedSet(2048)
snapshot(): SynchronizationSnapshot {
return {
status: this.#status,
jobs: [...this.#jobs.values()].sort(compareResources).map((resource) => ({
resource,
provisionalProgress: this.#progress.get(resource.id) ?? null,
})),
deletions: [...this.#deletions.values()].sort((left, right) =>
left.id.localeCompare(right.id),
),
cursor: this.#cursor,
message: this.#message,
}
}
setStatus(
status: SynchronizationStatus,
message: string | null = null,
): void {
this.#status = status
this.#message = message
}
clear(status: SynchronizationStatus): void {
this.#jobs.clear()
this.#deletions.clear()
this.#progress.clear()
this.#eventIds.clear()
this.#cursor = null
this.#nextSequence = 1
this.setStatus(status)
}
installSnapshot(resources: JobResourceDto[], cursor: string): void {
this.#jobs.clear()
this.#deletions.clear()
this.#progress.clear()
for (const resource of resources) this.#jobs.set(resource.id, resource)
this.#cursor = cursor
this.#eventIds.clear()
}
beginConnection(): void {
this.#nextSequence = 1
}
apply(frame: EventFrameDto): SynchronizationEffect {
if (frame.sequence !== this.#nextSequence) return { kind: 'resync' }
this.#nextSequence += 1
if (this.#eventIds.has(frame.event_id)) return { kind: 'none' }
switch (frame.kind) {
case 'heartbeat':
return { kind: 'none' }
case 'resync_required':
return { kind: 'resync' }
case 'deletion_changed':
return {
kind: 'refetch_deletion',
operationId: frame.operation_id,
}
case 'job_changed': {
const resource = this.#jobs.get(frame.job_id)
if (
resource?.resource_kind === 'job' &&
resource.revision >= frame.job_revision
) {
this.#progress.delete(frame.job_id)
return { kind: 'none' }
}
if (validProgress(frame.authoritative_attempt, frame.progress))
this.#progress.set(frame.job_id, frame.progress)
else this.#progress.delete(frame.job_id)
return {
kind: 'refetch_job',
jobId: frame.job_id,
minimumRevision: frame.job_revision,
}
}
}
return unreachable(frame)
}
commit(frame: EventFrameDto): void {
this.#cursor = frame.cursor
this.#eventIds.add(frame.event_id)
}
replaceJob(
jobId: string,
minimumRevision: number,
resource: JobResourceDto | null,
): boolean {
if (
resource?.resource_kind === 'job' &&
resource.revision < minimumRevision
)
return false
this.#progress.delete(jobId)
if (resource === null) this.#jobs.delete(jobId)
else this.#jobs.set(jobId, resource)
return true
}
replaceDeletion(
operationId: string,
operation: DeletionOperationDto | null,
): void {
if (operation === null) this.#deletions.delete(operationId)
else this.#deletions.set(operationId, operation)
}
}
function unreachable(value: never): never {
throw new Error(`Unsupported event frame: ${String(value)}`)
}
function validProgress(
attempt: { attempt_id: string; fence: number } | null,
progress: EventProgressDto | null,
): progress is EventProgressDto {
return (
attempt !== null &&
progress !== null &&
attempt.attempt_id === progress.attempt_id &&
attempt.fence === progress.fence
)
}
function compareResources(left: JobResourceDto, right: JobResourceDto): number {
const time = right.created_at.localeCompare(left.created_at)
return time === 0 ? left.id.localeCompare(right.id) : time
}
class BoundedSet {
readonly #values = new Set<string>()
readonly #order: string[] = []
constructor(readonly capacity: number) {}
add(value: string): boolean {
if (this.#values.has(value)) return false
this.#values.add(value)
this.#order.push(value)
if (this.#order.length > this.capacity) {
const removed = this.#order.shift()
if (removed !== undefined) this.#values.delete(removed)
}
return true
}
has(value: string): boolean {
return this.#values.has(value)
}
clear(): void {
this.#values.clear()
this.#order.length = 0
}
}

View file

@ -0,0 +1,82 @@
import { describe, expect, test } from 'vitest'
import { SynchronizationState } from './state'
import { heartbeat, job, jobChanged } from './test-fixtures'
describe('synchronization state', () => {
test('deduplicates replay while advancing the connection cursor', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.beginConnection()
expect(state.apply(jobChanged(1, 'event_000001', 2, 20))).toEqual({
kind: 'refetch_job',
jobId: 'job_00000001',
minimumRevision: 2,
})
expect(state.snapshot().jobs[0]?.provisionalProgress?.completed_bytes).toBe(
20,
)
expect(state.replaceJob('job_00000001', 2, job(2))).toBe(true)
state.commit(jobChanged(1, 'event_000001', 2, 20))
const duplicate = jobChanged(2, 'event_000001', 2, 20)
expect(state.apply(duplicate)).toEqual({
kind: 'none',
})
state.commit(duplicate)
expect(state.snapshot().cursor).toBe('cursor-event-0002')
expect(state.snapshot().jobs[0]?.provisionalProgress).toBeNull()
})
test('requires increasing per-connection sequence numbers', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.beginConnection()
expect(state.apply(heartbeat(2, 'event_000002'))).toEqual({
kind: 'resync',
})
expect(state.snapshot().cursor).toBe('cursor-snapshot-0001')
})
test('retains replay deduplication when a connection restarts', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.beginConnection()
const first = heartbeat(1, 'event_000001')
state.apply(first)
state.commit(first)
state.beginConnection()
const replay = heartbeat(1, 'event_000001')
expect(state.apply(replay)).toEqual({
kind: 'none',
})
state.commit(replay)
expect(state.snapshot().cursor).toBe('cursor-event-0001')
})
test('rejects an HTTP refetch older than its invalidation', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
expect(state.replaceJob('job_00000001', 3, job(2))).toBe(false)
expect(state.snapshot().jobs[0]?.resource).toEqual(job(1))
expect(state.replaceJob('job_00000001', 3, job(3))).toBe(true)
expect(state.snapshot().jobs[0]?.resource).toEqual(job(3))
})
test('does not expose a progress hint with mismatched authority', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.beginConnection()
const frame = jobChanged(1, 'event_000001', 2, 20)
if (frame.kind !== 'job_changed') throw new Error('invalid fixture')
frame.progress = { ...frame.progress!, fence: 2 }
state.apply(frame)
expect(state.snapshot().jobs[0]?.provisionalProgress).toBeNull()
})
})

View file

@ -0,0 +1,93 @@
import type { EventFrameDto, JobDto, ProgressDto } from '../api/contracts'
export const observedAt = '2026-07-17T00:00:00Z'
export function job(revision: number, overrides: Partial<JobDto> = {}): JobDto {
return {
resource_kind: 'job',
id: 'job_00000001',
owner: 'owner_000001',
label: 'Fixture job',
revision,
phase: 'Running',
lifecycle: 'visible',
source: {
kind: 'http',
display_url: 'https://example.invalid/archive',
result_path: 'archive.bin',
},
target: {
store: 'target-main',
bucket: 'fixture-bucket',
prefix: 'results',
},
authoritative_attempt: {
attempt_id: 'attempt_0001',
fence: 1,
worker_incarnation: 'worker_00001',
},
progress: progress(10),
checkpoint: null,
result: null,
failure: null,
created_at: observedAt,
updated_at: observedAt,
event_cursor: 'cursor-snapshot-0001',
...overrides,
}
}
export function progress(completedBytes: number): ProgressDto {
return {
attempt_id: 'attempt_0001',
fence: 1,
completed_bytes: completedBytes,
total_bytes: 100,
completed_members: 0,
total_members: 1,
observed_at: observedAt,
}
}
export function jobChanged(
sequence: number,
eventId: string,
revision: number,
completedBytes: number,
): EventFrameDto {
return {
schema_version: 1,
kind: 'job_changed',
event_id: eventId,
sequence,
cursor: `cursor-event-${String(sequence).padStart(4, '0')}`,
occurred_at: observedAt,
job_id: 'job_00000001',
job_revision: revision,
phase: 'Running',
lifecycle: 'visible',
authoritative_attempt: {
attempt_id: 'attempt_0001',
fence: 1,
},
progress: {
attempt_id: 'attempt_0001',
fence: 1,
completed_bytes: completedBytes,
total_bytes: 100,
completed_members: 0,
total_members: 1,
},
}
}
export function heartbeat(sequence: number, eventId: string): EventFrameDto {
return {
schema_version: 1,
kind: 'heartbeat',
event_id: eventId,
sequence,
cursor: `cursor-event-${String(sequence).padStart(4, '0')}`,
occurred_at: observedAt,
}
}