|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 5 | +import * as Y from 'yjs' |
| 6 | + |
| 7 | +/** |
| 8 | + * One shared in-memory Redis backing per test, so several {@link FileDocStore} instances (modelling |
| 9 | + * several ECS tasks) all talk to the "same Redis". A minimal fake of just the stream/lock ops the |
| 10 | + * store uses. |
| 11 | + */ |
| 12 | +interface Backing { |
| 13 | + streams: Map<string, { id: string; message: Record<string, string> }[]> |
| 14 | + kv: Map<string, string> |
| 15 | + seq: number |
| 16 | +} |
| 17 | + |
| 18 | +const state = vi.hoisted(() => ({ backing: null as Backing | null })) |
| 19 | + |
| 20 | +const seqOf = (id: string) => Number(id.split('-')[0]) |
| 21 | + |
| 22 | +function makeClient(): any { |
| 23 | + const b = () => { |
| 24 | + if (!state.backing) throw new Error('backing not initialized') |
| 25 | + return state.backing |
| 26 | + } |
| 27 | + const client: any = { |
| 28 | + connect: async () => {}, |
| 29 | + quit: async () => {}, |
| 30 | + on: () => client, |
| 31 | + duplicate: () => makeClient(), |
| 32 | + xAdd: async (key: string, _star: string, fields: Record<string, string>) => { |
| 33 | + const id = `${++b().seq}-0` |
| 34 | + const arr = b().streams.get(key) ?? [] |
| 35 | + arr.push({ id, message: { ...fields } }) |
| 36 | + b().streams.set(key, arr) |
| 37 | + return id |
| 38 | + }, |
| 39 | + xRange: async (key: string) => (b().streams.get(key) ?? []).map((e) => ({ ...e })), |
| 40 | + xLen: async (key: string) => (b().streams.get(key) ?? []).length, |
| 41 | + xTrim: async (key: string, _strategy: string, minid: string) => { |
| 42 | + const arr = b().streams.get(key) ?? [] |
| 43 | + b().streams.set( |
| 44 | + key, |
| 45 | + arr.filter((e) => seqOf(e.id) >= seqOf(minid)) |
| 46 | + ) |
| 47 | + }, |
| 48 | + xRead: async (streams: { key: string; id: string }[]) => { |
| 49 | + const res: { name: string; messages: { id: string; message: Record<string, string> }[] }[] = |
| 50 | + [] |
| 51 | + for (const { key, id } of streams) { |
| 52 | + const after = (b().streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id)) |
| 53 | + if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) }) |
| 54 | + } |
| 55 | + if (res.length) return res |
| 56 | + await new Promise((r) => setTimeout(r, 5)) |
| 57 | + return null |
| 58 | + }, |
| 59 | + set: async (key: string, val: string, opts?: { NX?: boolean }) => { |
| 60 | + if (opts?.NX && b().kv.has(key)) return null |
| 61 | + b().kv.set(key, val) |
| 62 | + return 'OK' |
| 63 | + }, |
| 64 | + del: async (key: string) => { |
| 65 | + b().kv.delete(key) |
| 66 | + return 1 |
| 67 | + }, |
| 68 | + expire: async () => 1, |
| 69 | + } |
| 70 | + return client |
| 71 | +} |
| 72 | + |
| 73 | +vi.mock('redis', () => ({ createClient: () => makeClient() })) |
| 74 | + |
| 75 | +import { FileDocStore } from '@/handlers/file-doc-store' |
| 76 | + |
| 77 | +const REDIS_URL = 'redis://fake' |
| 78 | +const NAME = 'workspace-file-doc:file-1' |
| 79 | + |
| 80 | +function docWithText(text: string): Y.Doc { |
| 81 | + const doc = new Y.Doc() |
| 82 | + doc.getText('body').insert(0, text) |
| 83 | + return doc |
| 84 | +} |
| 85 | + |
| 86 | +/** The delta a doc emits when `text` is inserted — what the relay would `publish`. */ |
| 87 | +function updateFor(text: string): Uint8Array { |
| 88 | + const doc = docWithText(text) |
| 89 | + const update = Y.encodeStateAsUpdate(doc) |
| 90 | + doc.destroy() |
| 91 | + return update |
| 92 | +} |
| 93 | + |
| 94 | +let stores: FileDocStore[] = [] |
| 95 | +async function newStore(): Promise<FileDocStore> { |
| 96 | + const store = new FileDocStore(REDIS_URL) |
| 97 | + await store.init() |
| 98 | + stores.push(store) |
| 99 | + return store |
| 100 | +} |
| 101 | + |
| 102 | +describe('FileDocStore', () => { |
| 103 | + beforeEach(() => { |
| 104 | + state.backing = { streams: new Map(), kv: new Map(), seq: 0 } |
| 105 | + stores = [] |
| 106 | + }) |
| 107 | + |
| 108 | + afterEach(async () => { |
| 109 | + await Promise.all(stores.map((s) => s.shutdown())) |
| 110 | + }) |
| 111 | + |
| 112 | + it('elects exactly one seeder across tasks (no split-brain seed)', async () => { |
| 113 | + const a = await newStore() |
| 114 | + const b = await newStore() |
| 115 | + const [aWon, bWon] = await Promise.all([a.shouldSeed(NAME), b.shouldSeed(NAME)]) |
| 116 | + expect([aWon, bWon].filter(Boolean)).toHaveLength(1) |
| 117 | + }) |
| 118 | + |
| 119 | + it('does not re-seed once the stream already has content (stale lock)', async () => { |
| 120 | + const a = await newStore() |
| 121 | + expect(await a.shouldSeed(NAME)).toBe(true) |
| 122 | + // A seeds and releases its lock. |
| 123 | + a.publish(NAME, updateFor('hello')) |
| 124 | + await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull()) |
| 125 | + await a.releaseSeedLock(NAME) |
| 126 | + // A different task must NOT seed again — the lock is free but the stream is non-empty. |
| 127 | + const b = await newStore() |
| 128 | + expect(await b.shouldSeed(NAME)).toBe(false) |
| 129 | + }) |
| 130 | + |
| 131 | + it('getStreamState reconstructs the shared document from the stream', async () => { |
| 132 | + const a = await newStore() |
| 133 | + a.publish(NAME, updateFor('shared content')) |
| 134 | + let state: Uint8Array | null = null |
| 135 | + await vi.waitFor(async () => { |
| 136 | + state = await a.getStreamState(NAME) |
| 137 | + expect(state).not.toBeNull() |
| 138 | + }) |
| 139 | + const doc = new Y.Doc() |
| 140 | + Y.applyUpdate(doc, state!) |
| 141 | + expect(doc.getText('body').toString()).toBe('shared content') |
| 142 | + doc.destroy() |
| 143 | + }) |
| 144 | + |
| 145 | + it('attachRoom catches a fresh task up to the current shared state', async () => { |
| 146 | + const a = await newStore() |
| 147 | + a.publish(NAME, updateFor('already here')) |
| 148 | + await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull()) |
| 149 | + |
| 150 | + // A second task opens the same file: its doc must load the existing content, not start empty. |
| 151 | + const b = await newStore() |
| 152 | + const doc = new Y.Doc() |
| 153 | + await b.attachRoom(NAME, doc) |
| 154 | + expect(doc.getText('body').toString()).toBe('already here') |
| 155 | + doc.destroy() |
| 156 | + }) |
| 157 | + |
| 158 | + it('converges a peer task via the tailer after attach', async () => { |
| 159 | + const a = await newStore() |
| 160 | + const b = await newStore() |
| 161 | + const bDoc = new Y.Doc() |
| 162 | + await b.attachRoom(NAME, bDoc) |
| 163 | + |
| 164 | + // A publishes an edit; B's multiplexed reader must apply it to B's attached doc. |
| 165 | + a.publish(NAME, updateFor('from task A')) |
| 166 | + await vi.waitFor(() => expect(bDoc.getText('body').toString()).toBe('from task A'), { |
| 167 | + timeout: 2000, |
| 168 | + }) |
| 169 | + bDoc.destroy() |
| 170 | + }) |
| 171 | + |
| 172 | + it('compaction never trims peer entries the compacting task has not yet integrated', async () => { |
| 173 | + const streamKey = `filedoc:stream:${NAME}` |
| 174 | + const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64') |
| 175 | + |
| 176 | + // Two peer edits published by ANOTHER task that this task's tailer has not read yet. |
| 177 | + const peerDoc = new Y.Doc() |
| 178 | + const peerUpdates: Uint8Array[] = [] |
| 179 | + peerDoc.on('update', (u: Uint8Array) => peerUpdates.push(u)) |
| 180 | + peerDoc.getText('body').insert(0, 'PEER1') |
| 181 | + peerDoc.getText('body').insert(5, 'PEER2') |
| 182 | + |
| 183 | + // Backing: 400 already-integrated (no-op) entries this task's doc reflects, then the 2 un-integrated |
| 184 | + // peer entries. Enough entries to cross COMPACT_THRESHOLD. |
| 185 | + const entries = Array.from({ length: 400 }, (_, i) => ({ |
| 186 | + id: `${i + 1}-0`, |
| 187 | + message: { u: noop }, |
| 188 | + })) |
| 189 | + entries.push({ id: '401-0', message: { u: Buffer.from(peerUpdates[0]).toString('base64') } }) |
| 190 | + entries.push({ id: '402-0', message: { u: Buffer.from(peerUpdates[1]).toString('base64') } }) |
| 191 | + state.backing!.streams.set(streamKey, entries) |
| 192 | + state.backing!.seq = 402 |
| 193 | + |
| 194 | + const a = await newStore() |
| 195 | + // This task has integrated only up to entry 400 (all no-ops) — its local doc is empty and lags the |
| 196 | + // two peer entries. Inject that lagging room directly. |
| 197 | + ;(a as any).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0 }) |
| 198 | + await (a as any).maybeCompact(NAME) |
| 199 | + |
| 200 | + // A fresh catch-up must still reconstruct the peer content — compaction must not have trimmed 401/402. |
| 201 | + const doc = new Y.Doc() |
| 202 | + Y.applyUpdate(doc, (await a.getStreamState(NAME))!) |
| 203 | + expect(doc.getText('body').toString()).toBe('PEER1PEER2') |
| 204 | + doc.destroy() |
| 205 | + }) |
| 206 | + |
| 207 | + it('serializes merges across tasks via the merge lock', async () => { |
| 208 | + const a = await newStore() |
| 209 | + const b = await newStore() |
| 210 | + expect(await a.acquireMergeSlot(NAME, 5_000)).toBe(true) |
| 211 | + // A holds it → B is refused until A releases. |
| 212 | + expect(await b.acquireMergeSlot(NAME, 5_000)).toBe(false) |
| 213 | + await a.releaseMergeSlot(NAME) |
| 214 | + expect(await b.acquireMergeSlot(NAME, 5_000)).toBe(true) |
| 215 | + await b.releaseMergeSlot(NAME) |
| 216 | + }) |
| 217 | + |
| 218 | + it('is disabled without a REDIS_URL and behaves single-replica', async () => { |
| 219 | + const store = new FileDocStore(undefined) |
| 220 | + expect(store.enabled).toBe(false) |
| 221 | + // Seeds locally (returns true), never touches a stream. |
| 222 | + expect(await store.shouldSeed(NAME)).toBe(true) |
| 223 | + expect(await store.getStreamState(NAME)).toBeNull() |
| 224 | + const doc = new Y.Doc() |
| 225 | + await store.attachRoom(NAME, doc) // no-op, no throw |
| 226 | + expect(doc.getText('body').toString()).toBe('') |
| 227 | + doc.destroy() |
| 228 | + }) |
| 229 | +}) |
0 commit comments