feat: multipart blob sync support (#14138)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Flexible blob uploads: GRAPHQL, presigned, and multipart flows with
per‑part URLs, abort/complete operations, presigned proxy endpoints, and
nightly cleanup of expired pending uploads.

* **API / Schema**
* GraphQL additions: new types, mutations, enum and error to manage
upload lifecycle (create, complete, abort, get part URL).

* **Database**
* New blob status enum and columns (status, upload_id); listing now
defaults to completed blobs.

* **Localization**
  * Added user-facing message: "Blob is invalid."

* **Tests**
* Expanded unit and end‑to‑end coverage for upload flows, proxy
behavior, multipart and provider integrations.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-12-23 22:09:21 +08:00
committed by GitHub
parent a9937e18b6
commit 76524084d1
36 changed files with 2880 additions and 33 deletions
@@ -0,0 +1,162 @@
import {
abortBlobUploadMutation,
BlobUploadMethod,
completeBlobUploadMutation,
createBlobUploadMutation,
getBlobUploadPartUrlMutation,
setBlobMutation,
workspaceBlobQuotaQuery,
} from '@affine/graphql';
import { afterEach, expect, test, vi } from 'vitest';
import { CloudBlobStorage } from '../impls/cloud/blob';
const quotaResponse = {
workspace: {
quota: {
humanReadable: {
blobLimit: '1 MB',
},
blobLimit: 1024 * 1024,
},
},
};
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
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');
});
(storage.connection as any).gql = gqlMock;
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('falls back to graphql when presigned upload fails', 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 === setBlobMutation) {
return { setBlob: 'blob-key' };
}
if (query === completeBlobUploadMutation) {
return { completeBlobUpload: 'blob-key' };
}
throw new Error('Unexpected query');
});
(storage.connection as any).gql = gqlMock;
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 500 }))
);
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(setBlobMutation);
expect(queries).not.toContain(completeBlobUploadMutation);
});
test('falls back to graphql and aborts when multipart upload fails', async () => {
const storage = createStorage();
const gqlMock = vi.fn(async ({ query }) => {
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 === getBlobUploadPartUrlMutation) {
return {
getBlobUploadPartUrl: {
uploadUrl: 'https://upload.example.com/part',
},
};
}
if (query === abortBlobUploadMutation) {
return { abortBlobUpload: true };
}
if (query === setBlobMutation) {
return { setBlob: 'blob-key' };
}
throw new Error('Unexpected query');
});
(storage.connection as any).gql = gqlMock;
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 500 }))
);
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(abortBlobUploadMutation);
expect(queries).toContain(setBlobMutation);
});