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

View File

@@ -0,0 +1,3 @@
mutation abortBlobUpload($workspaceId: String!, $key: String!, $uploadId: String!) {
abortBlobUpload(workspaceId: $workspaceId, key: $key, uploadId: $uploadId)
}

View File

@@ -0,0 +1,3 @@
mutation completeBlobUpload($workspaceId: String!, $key: String!, $uploadId: String, $parts: [BlobUploadPartInput!]) {
completeBlobUpload(workspaceId: $workspaceId, key: $key, uploadId: $uploadId, parts: $parts)
}

View File

@@ -0,0 +1,16 @@
mutation createBlobUpload($workspaceId: String!, $key: String!, $size: Int!, $mime: String!) {
createBlobUpload(workspaceId: $workspaceId, key: $key, size: $size, mime: $mime) {
method
blobKey
alreadyUploaded
uploadUrl
headers
expiresAt
uploadId
partSize
uploadedParts {
partNumber
etag
}
}
}

View File

@@ -0,0 +1,7 @@
mutation getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) {
getBlobUploadPartUrl(workspaceId: $workspaceId, key: $key, uploadId: $uploadId, partNumber: $partNumber) {
uploadUrl
headers
expiresAt
}
}

View File

@@ -383,6 +383,65 @@ export const setBlobMutation = {
file: true,
};
export const abortBlobUploadMutation = {
id: 'abortBlobUploadMutation' as const,
op: 'abortBlobUpload',
query: `mutation abortBlobUpload($workspaceId: String!, $key: String!, $uploadId: String!) {
abortBlobUpload(workspaceId: $workspaceId, key: $key, uploadId: $uploadId)
}`,
};
export const completeBlobUploadMutation = {
id: 'completeBlobUploadMutation' as const,
op: 'completeBlobUpload',
query: `mutation completeBlobUpload($workspaceId: String!, $key: String!, $uploadId: String, $parts: [BlobUploadPartInput!]) {
completeBlobUpload(
workspaceId: $workspaceId
key: $key
uploadId: $uploadId
parts: $parts
)
}`,
};
export const createBlobUploadMutation = {
id: 'createBlobUploadMutation' as const,
op: 'createBlobUpload',
query: `mutation createBlobUpload($workspaceId: String!, $key: String!, $size: Int!, $mime: String!) {
createBlobUpload(workspaceId: $workspaceId, key: $key, size: $size, mime: $mime) {
method
blobKey
alreadyUploaded
uploadUrl
headers
expiresAt
uploadId
partSize
uploadedParts {
partNumber
etag
}
}
}`,
};
export const getBlobUploadPartUrlMutation = {
id: 'getBlobUploadPartUrlMutation' as const,
op: 'getBlobUploadPartUrl',
query: `mutation getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) {
getBlobUploadPartUrl(
workspaceId: $workspaceId
key: $key
uploadId: $uploadId
partNumber: $partNumber
) {
uploadUrl
headers
expiresAt
}
}`,
};
export const cancelSubscriptionMutation = {
id: 'cancelSubscriptionMutation' as const,
op: 'cancelSubscription',

View File

@@ -137,6 +137,44 @@ export interface BlobNotFoundDataType {
spaceId: Scalars['String']['output'];
}
export interface BlobUploadInit {
__typename?: 'BlobUploadInit';
alreadyUploaded: Maybe<Scalars['Boolean']['output']>;
blobKey: Scalars['String']['output'];
expiresAt: Maybe<Scalars['DateTime']['output']>;
headers: Maybe<Scalars['JSONObject']['output']>;
method: BlobUploadMethod;
partSize: Maybe<Scalars['Int']['output']>;
uploadId: Maybe<Scalars['String']['output']>;
uploadUrl: Maybe<Scalars['String']['output']>;
uploadedParts: Maybe<Array<BlobUploadedPart>>;
}
/** Blob upload method */
export enum BlobUploadMethod {
GRAPHQL = 'GRAPHQL',
MULTIPART = 'MULTIPART',
PRESIGNED = 'PRESIGNED',
}
export interface BlobUploadPart {
__typename?: 'BlobUploadPart';
expiresAt: Maybe<Scalars['DateTime']['output']>;
headers: Maybe<Scalars['JSONObject']['output']>;
uploadUrl: Scalars['String']['output'];
}
export interface BlobUploadPartInput {
etag: Scalars['String']['input'];
partNumber: Scalars['Int']['input'];
}
export interface BlobUploadedPart {
__typename?: 'BlobUploadedPart';
etag: Scalars['String']['output'];
partNumber: Scalars['Int']['output'];
}
export enum ChatHistoryOrder {
asc = 'asc',
desc = 'desc',
@@ -838,6 +876,7 @@ export enum ErrorNames {
ALREADY_IN_SPACE = 'ALREADY_IN_SPACE',
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
BAD_REQUEST = 'BAD_REQUEST',
BLOB_INVALID = 'BLOB_INVALID',
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED',
CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE = 'CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE',
@@ -1370,6 +1409,7 @@ export interface MissingOauthQueryParameterDataType {
export interface Mutation {
__typename?: 'Mutation';
abortBlobUpload: Scalars['Boolean']['output'];
acceptInviteById: Scalars['Boolean']['output'];
activateLicense: License;
/** add a blob to context */
@@ -1392,6 +1432,8 @@ export interface Mutation {
claimAudioTranscription: Maybe<TranscriptionResultType>;
/** Cleanup sessions */
cleanupCopilotSession: Array<Scalars['String']['output']>;
completeBlobUpload: Scalars['String']['output'];
createBlobUpload: BlobUploadInit;
/** Create change password url */
createChangePasswordUrl: Scalars['String']['output'];
/** Create a subscription checkout link of stripe */
@@ -1430,6 +1472,7 @@ export interface Mutation {
forkCopilotSession: Scalars['String']['output'];
generateLicenseKey: Scalars['String']['output'];
generateUserAccessToken: RevealedAccessToken;
getBlobUploadPartUrl: BlobUploadPart;
grantDocUserRoles: Scalars['Boolean']['output'];
grantMember: Scalars['Boolean']['output'];
/** import users */
@@ -1527,6 +1570,12 @@ export interface Mutation {
verifyEmail: Scalars['Boolean']['output'];
}
export interface MutationAbortBlobUploadArgs {
key: Scalars['String']['input'];
uploadId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
}
export interface MutationAcceptInviteByIdArgs {
inviteId: Scalars['String']['input'];
sendAcceptMail?: InputMaybe<Scalars['Boolean']['input']>;
@@ -1599,6 +1648,20 @@ export interface MutationCleanupCopilotSessionArgs {
options: DeleteSessionInput;
}
export interface MutationCompleteBlobUploadArgs {
key: Scalars['String']['input'];
parts?: InputMaybe<Array<BlobUploadPartInput>>;
uploadId?: InputMaybe<Scalars['String']['input']>;
workspaceId: Scalars['String']['input'];
}
export interface MutationCreateBlobUploadArgs {
key: Scalars['String']['input'];
mime: Scalars['String']['input'];
size: Scalars['Int']['input'];
workspaceId: Scalars['String']['input'];
}
export interface MutationCreateChangePasswordUrlArgs {
callbackUrl: Scalars['String']['input'];
userId: Scalars['String']['input'];
@@ -1693,6 +1756,13 @@ export interface MutationGenerateUserAccessTokenArgs {
input: GenerateAccessTokenInput;
}
export interface MutationGetBlobUploadPartUrlArgs {
key: Scalars['String']['input'];
partNumber: Scalars['Int']['input'];
uploadId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
}
export interface MutationGrantDocUserRolesArgs {
input: GrantDocUserRolesInput;
}
@@ -3411,6 +3481,73 @@ export type SetBlobMutationVariables = Exact<{
export type SetBlobMutation = { __typename?: 'Mutation'; setBlob: string };
export type AbortBlobUploadMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
key: Scalars['String']['input'];
uploadId: Scalars['String']['input'];
}>;
export type AbortBlobUploadMutation = {
__typename?: 'Mutation';
abortBlobUpload: boolean;
};
export type CompleteBlobUploadMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
key: Scalars['String']['input'];
uploadId?: InputMaybe<Scalars['String']['input']>;
parts?: InputMaybe<Array<BlobUploadPartInput> | BlobUploadPartInput>;
}>;
export type CompleteBlobUploadMutation = {
__typename?: 'Mutation';
completeBlobUpload: string;
};
export type CreateBlobUploadMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
key: Scalars['String']['input'];
size: Scalars['Int']['input'];
mime: Scalars['String']['input'];
}>;
export type CreateBlobUploadMutation = {
__typename?: 'Mutation';
createBlobUpload: {
__typename?: 'BlobUploadInit';
method: BlobUploadMethod;
blobKey: string;
alreadyUploaded: boolean | null;
uploadUrl: string | null;
headers: any | null;
expiresAt: string | null;
uploadId: string | null;
partSize: number | null;
uploadedParts: Array<{
__typename?: 'BlobUploadedPart';
partNumber: number;
etag: string;
}> | null;
};
};
export type GetBlobUploadPartUrlMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
key: Scalars['String']['input'];
uploadId: Scalars['String']['input'];
partNumber: Scalars['Int']['input'];
}>;
export type GetBlobUploadPartUrlMutation = {
__typename?: 'Mutation';
getBlobUploadPartUrl: {
__typename?: 'BlobUploadPart';
uploadUrl: string;
headers: any | null;
expiresAt: string | null;
};
};
export type CancelSubscriptionMutationVariables = Exact<{
plan?: InputMaybe<SubscriptionPlan>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
@@ -6824,6 +6961,26 @@ export type Mutations =
variables: SetBlobMutationVariables;
response: SetBlobMutation;
}
| {
name: 'abortBlobUploadMutation';
variables: AbortBlobUploadMutationVariables;
response: AbortBlobUploadMutation;
}
| {
name: 'completeBlobUploadMutation';
variables: CompleteBlobUploadMutationVariables;
response: CompleteBlobUploadMutation;
}
| {
name: 'createBlobUploadMutation';
variables: CreateBlobUploadMutationVariables;
response: CreateBlobUploadMutation;
}
| {
name: 'getBlobUploadPartUrlMutation';
variables: GetBlobUploadPartUrlMutationVariables;
response: GetBlobUploadPartUrlMutation;
}
| {
name: 'cancelSubscriptionMutation';
variables: CancelSubscriptionMutationVariables;