snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CloudDocStorage broadcast updates > awareness joins active documents through the batch route 1`] = `
[
{
"event": "space:join-batch",
"payload": {
"clientVersion": "0.27.5",
"spaces": [
{
"docId": "doc-a",
"spaceId": "space-1",
"spaceType": "workspace",
},
{
"docId": "doc-b",
"spaceId": "space-1",
"spaceType": "workspace",
},
],
},
},
{
"event": "space:load-awarenesses",
"payload": {
"docId": "doc-a",
"spaceId": "space-1",
"spaceType": "workspace",
},
},
{
"event": "space:load-awarenesses",
"payload": {
"docId": "doc-b",
"spaceId": "space-1",
"spaceType": "workspace",
},
},
]
`;
exports[`CloudDocStorage broadcast updates > batch route joins the workspace and applies lifecycle commands 1`] = `
{
"emitted": [
{
"event": "space:join-batch",
"payload": {
"clientVersion": "0.27.5",
"spaces": [
{
"docId": "space-1",
"spaceId": "space-1",
"spaceType": "workspace",
},
],
},
},
{
"event": "space:load-doc",
"payload": {
"docId": "space-1",
"spaceId": "space-1",
"spaceType": "workspace",
},
},
{
"event": "space:join-batch",
"payload": {
"clientVersion": "0.27.5",
"spaces": [
{
"docId": "doc-1",
"spaceId": "space-1",
"spaceType": "workspace",
},
],
},
},
{
"event": "space:load-doc",
"payload": {
"docId": "doc-1",
"spaceId": "space-1",
"spaceType": "workspace",
},
},
{
"event": "space:join-batch",
"payload": {
"clientVersion": "0.27.5",
"spaces": [
{
"docId": "doc-2",
"spaceId": "space-1",
"spaceType": "workspace",
},
],
},
},
{
"event": "space:load-doc",
"payload": {
"docId": "doc-2",
"spaceId": "space-1",
"spaceType": "workspace",
"stateVector": undefined,
},
},
{
"event": "space:join-batch",
"payload": {
"clientVersion": "0.27.5",
"spaces": [
{
"docId": "doc-3",
"spaceId": "space-1",
"spaceType": "workspace",
},
],
},
},
{
"event": "space:load-doc",
"payload": {
"docId": "doc-3",
"spaceId": "space-1",
"spaceType": "workspace",
},
},
{
"event": "space:join-batch",
"payload": {
"clientVersion": "0.27.5",
"spaces": [
{
"docId": "doc-4",
"spaceId": "space-1",
"spaceType": "workspace",
},
],
},
},
{
"event": "space:push-doc-update",
"payload": {
"docId": "doc-4",
"spaceId": "space-1",
"spaceType": "workspace",
"update": "AAA=",
},
},
{
"event": "space:doc-lifecycle",
"payload": {
"docId": "doc-1",
"lifecycle": "trash",
"spaceId": "space-1",
"spaceType": "workspace",
},
},
{
"event": "space:join-batch",
"payload": {
"clientVersion": "0.27.5",
"spaces": [
{
"docId": "denied",
"spaceId": "space-1",
"spaceType": "workspace",
},
],
},
},
{
"event": "space:join-batch",
"payload": {
"clientVersion": "0.27.5",
"spaces": [
{
"docId": "doc-1",
"spaceId": "space-1",
"spaceType": "workspace",
},
],
},
},
{
"event": "space:load-doc",
"payload": {
"docId": "doc-1",
"spaceId": "space-1",
"spaceType": "workspace",
"stateVector": undefined,
},
},
{
"event": "space:leave-batch",
"payload": {
"docIds": [
"space-1",
"doc-1",
"doc-2",
"doc-3",
"doc-4",
],
"spaceId": "space-1",
"spaceType": "workspace",
},
},
{
"event": "space:leave-batch",
"payload": {
"docIds": [],
"spaceId": "space-1",
"spaceType": "workspace",
},
},
],
"lifecycle": {
"rootUpdate": Any<Uint8Array>,
"timestamp": Any<Date>,
},
}
`;

View File

@@ -0,0 +1,23 @@
import { bench, describe } from 'vitest';
import { base64ToUint8Array, uint8ArrayToBase64 } from '../impls/cloud/socket';
const data = new Uint8Array(1024 * 256);
for (let i = 0; i < data.length; i++) {
data[i] = i % 251;
}
let encoded = '';
await uint8ArrayToBase64(data).then(result => {
encoded = result;
});
describe('base64 helpers', () => {
bench('encode Uint8Array to base64', async () => {
await uint8ArrayToBase64(data);
});
bench('decode base64 to Uint8Array', () => {
base64ToUint8Array(encoded);
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { base64ToUint8Array, uint8ArrayToBase64 } from '../impls/cloud/socket';
function makeSample(size: number) {
const data = new Uint8Array(size);
for (let i = 0; i < size; i++) {
data[i] = i % 251;
}
return data;
}
describe('base64 helpers', () => {
test('roundtrip preserves data', async () => {
const input = makeSample(1024);
const encoded = await uint8ArrayToBase64(input);
const decoded = base64ToUint8Array(encoded);
expect(decoded).toEqual(input);
});
test('handles large payloads', async () => {
const input = makeSample(256 * 1024);
const encoded = await uint8ArrayToBase64(input);
const decoded = base64ToUint8Array(encoded);
expect(decoded).toEqual(input);
});
});

View File

@@ -0,0 +1,943 @@
import { getEventListeners } from 'node:events';
import { UserFriendlyError } from '@affine/error';
import {
abortBlobUploadMutation,
BlobUploadMethod,
completeBlobUploadMutation,
createBlobUploadMutation,
getBlobUploadPartUrlQuery,
listBlobsQuery,
setBlobMutation,
workspaceBlobQuotaQuery,
} from '@affine/graphql';
import { afterEach, expect, test, vi } from 'vitest';
import { CloudBlobStorage } from '../impls/cloud/blob';
import { BlobSourceRegistry } from '../impls/cloud/blob-source-registry';
import { OverSizeError } from '../storage';
const originalBuildConfig = globalThis.BUILD_CONFIG;
const quotaResponse = {
workspace: {
quota: {
humanReadable: {
blobLimit: '1 MB',
},
blobLimit: 1024 * 1024,
},
},
};
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
globalThis.BUILD_CONFIG = originalBuildConfig;
});
function createStorage() {
return new CloudBlobStorage({
serverBaseUrl: 'https://example.com',
id: 'workspace-1',
});
}
test('uses graphql upload when server returns GRAPHQL method', async () => {
const storage = createStorage();
const gqlMock = vi.fn(async ({ query }) => {
if (query === workspaceBlobQuotaQuery) {
return quotaResponse;
}
if (query === createBlobUploadMutation) {
return {
createBlobUpload: {
method: BlobUploadMethod.GRAPHQL,
blobKey: 'blob-key',
alreadyUploaded: false,
},
};
}
if (query === setBlobMutation) {
return { setBlob: 'blob-key' };
}
throw new Error('Unexpected query');
});
vi.spyOn(storage.connection, 'gql').mockImplementation(
gqlMock as typeof storage.connection.gql
);
await storage.set({
key: 'blob-key',
data: new Uint8Array([1, 2, 3]),
mime: 'text/plain',
});
const queries = gqlMock.mock.calls.map(call => call[0].query);
expect(queries).toContain(createBlobUploadMutation);
expect(queries).toContain(setBlobMutation);
});
test.each(
[BlobUploadMethod.PRESIGNED, BlobUploadMethod.MULTIPART].flatMap(method =>
['500', '413', 'complete-413', 'abort', 'abort-string', 'pre-abort'].map(
failure => ({ method, failure })
)
)
)(
'handles $method upload failure $failure without retrying terminal errors',
async ({ method, failure }) => {
const storage = createStorage();
const controller = new AbortController();
const reason =
failure === 'abort-string'
? 'caller canceled'
: new DOMException('caller canceled', 'AbortError');
const gqlMock = vi.fn(async ({ query, context }) => {
if (query === workspaceBlobQuotaQuery) return quotaResponse;
if (query === createBlobUploadMutation) {
return {
createBlobUpload: {
method,
blobKey: 'blob-key',
alreadyUploaded: false,
uploadUrl: 'https://upload.example.com/blob',
uploadId: 'upload-1',
partSize: 2,
uploadedParts: [],
},
};
}
if (query === getBlobUploadPartUrlQuery) {
return {
workspace: {
blobUploadPartUrl: { uploadUrl: 'https://upload.example.com/part' },
},
};
}
if (query === abortBlobUploadMutation) {
expect(context?.signal?.aborted).not.toBe(true);
if (failure === '413') throw new Error('cleanup failed');
return { abortBlobUpload: true };
}
if (query === setBlobMutation) return { setBlob: 'blob-key' };
if (query === completeBlobUploadMutation) {
throw new UserFriendlyError({
status: 413,
code: 'CONTENT_TOO_LARGE',
type: 'CONTENT_TOO_LARGE',
name: 'CONTENT_TOO_LARGE',
message: 'Content too large',
});
}
throw new Error('Unexpected query');
});
vi.spyOn(storage.connection, 'gql').mockImplementation(
gqlMock as typeof storage.connection.gql
);
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
expect(init?.signal).toBe(controller.signal);
if (failure.startsWith('abort')) {
controller.abort(reason);
throw init?.signal?.reason;
}
return new Response('', {
status: failure === 'complete-413' ? 200 : Number(failure),
headers: { etag: 'part-etag' },
});
});
vi.stubGlobal('fetch', fetchMock);
if (failure === 'pre-abort') controller.abort(reason);
const uploading = storage.set(
{
key: 'blob-key',
data: new Uint8Array([1, 2, 3]),
mime: 'text/plain',
},
controller.signal
);
if (failure === '500') {
await expect(uploading).resolves.toBeUndefined();
} else if (failure.includes('413')) {
await expect(uploading).rejects.toBeInstanceOf(OverSizeError);
} else {
await expect(uploading).rejects.toBe(reason);
}
const queries = new Set(gqlMock.mock.calls.map(call => call[0].query));
expect(queries.has(setBlobMutation)).toBe(failure === '500');
expect(queries.has(abortBlobUploadMutation)).toBe(
method === BlobUploadMethod.MULTIPART && failure !== 'pre-abort'
);
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0);
if (failure === 'pre-abort') {
expect(gqlMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
}
}
);
test('uses presigned upload and completes without graphql fallback', async () => {
const storage = createStorage();
const gqlMock = vi.fn(async ({ query }) => {
if (query === workspaceBlobQuotaQuery) {
return quotaResponse;
}
if (query === createBlobUploadMutation) {
return {
createBlobUpload: {
method: BlobUploadMethod.PRESIGNED,
blobKey: 'blob-key',
alreadyUploaded: false,
uploadUrl: 'https://upload.example.com/blob',
},
};
}
if (query === completeBlobUploadMutation) {
return { completeBlobUpload: 'blob-key' };
}
throw new Error('Unexpected query');
});
vi.spyOn(storage.connection, 'gql').mockImplementation(
gqlMock as typeof storage.connection.gql
);
const fetchMock = vi.fn(async () => new Response('', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await storage.set({
key: 'blob-key',
data: new Uint8Array([1, 2, 3]),
mime: 'text/plain',
});
const queries = gqlMock.mock.calls.map(call => call[0].query);
expect(queries).toContain(completeBlobUploadMutation);
expect(queries).not.toContain(setBlobMutation);
expect(fetchMock).toHaveBeenCalledWith(
'https://upload.example.com/blob',
expect.objectContaining({
method: 'PUT',
})
);
});
test('uses multipart upload and completes without graphql fallback', async () => {
const storage = createStorage();
const controller = new AbortController();
const gqlMock = vi.fn(async ({ query, variables }) => {
if (query === workspaceBlobQuotaQuery) {
return quotaResponse;
}
if (query === createBlobUploadMutation) {
return {
createBlobUpload: {
method: BlobUploadMethod.MULTIPART,
blobKey: 'blob-key',
alreadyUploaded: false,
uploadId: 'upload-1',
partSize: 2,
uploadedParts: [],
},
};
}
if (query === getBlobUploadPartUrlQuery) {
return {
workspace: {
blobUploadPartUrl: {
uploadUrl: `https://upload.example.com/part/${variables.partNumber}`,
},
},
};
}
if (query === completeBlobUploadMutation) {
return { completeBlobUpload: 'blob-key' };
}
throw new Error('Unexpected query');
});
vi.spyOn(storage.connection, 'gql').mockImplementation(
gqlMock as typeof storage.connection.gql
);
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
expect(init?.signal).toBe(controller.signal);
const body = init?.body as ArrayBuffer;
const length = body.byteLength;
return new Response('', {
status: 200,
headers: {
etag: `etag-${length}`,
},
});
});
vi.stubGlobal('fetch', fetchMock);
await storage.set(
{
key: 'blob-key',
data: new Uint8Array([1, 2, 3]),
mime: 'text/plain',
},
controller.signal
);
const queries = gqlMock.mock.calls.map(call => call[0].query);
expect(queries).toContain(getBlobUploadPartUrlQuery);
expect(queries).toContain(completeBlobUploadMutation);
expect(queries).not.toContain(setBlobMutation);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0);
});
test.each(['success', 'failure', 'abort', 'pre-abort', 'timeout'])(
'blob HTTP transport preserves cancellation and releases timers on %s',
async outcome => {
vi.useFakeTimers();
const storage = createStorage();
const controller = new AbortController();
const reason = new DOMException('caller canceled', 'AbortError');
const fetchMock = vi.fn(async (_input: URL, init?: RequestInit) => {
if (outcome === 'success') return new Response('ok');
if (outcome === 'failure') throw new Error('offline');
const signal = init!.signal!;
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
});
});
vi.stubGlobal('fetch', fetchMock);
if (outcome === 'pre-abort') controller.abort(reason);
const pending = storage.connection.fetch('/blob', {
signal: controller.signal,
timeout: 1000,
});
const result = pending.then(
value => value,
error => error
);
if (outcome === 'abort') controller.abort(reason);
if (outcome === 'timeout') await vi.advanceTimersByTimeAsync(1000);
const settled = await result;
if (outcome === 'success') {
expect(settled).toBeInstanceOf(Response);
} else if (outcome === 'abort' || outcome === 'pre-abort') {
expect(settled).toBe(reason);
} else {
expect(UserFriendlyError.fromAny(settled).is('NETWORK_ERROR')).toBe(true);
}
expect(vi.getTimerCount()).toBe(0);
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0);
if (outcome === 'pre-abort') expect(fetchMock).not.toHaveBeenCalled();
}
);
test('downloads only through registered source-scoped V1 entries', async () => {
const storage = createStorage();
await expect(storage.get('blob-key')).rejects.toThrow(
'Blob source context is required'
);
const first = {
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId: 'doc-1',
};
const second = {
type: 'history' as const,
workspaceId: 'workspace-1',
docId: 'doc-2',
timestampMs: 1720000000000,
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [
{ key: 'blob-key', mime: 'text/plain', size: 9, source: first },
],
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
}
)
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [
{ key: 'blob-key', mime: 'text/plain', size: 9, source: second },
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
status: 403,
code: 'ACTION_FORBIDDEN',
type: 'ACTION_FORBIDDEN',
name: 'DOC_ACTION_DENIED',
message: 'denied',
}),
{ status: 403, headers: { 'content-type': 'application/json' } }
)
)
.mockResolvedValueOnce(
new Response('blob-data', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
)
.mockResolvedValueOnce(
new Response('blob-data', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
);
vi.stubGlobal('fetch', fetchMock);
await storage.registerSource(first);
await storage.registerSource(second);
const blob = await storage.get('blob-key');
const retried = await storage.get('blob-key');
expect(blob?.data).toEqual(new TextEncoder().encode('blob-data'));
expect(retried?.data).toEqual(new TextEncoder().encode('blob-data'));
const urls = fetchMock.mock.calls.map(call => call[0]?.toString());
expect(urls).toMatchInlineSnapshot(`
[
"https://example.com/api/workspaces/workspace-1/blob-manifest/v1?sourceType=currentDoc&docId=doc-1",
"https://example.com/api/workspaces/workspace-1/blob-manifest/v1?sourceType=history&docId=doc-2&timestampMs=1720000000000",
"https://example.com/api/workspaces/workspace-1/blobs/v1/blob-key?sourceType=currentDoc&docId=doc-1",
"https://example.com/api/workspaces/workspace-1/blobs/v1/blob-key?sourceType=history&docId=doc-2&timestampMs=1720000000000",
"https://example.com/api/workspaces/workspace-1/blobs/v1/blob-key?sourceType=history&docId=doc-2&timestampMs=1720000000000",
]
`);
expect(urls.some(url => url?.includes('redirect=manual'))).toBe(false);
await Promise.all([
storage.unregisterSource(first),
storage.unregisterSource(second),
]);
await expect(storage.get('blob-key', undefined, second)).rejects.toThrow(
'Blob source context is required'
);
expect(fetchMock).toHaveBeenCalledTimes(5);
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [
{ key: 'failed-blob', mime: 'text/plain', size: 1, source: second },
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
.mockResolvedValueOnce(new Response('', { status: 500 }));
await expect(
(async () => {
await storage.registerSource(second);
try {
return await storage.get('failed-blob', undefined, second);
} finally {
await storage.unregisterSource(second);
}
})()
).rejects.toBeDefined();
await expect(storage.get('failed-blob', undefined, second)).rejects.toThrow(
'Blob source context is required'
);
expect(fetchMock.mock.calls.slice(-2).map(call => call[0]?.toString()))
.toMatchInlineSnapshot(`
[
"https://example.com/api/workspaces/workspace-1/blob-manifest/v1?sourceType=history&docId=doc-2&timestampMs=1720000000000",
"https://example.com/api/workspaces/workspace-1/blobs/v1/failed-blob?sourceType=history&docId=doc-2&timestampMs=1720000000000",
]
`);
fetchMock
.mockResolvedValueOnce(new Response('', { status: 500 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [
{
key: 'recovered-blob',
mime: 'text/plain',
size: 9,
source: second,
},
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
.mockResolvedValueOnce(
new Response('recovered', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
);
await expect(storage.registerSource(second)).rejects.toBeDefined();
const recovered = await storage.get('recovered-blob', undefined, second);
expect({
data: recovered && new TextDecoder().decode(recovered.data),
urls: fetchMock.mock.calls.slice(-3).map(call => call[0]?.toString()),
}).toMatchInlineSnapshot(`
{
"data": "recovered",
"urls": [
"https://example.com/api/workspaces/workspace-1/blob-manifest/v1?sourceType=history&docId=doc-2&timestampMs=1720000000000",
"https://example.com/api/workspaces/workspace-1/blob-manifest/v1?sourceType=history&docId=doc-2&timestampMs=1720000000000",
"https://example.com/api/workspaces/workspace-1/blobs/v1/recovered-blob?sourceType=history&docId=doc-2&timestampMs=1720000000000",
],
}
`);
await storage.unregisterSource(second);
fetchMock.mockImplementation(
async () =>
new Response(JSON.stringify({ version: 1, entries: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
await storage.registerSource(first);
await expect(storage.get('absent')).resolves.toBeNull();
await expect(storage.get('absent', undefined, first)).resolves.toBeNull();
await expect(storage.get('absent', undefined, second)).rejects.toThrow(
'Blob source context is required'
);
fetchMock.mockRejectedValueOnce(new Error('manifest offline'));
await expect(storage.get('absent', undefined, first)).rejects.toThrow(
'manifest offline'
);
const canceled = new AbortController();
canceled.abort(new DOMException('canceled', 'AbortError'));
const requests = fetchMock.mock.calls.length;
await expect(storage.get('absent', canceled.signal, first)).rejects.toBe(
canceled.signal.reason
);
expect(fetchMock).toHaveBeenCalledTimes(requests);
await storage.unregisterSource(first);
});
test('keeps an unregistered source denied across an in-flight download', async () => {
const storage = createStorage();
const source = {
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId: 'doc-1',
};
let releaseDownload!: (response: Response) => void;
const download = new Promise<Response>(resolve => {
releaseDownload = resolve;
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [{ key: 'stale', mime: 'text/plain', size: 1, source }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
.mockReturnValueOnce(download);
vi.stubGlobal('fetch', fetchMock);
await storage.registerSource(source);
const reading = storage.get('stale');
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
await storage.unregisterSource(source);
releaseDownload(
new Response('stale-bytes', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
);
await expect(reading).resolves.toBeNull();
await expect(storage.get('stale')).rejects.toThrow(
'Blob source context is required'
);
expect(fetchMock).toHaveBeenCalledTimes(2);
let releaseRefresh!: (response: Response) => void;
const refreshingManifest = new Promise<Response>(resolve => {
releaseRefresh = resolve;
});
fetchMock
.mockReturnValueOnce(refreshingManifest)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [{ key: 'fresh-key', mime: 'text/plain', size: 5, source }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
.mockResolvedValueOnce(
new Response('fresh', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
);
const refresh = storage.registerSource(source);
const dirtyRefresh = storage.registerSource(source);
releaseRefresh(
new Response(
JSON.stringify({
version: 1,
entries: [{ key: 'stale', mime: 'text/plain', size: 1, source }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
);
await Promise.all([refresh, dirtyRefresh]);
await expect(storage.get('fresh-key')).resolves.toMatchObject({
data: new TextEncoder().encode('fresh'),
});
expect(fetchMock).toHaveBeenCalledTimes(5);
});
test('pages readable workspace sources without workspace inventory fallback', async () => {
const storage = createStorage();
const source = (docId: string) => ({
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId,
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ version: 1, entries: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [
{ key: 'one', mime: 'text/plain', size: 1, source: source('a') },
],
nextCursor: 'a',
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
.mockResolvedValueOnce(
new Response('1', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [
{ key: 'two', mime: 'text/plain', size: 1, source: source('b') },
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
);
vi.stubGlobal('fetch', fetchMock);
await storage.registerSource(source('a'));
const entries = storage.readableSources()[Symbol.asyncIterator]();
await expect(entries.next()).resolves.toMatchObject({
value: { key: 'one' },
done: false,
});
await storage.unregisterSource(source('a'));
expect(fetchMock).toHaveBeenCalledTimes(2);
await expect(
storage.get('one', undefined, source('a'))
).resolves.toMatchObject({ key: 'one' });
await expect(entries.next()).resolves.toMatchObject({
value: { key: 'two' },
done: false,
});
expect(fetchMock).toHaveBeenCalledTimes(4);
await expect(entries.next()).resolves.toEqual({
value: undefined,
done: true,
});
expect(fetchMock.mock.calls[3]?.[0]?.toString()).toContain('cursor=a');
await expect(storage.get('two')).rejects.toThrow(
'Blob source context is required'
);
await expect(storage.list()).rejects.toThrow(
'Workspace blob inventory is unavailable'
);
});
test('uses the managed workspace inventory only through its typed query', async () => {
const storage = createStorage();
const gqlMock = vi.fn(async ({ query }) => {
expect(query).toBe(listBlobsQuery);
return {
workspace: {
blobs: [
{
key: 'managed',
mime: 'text/plain',
size: 7,
createdAt: '2026-08-29T00:00:00.000Z',
},
],
},
};
});
vi.spyOn(storage.connection, 'gql').mockImplementation(
gqlMock as typeof storage.connection.gql
);
expect(await storage.listManageable()).toEqual([
{
key: 'managed',
mime: 'text/plain',
size: 7,
createdAt: new Date('2026-08-29T00:00:00.000Z'),
},
]);
await expect(storage.list()).rejects.toThrow(
'Workspace blob inventory is unavailable'
);
});
test('releases transient workspace sources and preserves owned registrations', async () => {
const storage = createStorage();
const source = {
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId: 'doc-1',
};
const entry = { key: 'stable', mime: 'text/plain', size: 4, source };
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ version: 1, entries: [entry] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
version: 1,
entries: [{ key: 'partial', mime: 'text/plain', size: 1, source }],
nextCursor: 'next',
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
.mockResolvedValueOnce(new Response('', { status: 500 }))
.mockResolvedValueOnce(
new Response('keep', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ version: 1, entries: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
.mockResolvedValueOnce(
new Response('keep', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
);
vi.stubGlobal('fetch', fetchMock);
await storage.registerSource(source);
await expect(async () => {
for await (const _entry of storage.readableSources()) {
// Consume until the next page fails.
}
}).rejects.toThrow();
expect((await storage.get('stable'))?.data).toEqual(
new TextEncoder().encode('keep')
);
for await (const _entry of storage.readableSources()) {
// A complete empty workspace generation must also release its page state.
}
expect((await storage.get('stable'))?.data).toEqual(
new TextEncoder().encode('keep')
);
await storage.unregisterSource(source);
await expect(storage.get('stable')).rejects.toThrow(
'Blob source context is required'
);
const registry = new BlobSourceRegistry();
const sources = ['one', 'two', 'three'].map(docId => ({
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId,
}));
const record = (source: (typeof sources)[number]) => ({
key: source.docId,
mime: 'text/plain',
size: 1,
source,
});
registry.replaceOwned(sources[0], [record(sources[0])], {});
registry.replaceOwned(sources[1], [record(sources[1])], {});
registry.replaceOwned(sources[2], [record(sources[2])], {});
expect(
['one', 'two', 'three'].map(key =>
registry.candidates(key).map(candidate => candidate.source.docId)
)
).toEqual([['one'], ['two'], ['three']]);
for (let index = 0; index < 256; index++) {
const source = {
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId: `owned-${index}`,
};
registry.replaceOwned(source, [record(source)], {});
}
expect(registry.candidates('one')).toHaveLength(1);
registry.removeOwned(sources[1]);
expect(registry.candidates('two')).toEqual([]);
const released = new BlobSourceRegistry();
for (let index = 0; index < 1000; index++) {
const source = {
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId: `removed-${index}`,
};
released.replaceOwned(source, [record(source)], {});
released.removeOwned(source);
}
expect(
['removed-0', 'removed-999'].flatMap(key => released.candidates(key))
).toEqual([]);
const pendingStorage = createStorage();
const pendingSources = Array.from({ length: 33 }, (_, index) => ({
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId: `pending-${index}`,
}));
const pendingFetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
if (input.toString().includes('docId=pending-32')) {
return Promise.resolve(
new Response(JSON.stringify({ version: 1, entries: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
}
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true }
);
});
});
vi.stubGlobal('fetch', pendingFetch);
const pending = pendingSources
.slice(0, 32)
.map(source =>
pendingStorage.registerSource(source).catch((error: unknown) => error)
);
const dirty = pendingStorage
.registerSource(pendingSources[0])
.catch((error: unknown) => error);
const unregistering = pendingStorage.unregisterSource(pendingSources[0]);
await expect(
pendingStorage.registerSource(pendingSources[32])
).rejects.toThrow('Blob source registration budget exceeded');
await unregistering;
await pending[0];
await pendingStorage.registerSource(pendingSources[32]);
await dirty;
const fetchCount = pendingFetch.mock.calls.length;
await expect(
pendingStorage.get('unregistered', undefined, pendingSources[32])
).resolves.toBeNull();
expect(pendingFetch).toHaveBeenCalledTimes(fetchCount + 1);
await pendingStorage.unregisterSource(pendingSources[32]);
await Promise.all(
pendingSources
.slice(1, 32)
.map(source => pendingStorage.unregisterSource(source))
);
await Promise.all(pending);
const contended = createStorage();
const registered = Array.from({ length: 64 }, (_, index) => ({
type: 'currentDoc' as const,
workspaceId: 'workspace-1',
docId: `contended-${index}`,
}));
const releases: Array<() => void> = [];
let holdRefresh = false;
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(input.toString());
if (url.pathname.includes('/blobs/v1/')) return new Response('found');
const docId = url.searchParams.get('docId');
if (holdRefresh) {
await new Promise<void>(resolve => releases.push(resolve));
}
return new Response(
JSON.stringify({
version: 1,
entries:
holdRefresh && docId === registered[63].docId
? [
{
key: 'after-budget',
mime: 'text/plain',
size: 5,
source: registered[63],
},
]
: [],
}),
{ headers: { 'content-type': 'application/json' } }
);
})
);
for (const source of registered) await contended.registerSource(source);
holdRefresh = true;
const refreshing = registered
.slice(32)
.map(source => contended.registerSource(source));
expect(releases).toHaveLength(32);
const reading = contended.get('after-budget').then(
value => value,
error => error
);
for (const release of releases) release();
await Promise.all(refreshing);
await expect(reading).resolves.toMatchObject({ key: 'after-budget' });
await Promise.all(
registered.map(source => contended.unregisterSource(source))
);
});

View File

@@ -0,0 +1,224 @@
import { describe, expect, test, vi } from 'vitest';
import { CloudAwarenessStorage } from '../impls/cloud/awareness';
import { CloudDocStorage } from '../impls/cloud/doc';
const base64UpdateA = 'AQID';
const base64UpdateB = 'BAUG';
class FakeSocket {
connected = true;
readonly joinedDocs = new Set<string>();
readonly emitted: Array<{ event: string; payload: unknown }> = [];
readonly handlers = new Map<string, (...args: unknown[]) => void>();
on(event: string, handler: (...args: unknown[]) => void) {
this.handlers.set(event, handler);
return this;
}
once(event: string, handler: (...args: unknown[]) => void) {
this.handlers.set(event, handler);
return this;
}
off(event: string, handler?: (...args: unknown[]) => void) {
if (!handler || this.handlers.get(event) === handler) {
this.handlers.delete(event);
}
return this;
}
emit(event: string, payload?: unknown) {
this.emitted.push({ event, payload });
return true;
}
async emitWithAck(
event: string,
payload: { docId?: string; spaces?: { docId?: string }[] }
) {
this.emitted.push({ event, payload });
if (event === 'space:join-batch') {
for (const space of payload.spaces ?? []) {
if (space.docId === 'denied') {
return { error: { name: 'DOC_ACTION_DENIED', message: 'denied' } };
}
if (space.docId) this.joinedDocs.add(space.docId);
}
}
if (event === 'space:load-doc' || event === 'space:push-doc-update') {
expect(this.joinedDocs.has(payload.docId!)).toBe(true);
return { data: { missing: 'AAA=', state: 'AA==', timestamp: 1_000 } };
}
if (event === 'space:doc-lifecycle') {
return { data: { rootUpdate: base64UpdateA, timestamp: 1_000 } };
}
return { data: { clientId: 'client-1', success: true } };
}
}
describe('CloudDocStorage broadcast updates', () => {
test('emits updates from batch payload', () => {
const storage = new CloudDocStorage({
id: 'space-1',
serverBaseUrl: 'http://localhost',
isSelfHosted: true,
type: 'workspace',
readonlyMode: true,
});
(storage as any).connection.idConverter = {
oldIdToNewId: (id: string) => id,
newIdToOldId: (id: string) => id,
};
const received: Uint8Array[] = [];
storage.subscribeDocUpdate(update => {
received.push(update.bin);
});
storage.onServerUpdates({
spaceType: 'workspace',
spaceId: 'space-1',
docId: 'doc-1',
updates: [base64UpdateA, base64UpdateB],
timestamp: Date.now(),
});
expect(received).toEqual([
new Uint8Array([1, 2, 3]),
new Uint8Array([4, 5, 6]),
]);
});
test('repairs strict invalidation through readable timestamps', async () => {
const storage = new CloudDocStorage({
id: 'space-1',
serverBaseUrl: 'http://localhost',
isSelfHosted: true,
type: 'workspace',
readonlyMode: true,
});
(storage as any).connection.idConverter = {
oldIdToNewId: (id: string) => id,
newIdToOldId: (id: string) => id,
};
const getDocTimestamps = vi
.spyOn(storage, 'getDocTimestamps')
.mockResolvedValue({ 'doc-a': new Date(1_000) });
const received: Array<{ docId: string; bin: Uint8Array }> = [];
storage.subscribeDocUpdate(update => {
received.push({ docId: update.docId, bin: update.bin });
});
storage.onServerInvalidation({
spaceType: 'workspace',
spaceId: 'space-1',
timestamp: 1_000,
});
storage.onServerInvalidation({
spaceType: 'workspace',
spaceId: 'space-1',
timestamp: 1_001,
});
await vi.waitFor(() => expect(received).toHaveLength(1));
expect(getDocTimestamps).toHaveBeenCalledOnce();
expect(received[0]).toMatchObject({ docId: 'doc-a' });
expect(received[0]?.bin).toEqual(new Uint8Array());
});
test('batch route joins the workspace and applies lifecycle commands', async () => {
vi.stubGlobal('BUILD_CONFIG', { appVersion: '0.27.5' });
const fakeSocket = new FakeSocket();
const disconnect = vi.fn();
const storage = new CloudDocStorage({
id: 'space-1',
serverBaseUrl: 'http://localhost',
isSelfHosted: true,
type: 'workspace',
readonlyMode: true,
});
const connection = storage.connection as any;
Object.defineProperty(connection, 'manager', {
configurable: true,
value: {
connect: () => ({ socket: fakeSocket, disconnect }),
},
});
const inner = await connection.doConnect();
connection._inner = inner;
await storage.getDocSnapshot('doc-1');
await storage.getDocDiff('doc-2');
await storage.getDocTimestamp('doc-3');
await storage.pushDocUpdate({
docId: 'doc-4',
bin: new Uint8Array([0, 0]),
});
const lifecycle = await storage.applyDocLifecycle('doc-1', 'trash');
await expect(storage.getDocSnapshot('denied')).rejects.toMatchObject({
name: 'DOC_ACTION_DENIED',
});
fakeSocket.joinedDocs.delete('doc-1');
await storage.getDocDiff('doc-1');
connection.doDisconnect(inner);
expect({ emitted: fakeSocket.emitted, lifecycle }).toMatchSnapshot({
lifecycle: {
rootUpdate: expect.any(Uint8Array),
timestamp: expect.any(Date),
},
});
expect(disconnect).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
test('awareness joins active documents through the batch route', async () => {
vi.stubGlobal('BUILD_CONFIG', { appVersion: '0.27.5' });
const fakeSocket = new FakeSocket();
const storage = new CloudAwarenessStorage({
id: 'space-1',
serverBaseUrl: 'http://localhost',
isSelfHosted: true,
type: 'workspace',
});
Object.defineProperty(storage, 'connection', {
configurable: true,
value: {
status: 'connected',
inner: { socket: fakeSocket },
onStatusChanged: () => () => {},
},
});
const unsubscribeA = storage.subscribeUpdate(
'doc-a',
() => {},
async () => null
);
const unsubscribeB = storage.subscribeUpdate(
'doc-b',
() => {},
async () => null
);
await vi.waitFor(() => {
expect(
fakeSocket.emitted.filter(
({ event: emittedEvent }) => emittedEvent === 'space:join-batch'
)
).toHaveLength(1);
});
expect(fakeSocket.emitted).toMatchSnapshot();
unsubscribeA();
unsubscribeB();
vi.unstubAllGlobals();
});
});

View File

@@ -0,0 +1,192 @@
import 'fake-indexeddb/auto';
import { expect, test, vitest } from 'vitest';
import { Awareness } from 'y-protocols/awareness.js';
import { Doc as YDoc } from 'yjs';
import { AwarenessFrontend } from '../frontend/awareness';
import { DocFrontend } from '../frontend/doc';
import { BroadcastChannelAwarenessStorage } from '../impls/broadcast-channel/awareness';
import { IndexedDBDocStorage } from '../impls/idb';
import { AwarenessSyncImpl } from '../sync/awareness';
import { DocSyncImpl } from '../sync/doc';
import { expectYjsEqual } from './utils';
test('doc', async () => {
const doc1 = new YDoc({
guid: 'test-doc',
});
doc1.getMap('test').set('hello', 'world');
const docStorage = new IndexedDBDocStorage({
id: 'ws1',
flavour: 'a',
type: 'workspace',
});
docStorage.connection.connect();
await docStorage.connection.waitForConnected();
const frontend1 = new DocFrontend(docStorage, DocSyncImpl.dummy);
frontend1.start();
frontend1.connectDoc(doc1);
await vitest.waitFor(async () => {
const doc = await docStorage.getDoc('test-doc');
expectYjsEqual(doc!.bin, {
test: {
hello: 'world',
},
});
});
const doc2 = new YDoc({
guid: 'test-doc',
});
const frontend2 = new DocFrontend(docStorage, DocSyncImpl.dummy);
frontend2.start();
frontend2.connectDoc(doc2);
await vitest.waitFor(async () => {
expectYjsEqual(doc2, {
test: {
hello: 'world',
},
});
});
});
test('awareness', async () => {
const storage1 = new BroadcastChannelAwarenessStorage({
id: 'ws1:a',
});
const storage2 = new BroadcastChannelAwarenessStorage({
id: 'ws1:b',
});
storage1.connection.connect();
storage2.connection.connect();
await storage1.connection.waitForConnected();
await storage2.connection.waitForConnected();
// peer a
const docA = new YDoc({ guid: 'test-doc' });
docA.clientID = 1;
const awarenessA = new Awareness(docA);
// peer b
const docB = new YDoc({ guid: 'test-doc' });
docB.clientID = 2;
const awarenessB = new Awareness(docB);
// peer c
const docC = new YDoc({ guid: 'test-doc' });
docC.clientID = 3;
const awarenessC = new Awareness(docC);
{
const sync = new AwarenessSyncImpl({
local: storage1,
remotes: {
b: storage2,
},
});
const frontend = new AwarenessFrontend(sync);
frontend.connectAwareness(awarenessA);
frontend.connectAwareness(awarenessB);
}
{
const sync = new AwarenessSyncImpl({
local: storage2,
remotes: {
a: storage1,
},
});
const frontend = new AwarenessFrontend(sync);
frontend.connectAwareness(awarenessC);
}
awarenessA.setLocalState({
hello: 'world',
});
await vitest.waitFor(() => {
expect(awarenessB.getStates().get(1)).toEqual({
hello: 'world',
});
expect(awarenessC.getStates().get(1)).toEqual({
hello: 'world',
});
});
awarenessB.setLocalState({
foo: 'bar',
});
await vitest.waitFor(() => {
expect(awarenessA.getStates().get(2)).toEqual({
foo: 'bar',
});
expect(awarenessC.getStates().get(2)).toEqual({
foo: 'bar',
});
});
awarenessC.setLocalState({
baz: 'qux',
});
await vitest.waitFor(() => {
expect(awarenessA.getStates().get(3)).toEqual({
baz: 'qux',
});
expect(awarenessB.getStates().get(3)).toEqual({
baz: 'qux',
});
});
});
test('doc priority requests accumulate', async () => {
const docStorage = new IndexedDBDocStorage({
id: 'ws-priority',
flavour: 'a',
type: 'workspace',
});
docStorage.connection.connect();
await docStorage.connection.waitForConnected();
const frontend = new DocFrontend(docStorage, DocSyncImpl.dummy);
// two holders prioritize the same doc, then one of them goes away
frontend.addPriority('high', 10);
const releaseSecondHolder = frontend.addPriority('high', 10);
releaseSecondHolder();
frontend.addPriority('low', 5);
const loadOrder: string[] = [];
vitest.spyOn(docStorage, 'getDoc').mockImplementation(async docId => {
loadOrder.push(docId);
return null;
});
// both load jobs are queued before the main loop starts, so the queue
// priority alone decides which doc is loaded first
frontend.connectDoc(new YDoc({ guid: 'low' }));
frontend.connectDoc(new YDoc({ guid: 'high' }));
frontend.start();
await vitest.waitFor(
() => {
expect(loadOrder).toEqual(['high', 'low']);
},
{ timeout: 2000 }
);
frontend.stop();
});

View File

@@ -0,0 +1,988 @@
import 'fake-indexeddb/auto';
import * as reader from '@affine/reader';
import { firstValueFrom, NEVER } from 'rxjs';
import { afterEach, expect, test, vi } from 'vitest';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { DummyConnection } from '../connection';
import {
IndexedDBBlobStorage,
IndexedDBBlobSyncStorage,
IndexedDBDocStorage,
IndexedDBDocSyncStorage,
} from '../impls/idb';
import {
type AggregateOptions,
type AggregateResult,
type CrawlResult,
type DocClock,
type DocClocks,
type DocDiff,
type DocIndexedClock,
type DocRecord,
type DocStorage,
type DocUpdate,
type IndexerDocument,
type IndexerSchema,
IndexerStorageBase,
IndexerSyncStorageBase,
type Query,
type SearchOptions,
type SearchResult,
SpaceStorage,
} from '../storage';
import { Sync } from '../sync';
import { BlobSyncPeer } from '../sync/blob/peer';
import { DocSyncPeer } from '../sync/doc/peer';
import { IndexerSyncImpl } from '../sync/indexer';
import { expectYjsEqual } from './utils';
afterEach(() => {
vi.restoreAllMocks();
});
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
class TestDocStorage implements DocStorage {
readonly storageType = 'doc' as const;
readonly connection = new DummyConnection();
readonly isReadonly = false;
private readonly subscribers = new Set<
(update: DocRecord, origin?: string) => void
>();
constructor(
readonly spaceId: string,
private readonly timestamps: Map<string, Date>,
private readonly crawlDocDataImpl: (
docId: string
) => Promise<CrawlResult | null>
) {}
async getDoc(_docId: string): Promise<DocRecord | null> {
return null;
}
async getDocDiff(
_docId: string,
_state?: Uint8Array
): Promise<DocDiff | null> {
return null;
}
async pushDocUpdate(update: DocUpdate, origin?: string): Promise<DocClock> {
const timestamp = this.timestamps.get(update.docId) ?? new Date();
const record = { ...update, timestamp };
this.timestamps.set(update.docId, timestamp);
for (const subscriber of this.subscribers) {
subscriber(record, origin);
}
return { docId: update.docId, timestamp };
}
async getDocTimestamp(docId: string): Promise<DocClock | null> {
const timestamp = this.timestamps.get(docId);
return timestamp ? { docId, timestamp } : null;
}
async getDocTimestamps(): Promise<DocClocks> {
return Object.fromEntries(this.timestamps);
}
async deleteDoc(docId: string): Promise<void> {
this.timestamps.delete(docId);
}
subscribeDocUpdate(callback: (update: DocRecord, origin?: string) => void) {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async crawlDocData(docId: string): Promise<CrawlResult | null> {
return this.crawlDocDataImpl(docId);
}
}
class TimestampBlindDocStorage extends IndexedDBDocStorage {
override async getDocTimestamps(): Promise<DocClocks> {
return {};
}
}
class PermissionDeniedRemoteDocStorage implements DocStorage {
readonly storageType = 'doc' as const;
readonly connection = new DummyConnection();
readonly isReadonly = false;
pushCount = 0;
constructor(readonly spaceId: string) {}
async getDoc(_docId: string): Promise<DocRecord | null> {
return null;
}
async getDocDiff(
_docId: string,
_state?: Uint8Array
): Promise<DocDiff | null> {
return null;
}
async pushDocUpdate(_update: DocUpdate): Promise<DocClock> {
this.pushCount++;
const error = new Error('No permission to update doc');
error.name = 'DOC_ACTION_DENIED';
throw error;
}
async getDocTimestamp(_docId: string): Promise<DocClock | null> {
return null;
}
async getDocTimestamps(): Promise<DocClocks> {
return {};
}
async deleteDoc(_docId: string): Promise<void> {
return;
}
subscribeDocUpdate(_callback: (update: DocRecord, origin?: string) => void) {
return () => {};
}
}
class PermissionDeniedConnection extends DummyConnection {
waitCount = 0;
override async waitForConnected(_signal?: AbortSignal): Promise<void> {
this.waitCount++;
const error = new Error('No permission to access space');
error.name = 'SPACE_ACCESS_DENIED';
throw error;
}
}
class PermissionDeniedConnectionDocStorage extends PermissionDeniedRemoteDocStorage {
override readonly connection = new PermissionDeniedConnection();
}
class TrackingIndexerStorage extends IndexerStorageBase {
override readonly connection = new DummyConnection();
override readonly isReadonly = false;
constructor(
private readonly calls: string[],
override readonly recommendRefreshInterval: number
) {
super();
}
override async search<
T extends keyof IndexerSchema,
const O extends SearchOptions<T>,
>(_table: T, _query: Query<T>, _options?: O): Promise<SearchResult<T, O>> {
return {
pagination: { count: 0, limit: 0, skip: 0, hasMore: false },
nodes: [],
} as SearchResult<T, O>;
}
override async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
_table: T,
_query: Query<T>,
_field: keyof IndexerSchema[T],
_options?: O
): Promise<AggregateResult<T, O>> {
return {
pagination: { count: 0, limit: 0, skip: 0, hasMore: false },
buckets: [],
} as AggregateResult<T, O>;
}
override search$<
T extends keyof IndexerSchema,
const O extends SearchOptions<T>,
>(_table: T, _query: Query<T>, _options?: O) {
return NEVER;
}
override aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(_table: T, _query: Query<T>, _field: keyof IndexerSchema[T], _options?: O) {
return NEVER;
}
override async deleteByQuery<T extends keyof IndexerSchema>(
table: T,
_query: Query<T>
): Promise<void> {
this.calls.push(`deleteByQuery:${String(table)}`);
}
override async insert<T extends keyof IndexerSchema>(
table: T,
document: IndexerDocument<T>
): Promise<void> {
this.calls.push(`insert:${String(table)}:${document.id}`);
}
override async delete<T extends keyof IndexerSchema>(
table: T,
id: string
): Promise<void> {
this.calls.push(`delete:${String(table)}:${id}`);
}
override async update<T extends keyof IndexerSchema>(
table: T,
document: IndexerDocument<T>
): Promise<void> {
this.calls.push(`update:${String(table)}:${document.id}`);
}
override async refresh<T extends keyof IndexerSchema>(
_table: T
): Promise<void> {
return;
}
override async refreshIfNeed(): Promise<void> {
this.calls.push('refresh');
}
override async indexVersion(): Promise<number> {
return 1;
}
}
class TrackingIndexerSyncStorage extends IndexerSyncStorageBase {
override readonly connection = new DummyConnection();
private readonly clocks = new Map<string, DocIndexedClock>();
constructor(private readonly calls: string[]) {
super();
}
override async getDocIndexedClock(
docId: string
): Promise<DocIndexedClock | null> {
return this.clocks.get(docId) ?? null;
}
override async setDocIndexedClock(clock: DocIndexedClock): Promise<void> {
this.calls.push(`setClock:${clock.docId}`);
this.clocks.set(clock.docId, clock);
}
override async clearDocIndexedClock(docId: string): Promise<void> {
this.calls.push(`clearClock:${docId}`);
this.clocks.delete(docId);
}
}
test('doc', async () => {
const doc = new YDoc();
doc.getMap('test').set('hello', 'world');
const update = encodeStateAsUpdate(doc);
const peerADoc = new IndexedDBDocStorage({
id: 'ws1',
flavour: 'a',
type: 'workspace',
});
const peerASync = new IndexedDBDocSyncStorage({
id: 'ws1',
flavour: 'a',
type: 'workspace',
});
const peerBDoc = new TimestampBlindDocStorage({
id: 'ws1',
flavour: 'b',
type: 'workspace',
});
const peerCDoc = new IndexedDBDocStorage({
id: 'ws1',
flavour: 'c',
type: 'workspace',
});
const peerA = new SpaceStorage({
doc: peerADoc,
docSync: peerASync,
});
const peerB = new SpaceStorage({
doc: peerBDoc,
});
const peerC = new SpaceStorage({
doc: peerCDoc,
});
peerA.connect();
peerB.connect();
peerC.connect();
await peerA.waitForConnected();
await peerB.waitForConnected();
await peerC.waitForConnected();
await peerA.get('doc').pushDocUpdate({
docId: 'doc1',
bin: update,
});
const prioritizedDocId = 'prioritized-doc';
const localPrioritizedDoc = new YDoc();
localPrioritizedDoc.getMap('test').set('local', true);
const localPrioritizedClock = await peerA.get('doc').pushDocUpdate({
docId: prioritizedDocId,
bin: encodeStateAsUpdate(localPrioritizedDoc),
});
await peerASync.setPeerPushedClock('b', localPrioritizedClock);
const remotePrioritizedDoc = new YDoc();
remotePrioritizedDoc.getMap('test').set('remote', true);
await peerB.get('doc').pushDocUpdate({
docId: prioritizedDocId,
bin: encodeStateAsUpdate(remotePrioritizedDoc),
});
const rootDoc = new YDoc();
rootDoc.getMap('meta').set('name', 'Self-host workspace');
await peerB.get('doc').pushDocUpdate({
docId: 'ws1',
bin: encodeStateAsUpdate(rootDoc),
});
const sync = new Sync({
local: peerA,
remotes: {
b: peerB,
c: peerC,
},
});
const removeRootPriority = sync.doc.addPriority('ws1', 100);
const removeForegroundPriority = sync.doc.addPriority('doc1', 200);
const remoteDiff = vi.spyOn(peerBDoc, 'getDocDiff');
expect(await firstValueFrom(sync.doc.docState$('doc1'))).toMatchObject({
synced: false,
});
sync.start();
await new Promise(resolve => setTimeout(resolve, 1000));
{
expect(remoteDiff.mock.calls[0]?.[0]).toBe('ws1');
const b = await peerB.get('doc').getDoc('doc1');
expectYjsEqual(b!.bin, {
test: {
hello: 'world',
},
});
const c = await peerC.get('doc').getDoc('doc1');
expectYjsEqual(c!.bin, {
test: {
hello: 'world',
},
});
const root = await peerA.get('doc').getDoc('ws1');
expectYjsEqual(root!.bin, {
meta: {
name: 'Self-host workspace',
},
});
const prioritized = await peerA.get('doc').getDoc(prioritizedDocId);
expectYjsEqual(prioritized!.bin, {
test: {
local: true,
},
});
}
const removeDocPriority = sync.doc.addPriority(prioritizedDocId, 100);
await vi.waitFor(async () => {
const prioritized = await peerA.get('doc').getDoc(prioritizedDocId);
expectYjsEqual(prioritized!.bin, {
test: {
local: true,
remote: true,
},
});
});
doc.getMap('test').set('foo', 'bar');
const update2 = encodeStateAsUpdate(doc);
await peerC.get('doc').pushDocUpdate({
docId: 'doc1',
bin: update2,
});
await new Promise(resolve => setTimeout(resolve, 1000));
{
const a = await peerA.get('doc').getDoc('doc1');
expectYjsEqual(a!.bin, {
test: {
hello: 'world',
foo: 'bar',
},
});
const c = await peerC.get('doc').getDoc('doc1');
expectYjsEqual(c!.bin, {
test: {
hello: 'world',
foo: 'bar',
},
});
}
removeDocPriority();
removeForegroundPriority();
removeRootPriority();
sync.stop();
peerA.disconnect();
peerB.disconnect();
peerC.disconnect();
});
test('blob', async () => {
const a = new IndexedDBBlobStorage({
id: 'ws1',
flavour: 'a',
type: 'workspace',
});
const b = new IndexedDBBlobStorage({
id: 'ws1',
flavour: 'b',
type: 'workspace',
});
const c = new IndexedDBBlobStorage({
id: 'ws1',
flavour: 'c',
type: 'workspace',
});
const blobSync = new IndexedDBBlobSyncStorage({
id: 'ws1',
flavour: 'a',
type: 'workspace',
});
const peerA = new SpaceStorage({
blob: a,
blobSync,
});
const peerB = new SpaceStorage({
blob: b,
});
const peerC = new SpaceStorage({
blob: c,
});
peerA.connect();
peerB.connect();
peerC.connect();
await peerA.waitForConnected();
await peerB.waitForConnected();
await peerC.waitForConnected();
await a.set({
key: 'test',
data: new Uint8Array([1, 2, 3, 4]),
mime: 'text/plain',
createdAt: new Date(100),
});
await c.set({
key: 'test2',
data: new Uint8Array([4, 3, 2, 1]),
mime: 'text/plain',
createdAt: new Date(100),
});
const sync = new Sync({
local: peerA,
remotes: {
b: peerB,
c: peerC,
},
});
sync.start();
await new Promise(resolve => setTimeout(resolve, 1000));
{
const a = await peerA.get('blob').get('test');
expect(a).not.toBeNull();
expect(a?.data).toEqual(new Uint8Array([1, 2, 3, 4]));
}
{
const b = await peerB.get('blob').get('test');
expect(b).not.toBeNull();
expect(b?.data).toEqual(new Uint8Array([1, 2, 3, 4]));
}
{
const c = await peerC.get('blob').get('test2');
expect(c).not.toBeNull();
expect(c?.data).toEqual(new Uint8Array([4, 3, 2, 1]));
}
sync.stop();
const localReads = vi.spyOn(a, 'get');
const localLists = vi.spyOn(a, 'list');
const remoteReads = vi.spyOn(c, 'get');
await new BlobSyncPeer('c', a, c, blobSync).fullDownload();
expect(localReads).not.toHaveBeenCalled();
expect(localLists).toHaveBeenCalledTimes(1);
expect(remoteReads.mock.calls.map(([key]) => key)).toEqual(['test2']);
remoteReads.mockRestore();
localLists.mockClear();
for (const key of ['fresh', 'retry']) {
await c.set({ key, data: new Uint8Array([1, 2, 3]), mime: 'text/plain' });
}
const entries = await c.list();
const scoped = Object.assign(c, {
registerSource: vi.fn(),
unregisterSource: vi.fn(),
async *readableSources() {
for (const entry of entries) {
for (const docId of ['first', 'second']) {
yield {
...entry,
source: { type: 'currentDoc' as const, workspaceId: 'ws1', docId },
};
}
}
},
});
const get = c.get.bind(c);
let retryAttempts = 0;
const reads = vi.spyOn(c, 'get').mockImplementation(async key => {
if (key === 'retry' && retryAttempts++ === 0)
throw new Error('source denied');
return get(key);
});
const inventory = vi
.spyOn(c, 'list')
.mockRejectedValue(new Error('cloud inventory forbidden'));
await new BlobSyncPeer('scoped', a, scoped, blobSync).fullDownload();
expect(localReads).not.toHaveBeenCalled();
expect(localLists).toHaveBeenCalledTimes(1);
expect(inventory).not.toHaveBeenCalled();
expect(reads.mock.calls.map(([key]) => key)).toEqual([
'fresh',
'retry',
'retry',
]);
localReads.mockRestore();
expect((await a.get('fresh'))?.data).toEqual(new Uint8Array([1, 2, 3]));
expect((await a.get('retry'))?.data).toEqual(new Uint8Array([1, 2, 3]));
});
test('doc sync peer stops retrying a doc when remote denies permission', async () => {
const local = new IndexedDBDocStorage({
id: 'ws-denied',
flavour: 'local-denied',
type: 'workspace',
});
const syncMetadata = new IndexedDBDocSyncStorage({
id: 'ws-denied',
flavour: 'local-denied',
type: 'workspace',
});
const remote = new PermissionDeniedRemoteDocStorage('ws-denied');
const peer = new DocSyncPeer('remote-denied', local, syncMetadata, remote);
const abort = new AbortController();
local.connection.connect();
syncMetadata.connection.connect();
await local.connection.waitForConnected();
await syncMetadata.connection.waitForConnected();
const doc = new YDoc();
doc.getMap('test').set('hello', 'world');
await local.pushDocUpdate({
docId: 'doc-denied',
bin: encodeStateAsUpdate(doc),
});
try {
void peer.mainLoop(abort.signal);
await vi.waitFor(() => {
expect(remote.pushCount).toBe(1);
});
await vi.waitFor(() => {
let state:
| {
syncing: boolean;
synced: boolean;
retrying: boolean;
errorMessage: string | null;
}
| undefined;
const dispose = peer.docState$('doc-denied').subscribe(next => {
state = next;
});
dispose.unsubscribe();
expect(state).toMatchObject({
syncing: false,
synced: false,
retrying: false,
errorMessage: expect.stringContaining('No permission'),
});
});
await vi.waitFor(() => {
let state:
| {
synced: boolean;
errorMessage: string | null;
}
| undefined;
const dispose = peer.peerState$.subscribe(next => {
state = next;
});
dispose.unsubscribe();
expect(state).toMatchObject({
synced: false,
errorMessage: expect.stringContaining('No permission'),
});
});
await new Promise(resolve => setTimeout(resolve, 1200));
expect(remote.pushCount).toBe(1);
} finally {
abort.abort();
local.connection.disconnect();
syncMetadata.connection.disconnect();
}
});
test('doc sync peer stops retrying when remote connection denies permission', async () => {
const local = new IndexedDBDocStorage({
id: 'ws-connection-denied',
flavour: 'local-connection-denied',
type: 'workspace',
});
const syncMetadata = new IndexedDBDocSyncStorage({
id: 'ws-connection-denied',
flavour: 'local-connection-denied',
type: 'workspace',
});
const remote = new PermissionDeniedConnectionDocStorage(
'ws-connection-denied'
);
const peer = new DocSyncPeer(
'remote-connection-denied',
local,
syncMetadata,
remote
);
const abort = new AbortController();
local.connection.connect();
syncMetadata.connection.connect();
await local.connection.waitForConnected();
await syncMetadata.connection.waitForConnected();
try {
void peer.mainLoop(abort.signal);
await vi.waitFor(() => {
expect(remote.connection.waitCount).toBe(1);
});
await vi.waitFor(() => {
let state:
| {
retrying: boolean;
errorMessage: string | null;
}
| undefined;
const dispose = peer.peerState$.subscribe(next => {
state = next;
});
dispose.unsubscribe();
expect(state).toMatchObject({
retrying: false,
errorMessage: expect.stringContaining('No permission'),
});
});
await new Promise(resolve => setTimeout(resolve, 1200));
expect(remote.connection.waitCount).toBe(1);
} finally {
abort.abort();
local.connection.disconnect();
syncMetadata.connection.disconnect();
}
});
test('doc sync peer resolves on terminal permission error without abort signal', async () => {
const local = new IndexedDBDocStorage({
id: 'ws-connection-denied-no-signal',
flavour: 'local-connection-denied-no-signal',
type: 'workspace',
});
const syncMetadata = new IndexedDBDocSyncStorage({
id: 'ws-connection-denied-no-signal',
flavour: 'local-connection-denied-no-signal',
type: 'workspace',
});
const remote = new PermissionDeniedConnectionDocStorage(
'ws-connection-denied-no-signal'
);
const peer = new DocSyncPeer(
'remote-connection-denied-no-signal',
local,
syncMetadata,
remote
);
local.connection.connect();
syncMetadata.connection.connect();
await local.connection.waitForConnected();
await syncMetadata.connection.waitForConnected();
try {
await expect(peer.mainLoop()).resolves.toBeUndefined();
expect(remote.connection.waitCount).toBe(1);
let state:
| {
retrying: boolean;
errorMessage: string | null;
}
| undefined;
const dispose = peer.peerState$.subscribe(next => {
state = next;
});
dispose.unsubscribe();
expect(state).toMatchObject({
retrying: false,
errorMessage: expect.stringContaining('No permission'),
});
} finally {
local.connection.disconnect();
syncMetadata.connection.disconnect();
}
});
test('indexer defers indexed clock persistence until a refresh happens on delayed refresh storages', async () => {
const calls: string[] = [];
const docsInRootDoc = new Map([['doc1', { title: 'Doc 1' }]]);
const docStorage = new TestDocStorage(
'workspace-id',
new Map([['doc1', new Date('2026-01-01T00:00:00.000Z')]]),
async () => ({
title: 'Doc 1',
summary: 'summary',
blocks: [
{ blockId: 'block-1', flavour: 'affine:image', blob: ['blob-1'] },
],
})
);
const indexer = new TrackingIndexerStorage(calls, 30_000);
const update = vi.spyOn(indexer, 'update');
const indexerSyncStorage = new TrackingIndexerSyncStorage(calls);
const sync = new IndexerSyncImpl(
docStorage,
{
local: indexer,
remotes: {},
},
indexerSyncStorage
);
vi.spyOn(reader, 'readAllDocsFromRootDoc').mockImplementation(
() => new Map(docsInRootDoc)
);
try {
sync.start();
await sync.waitForCompleted();
const docUpdate = update.mock.calls.find(([table]) => table === 'doc');
expect(docUpdate).toBeDefined();
expect([...(docUpdate?.[1].fields ?? [])]).toEqual(
expect.arrayContaining([
['docId', ['doc1']],
['title', ['Doc 1']],
['summary', ['summary']],
])
);
expect(calls).not.toContain('setClock:doc1');
sync.stop();
await vi.waitFor(() => {
expect(calls).toContain('setClock:doc1');
});
const lastRefreshIndex = calls.lastIndexOf('refresh');
const setClockIndex = calls.indexOf('setClock:doc1');
expect(lastRefreshIndex).toBeGreaterThanOrEqual(0);
expect(setClockIndex).toBeGreaterThan(lastRefreshIndex);
} finally {
sync.stop();
}
});
test('indexer completion waits for the current job to finish', async () => {
const docsInRootDoc = new Map([['doc1', { title: 'Doc 1' }]]);
const crawlStarted = deferred<void>();
const releaseCrawl = deferred<void>();
const docStorage = new TestDocStorage(
'workspace-id',
new Map([['doc1', new Date('2026-01-01T00:00:00.000Z')]]),
async () => {
crawlStarted.resolve();
await releaseCrawl.promise;
return {
title: 'Doc 1',
summary: 'summary',
blocks: [
{ blockId: 'block-1', flavour: 'affine:image', blob: ['blob-1'] },
],
};
}
);
const sync = new IndexerSyncImpl(
docStorage,
{
local: new TrackingIndexerStorage([], 30_000),
remotes: {},
},
new TrackingIndexerSyncStorage([])
);
vi.spyOn(reader, 'readAllDocsFromRootDoc').mockImplementation(
() => new Map(docsInRootDoc)
);
try {
sync.start();
await crawlStarted.promise;
let completed = false;
let docCompleted = false;
const waitForCompleted = sync.waitForCompleted().then(() => {
completed = true;
});
const waitForDocCompleted = sync.waitForDocCompleted('doc1').then(() => {
docCompleted = true;
});
await new Promise(resolve => setTimeout(resolve, 20));
expect(completed).toBe(false);
expect(docCompleted).toBe(false);
releaseCrawl.resolve();
await waitForCompleted;
await waitForDocCompleted;
} finally {
sync.stop();
}
});
test('indexer priority requests accumulate', async () => {
const docsInRootDoc = new Map([
['doc-low', { title: 'Doc Low' }],
['doc-high', { title: 'Doc High' }],
]);
const crawled: string[] = [];
const rootDocCrawlStarted = deferred<void>();
const releaseRootDocCrawl = deferred<void>();
const docStorage = new TestDocStorage(
'workspace-id',
new Map([
['doc-low', new Date('2026-01-01T00:00:00.000Z')],
['doc-high', new Date('2026-01-01T00:00:00.000Z')],
]),
async docId => {
crawled.push(docId);
return { title: docId, summary: 'summary', blocks: [] };
}
);
const indexer = new TrackingIndexerStorage([], 30_000);
// hold the loop inside the root doc crawl, so both docs stay queued while
// their priorities are changed
let holding = false;
vi.spyOn(indexer, 'insert').mockImplementation(async () => {
if (!holding) {
holding = true;
rootDocCrawlStarted.resolve();
await releaseRootDocCrawl.promise;
}
});
const sync = new IndexerSyncImpl(
docStorage,
{
local: indexer,
remotes: {},
},
new TrackingIndexerSyncStorage([])
);
vi.spyOn(reader, 'readAllDocsFromRootDoc').mockImplementation(
() => new Map(docsInRootDoc)
);
try {
sync.start();
await rootDocCrawlStarted.promise;
sync.addPriority('doc-low', 5);
// two holders on the same doc, one of them goes away
sync.addPriority('doc-high', 10);
const releaseSecondHolder = sync.addPriority('doc-high', 10);
releaseSecondHolder();
releaseRootDocCrawl.resolve();
await vi.waitFor(() => {
expect(crawled).toHaveLength(2);
});
// the remaining holder still asked for +10, so `doc-high` must outrank the
// +5 of `doc-low`
expect(crawled).toEqual(['doc-high', 'doc-low']);
} finally {
releaseRootDocCrawl.resolve();
sync.stop();
}
});

View File

@@ -0,0 +1,30 @@
import { expect } from 'vitest';
import { applyUpdate, Doc as YDoc } from 'yjs';
export function expectYjsEqual(
doc: Uint8Array | YDoc,
match: Record<string, any>
) {
let ydoc: YDoc;
if (doc instanceof Uint8Array) {
ydoc = new YDoc();
applyUpdate(ydoc, doc);
} else {
ydoc = doc;
}
for (const key in match) {
const value = match[key];
if (Array.isArray(value)) {
const actual = ydoc.getArray(key).toJSON();
expect(actual).toEqual(value);
} else if (typeof value === 'string') {
const actual = ydoc.getText(key).toJSON();
expect(actual).toEqual(value);
} else {
const actual = ydoc.getMap(key).toJSON();
expect(actual).toEqual(value);
}
}
return doc;
}