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>
136 lines
6.7 KiB
TypeScript
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);
|
|
});
|
|
});
|