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
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:
@@ -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>,
|
||||
},
|
||||
}
|
||||
`;
|
||||
23
packages/common/nbstore/src/__tests__/base64.bench.ts
Normal file
23
packages/common/nbstore/src/__tests__/base64.bench.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
packages/common/nbstore/src/__tests__/base64.spec.ts
Normal file
27
packages/common/nbstore/src/__tests__/base64.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
943
packages/common/nbstore/src/__tests__/cloud-blob.spec.ts
Normal file
943
packages/common/nbstore/src/__tests__/cloud-blob.spec.ts
Normal 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×tampMs=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×tampMs=1720000000000",
|
||||
"https://example.com/api/workspaces/workspace-1/blobs/v1/blob-key?sourceType=history&docId=doc-2×tampMs=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×tampMs=1720000000000",
|
||||
"https://example.com/api/workspaces/workspace-1/blobs/v1/failed-blob?sourceType=history&docId=doc-2×tampMs=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×tampMs=1720000000000",
|
||||
"https://example.com/api/workspaces/workspace-1/blob-manifest/v1?sourceType=history&docId=doc-2×tampMs=1720000000000",
|
||||
"https://example.com/api/workspaces/workspace-1/blobs/v1/recovered-blob?sourceType=history&docId=doc-2×tampMs=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))
|
||||
);
|
||||
});
|
||||
224
packages/common/nbstore/src/__tests__/cloud-doc-updates.spec.ts
Normal file
224
packages/common/nbstore/src/__tests__/cloud-doc-updates.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
192
packages/common/nbstore/src/__tests__/frontend.spec.ts
Normal file
192
packages/common/nbstore/src/__tests__/frontend.spec.ts
Normal 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();
|
||||
});
|
||||
988
packages/common/nbstore/src/__tests__/sync.spec.ts
Normal file
988
packages/common/nbstore/src/__tests__/sync.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
30
packages/common/nbstore/src/__tests__/utils.ts
Normal file
30
packages/common/nbstore/src/__tests__/utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user