UNPKG

@durable-streams/y-durable-streams

Version:

Yjs provider for Durable Streams - sync Yjs documents over append-only streams

301 lines (264 loc) 9.17 kB
/** * Compactor - Handles Yjs document compaction. * * Compaction creates a snapshot from the current document state. * Snapshots are stored with offset-based keys ({offset}_snapshot). * New clients load snapshot + updates after that offset. */ import * as Y from "yjs" import * as encoding from "lib0/encoding" import * as decoding from "lib0/decoding" import { DurableStream, DurableStreamError, FetchError, } from "@durable-streams/client" import { YjsStreamPaths } from "./types" import type { CompactionResult, YjsDocumentState, YjsIndexEntry } from "./types" /** * Interface for the server that the Compactor works with. */ export interface CompactorServer { getDsServerUrl: () => string getDsServerHeaders: () => Record<string, string> getDocumentState: ( service: string, docPath: string ) => YjsDocumentState | undefined /** Atomically check if compaction can start and set compacting=true if so */ tryStartCompaction: (service: string, docPath: string) => boolean setCompacting: (service: string, docPath: string, compacting: boolean) => void resetUpdateCounters: (service: string, docPath: string) => void updateSnapshotOffset: ( service: string, docPath: string, offset: string ) => void /** Append a JSON entry to an index stream, creating the stream if needed */ appendToIndexStream: ( dsPath: string, entry: Record<string, unknown> ) => Promise<void> } /** * Check if an error is a 404 Not Found error. */ function isNotFoundError(err: unknown): boolean { return ( (err instanceof DurableStreamError && err.code === `NOT_FOUND`) || (err instanceof FetchError && err.status === 404) ) } /** * Handles document compaction. */ export class Compactor { private readonly server: CompactorServer constructor(server: CompactorServer) { this.server = server } /** * Trigger compaction for a document. * Uses atomic check-and-set to prevent concurrent compactions. */ async triggerCompaction(service: string, docPath: string): Promise<void> { // Atomically check if we can start compaction // This prevents race conditions where multiple triggers see compacting=false if (!this.server.tryStartCompaction(service, docPath)) { return } try { await this.performCompaction(service, docPath) } finally { this.server.setCompacting(service, docPath, false) } } /** * Perform the actual compaction. */ private async performCompaction( service: string, docPath: string ): Promise<CompactionResult> { const state = this.server.getDocumentState(service, docPath) if (!state) { throw new Error(`Document state not found for ${service}/${docPath}`) } const dsServerUrl = this.server.getDsServerUrl() const dsHeaders = this.server.getDsServerHeaders() // Create a Y.Doc and load current state const doc = new Y.Doc() try { // Load existing snapshot if any if (state.snapshotOffset) { const snapshotKey = YjsStreamPaths.snapshotKey(state.snapshotOffset) const snapshotUrl = `${dsServerUrl}${YjsStreamPaths.snapshotStream(service, docPath, snapshotKey)}` const stream = new DurableStream({ url: snapshotUrl, headers: dsHeaders, contentType: `application/octet-stream`, }) try { const response = await stream.stream({ offset: `-1` }) const snapshot = await response.body() if (snapshot.length > 0) { Y.applyUpdate(doc, snapshot) } } catch (err) { if (!isNotFoundError(err)) { throw err } } } // Load updates since the current snapshot (or from start if none) // This avoids reapplying full history on each compaction. // When a snapshot exists, read from the offset after the snapshot // (snapshot was built from data up to and including snapshotOffset). const updatesUrl = `${dsServerUrl}${YjsStreamPaths.dsStream(service, docPath)}` const updatesStream = new DurableStream({ url: updatesUrl, headers: dsHeaders, contentType: `application/octet-stream`, }) const updatesOffset = state.snapshotOffset ? incrementOffset(state.snapshotOffset) : `-1` let currentEndOffset = state.snapshotOffset ?? `-1` try { const response = await updatesStream.stream({ offset: updatesOffset, }) const updatesData = await response.body() if (updatesData.length > 0) { // Updates are stored with lib0 framing const decoder = decoding.createDecoder(updatesData) while (decoding.hasContent(decoder)) { const update = decoding.readVarUint8Array(decoder) Y.applyUpdate(doc, update) } } // Store the full offset string from the server currentEndOffset = response.offset } catch (err) { if (isNotFoundError(err)) { console.error( `[Compactor] Updates stream not found for ${service}/${docPath} during compaction` ) } throw err } // Encode the current state as a snapshot const newSnapshot = Y.encodeStateAsUpdate(doc) // Create new snapshot storage with offset-based key const snapshotKey = YjsStreamPaths.snapshotKey(currentEndOffset) const newSnapshotUrl = `${dsServerUrl}${YjsStreamPaths.snapshotStream(service, docPath, snapshotKey)}` const snapshotStream = await DurableStream.create({ url: newSnapshotUrl, headers: dsHeaders, contentType: `application/octet-stream`, }) await snapshotStream.append(newSnapshot, { contentType: `application/octet-stream`, }) const oldSnapshotOffset = state.snapshotOffset // Write to internal index stream to persist the snapshot offset await this.writeIndexEntry(service, docPath, currentEndOffset) // Update in-memory snapshot offset this.server.updateSnapshotOffset(service, docPath, currentEndOffset) // Reset counters this.server.resetUpdateCounters(service, docPath) // Delete old snapshot if any if (oldSnapshotOffset) { this.deleteOldSnapshot(service, docPath, oldSnapshotOffset).catch( (err) => { console.error( `[Compactor] Error deleting old snapshot for ${service}/${docPath}:`, err ) } ) } const result: CompactionResult = { snapshotOffset: currentEndOffset, snapshotSizeBytes: newSnapshot.length, oldSnapshotOffset, } console.log( `[Compactor] Compacted ${service}/${docPath}: ` + `snapshot=${newSnapshot.length} bytes, ` + `offset=${currentEndOffset}` ) return result } finally { doc.destroy() } } /** * Write a new entry to the internal index stream. * This persists the snapshot offset so it survives server restarts. */ private async writeIndexEntry( service: string, docPath: string, snapshotOffset: string ): Promise<void> { const indexPath = YjsStreamPaths.indexStream(service, docPath) const indexEntry: YjsIndexEntry = { snapshotOffset, createdAt: Date.now(), } await this.server.appendToIndexStream(indexPath, indexEntry) } /** * Delete old snapshot. */ private async deleteOldSnapshot( service: string, docPath: string, snapshotOffset: string ): Promise<void> { const dsServerUrl = this.server.getDsServerUrl() const dsHeaders = this.server.getDsServerHeaders() const snapshotKey = YjsStreamPaths.snapshotKey(snapshotOffset) const snapshotUrl = `${dsServerUrl}${YjsStreamPaths.snapshotStream(service, docPath, snapshotKey)}` try { await DurableStream.delete({ url: snapshotUrl, headers: dsHeaders }) } catch (err) { if (!isNotFoundError(err)) { throw err } } } } /** * Increment an offset string by 1 in the sequence portion. * Offsets are formatted as "{timestamp}_{sequence}" with zero-padded parts. */ function incrementOffset(offset: string): string { const parts = offset.split(`_`) if (parts.length !== 2) return offset const seq = parseInt(parts[1]!, 10) if (isNaN(seq)) return offset const nextSeq = (seq + 1).toString().padStart(parts[1]!.length, `0`) return `${parts[0]}_${nextSeq}` } /** * Frame a Yjs update with lib0 encoding for storage. */ export function frameUpdate(update: Uint8Array): Uint8Array { const encoder = encoding.createEncoder() encoding.writeVarUint8Array(encoder, update) return encoding.toUint8Array(encoder) } /** * Decode lib0-framed updates from a binary stream. */ export function decodeUpdates(data: Uint8Array): Array<Uint8Array> { const updates: Array<Uint8Array> = [] if (data.length === 0) return updates const decoder = decoding.createDecoder(data) while (decoding.hasContent(decoder)) { updates.push(decoding.readVarUint8Array(decoder)) } return updates }