Files
mdg-filius/packages/editor/test/remoteBlobs.test.ts
T
julian.kiedaischandClaude Opus 5.5 4b33e36580 feat: F5.5 large binary files as blobs on server F
From 16 KB a picture's or binary file's entry names its bytes by their
SHA-256 instead of holding them; the bytes are on F, content-addressed
(GET/POST /blobs with the session's grant, from the editor's origin, at
most 16 MiB). A document's blob side says where they are: the editor's
fetches and uploads, the room thread's asks F's main thread, and a start
waits for the ones asked for; without a side everything stays in the
document. Text stays Y.Text. A blob whose bytes are not there is never
written back empty. A blob F has not got yet is asked for again a few
times, and a running room reads late bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:46:50 +00:00

136 lines
6.7 KiB
TypeScript

// The editor's blob side on F (remoteBlobs), on a fetch the test answers: a
// blob made here is kept at once and taken to F with the cookie, tried
// again once, then told as failed (also when F names it otherwise); the
// blobs a document names are fetched once each and told as they come; one F
// has not got is not asked for again, one the network lost is.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { attachBlobs, BLOB_THRESHOLD_BYTES, blobSha, changeFiles, emptyProject, memoryBlobSide, missingBlobs, ROOTS, writeProject } from '@mdg-filius/doc';
import { BLOB_ASK_AGAIN_MILLISECONDS, BLOB_ASKS, remoteBlobs, UPLOAD_RETRY_MILLISECONDS } from '../src/remote/remoteBlobs.js';
const picture = (seed: number) => Uint8Array.from({ length: BLOB_THRESHOLD_BYTES }, (_, index) => (index + seed) % 256);
interface Call {
readonly url: string;
readonly init: RequestInit | undefined;
}
function fakeFetch(answer: (call: Call) => Response | Promise<Response>) {
const calls: Call[] = [];
const fetch = (async (url: string, init?: RequestInit) => {
const call = { url, init };
calls.push(call);
return answer(call);
}) as typeof globalThis.fetch;
return { calls, fetch };
}
/** A document naming these pictures as blobs on a computer, made with a side of its own. */
function docNaming(...pictures: Uint8Array[]): Y.Doc {
const made = new Y.Doc();
attachBlobs(made, memoryBlobSide());
writeProject(made, emptyProject());
made.transact(() => made.getMap<Y.Map<unknown>>(ROOTS.nodes).set('pc', new Y.Map()));
changeFiles(made, 'pc', 'person', (files) => pictures.forEach((each, index) => files.writeImageFile(`/p${index}.png`, each, 'image/png')));
const copy = new Y.Doc();
Y.applyUpdate(copy, Y.encodeStateAsUpdate(made));
return copy;
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("the editor's blobs on F", () => {
it('keeps a blob at once and takes it to F with the cookie', async () => {
const bytes = picture(1);
const { calls, fetch } = fakeFetch(() => Response.json({ sha: blobSha(bytes) }, { status: 201 }));
const failed: string[] = [];
const side = remoteBlobs({ server: 'https://f.test/', project: 'p 1', fetch, onFailure: (sha) => failed.push(sha) });
side.keep(blobSha(bytes), bytes);
expect(side.bytes(blobSha(bytes))).toBe(bytes);
await vi.runAllTimersAsync();
expect(calls.map(({ url, init }) => [url, init?.method, init?.credentials, init?.body])).toEqual([['https://f.test/blobs?project=p%201', 'POST', 'include', bytes]]);
expect(failed).toEqual([]);
// Kept once: the same again goes nowhere.
side.keep(blobSha(bytes), bytes);
await vi.runAllTimersAsync();
expect(calls).toHaveLength(1);
});
it('tries again once after a while, then says it failed — also when F names the bytes otherwise', async () => {
for (const answer of [() => new Response('', { status: 500 }), () => Response.json({ sha: 'other' }), () => Promise.reject(new Error('offline'))]) {
const bytes = picture(2);
const { calls, fetch } = fakeFetch(answer);
const failed: string[] = [];
remoteBlobs({ server: 'https://f.test', project: 'p', fetch, onFailure: (sha) => failed.push(sha) }).keep(blobSha(bytes), bytes);
await vi.advanceTimersByTimeAsync(UPLOAD_RETRY_MILLISECONDS - 1);
expect(calls).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
await vi.runAllTimersAsync();
expect([calls.length, failed]).toEqual([2, [blobSha(bytes)]]);
}
// A second try that works says nothing.
const bytes = picture(3);
let tries = 0;
const { fetch } = fakeFetch(() => (++tries === 1 ? new Response('', { status: 503 }) : Response.json({ sha: blobSha(bytes) })));
const failed: string[] = [];
remoteBlobs({ server: 'https://f.test', project: 'p', fetch, onFailure: (sha) => failed.push(sha) }).keep(blobSha(bytes), bytes);
await vi.runAllTimersAsync();
expect([tries, failed]).toEqual([2, []]);
});
it('fetches the blobs a document names once each, and tells when they came', async () => {
const [first, second] = [picture(4), picture(5)];
const doc = docNaming(first, second);
const { calls, fetch } = fakeFetch(({ url }) => new Response(url.includes(blobSha(first)) ? first : second));
const side = remoteBlobs({ server: 'https://f.test', project: 'p', fetch });
attachBlobs(doc, side);
let heard = 0;
side.onArrive(() => (heard += 1));
expect(missingBlobs(doc).sort()).toEqual([blobSha(first), blobSha(second)].sort());
await Promise.all([side.fetchMissing(doc), side.fetchMissing(doc)]);
expect(calls.map(({ url, init }) => [url, init?.credentials]).sort()).toEqual(
[
[`https://f.test/blobs/${blobSha(first)}?project=p`, 'include'],
[`https://f.test/blobs/${blobSha(second)}?project=p`, 'include'],
].sort(),
);
expect([missingBlobs(doc), heard]).toEqual([[], 1]);
expect(side.bytes(blobSha(first))).toEqual(first);
});
it('asks again a few times, a while apart, for a blob F has not got yet; for one the network lost with the next change', async () => {
const [late, gone, lost] = [picture(6), picture(7), picture(8)];
const doc = docNaming(late, gone, lost);
let offline = true;
let lateAsked = 0;
const { calls, fetch } = fakeFetch(({ url }) => {
if (url.includes(blobSha(late))) return ++lateAsked < 3 ? new Response('', { status: 404 }) : new Response(late);
if (url.includes(blobSha(gone))) return new Response('', { status: 404 });
if (offline) return Promise.reject(new Error('offline'));
return new Response(lost);
});
const side = remoteBlobs({ server: 'https://f.test', project: 'p', fetch });
attachBlobs(doc, side);
await side.fetchMissing(doc);
offline = false;
// Without a change, only what F has not got is asked for again.
await vi.advanceTimersByTimeAsync(BLOB_ASK_AGAIN_MILLISECONDS - 1);
expect(calls).toHaveLength(3);
await vi.advanceTimersByTimeAsync(1);
await vi.runAllTimersAsync();
expect(side.bytes(blobSha(late))).toEqual(late);
expect(calls.filter(({ url }) => url.includes(blobSha(late)))).toHaveLength(3);
expect(calls.filter(({ url }) => url.includes(blobSha(gone)))).toHaveLength(BLOB_ASKS);
// The one the network lost comes with the next change (its fetch then asks for what is still missing).
await side.fetchMissing(doc);
expect(side.bytes(blobSha(lost))).toEqual(lost);
expect(missingBlobs(doc)).toEqual([blobSha(gone)]);
// Given up on: not asked for again.
await side.fetchMissing(doc);
await vi.runAllTimersAsync();
expect(calls.filter(({ url }) => url.includes(blobSha(gone)))).toHaveLength(BLOB_ASKS);
});
});