feat!: affine cloud support (#3813)

Co-authored-by: Hongtao Lye <codert.sn@gmail.com>
Co-authored-by: liuyi <forehalo@gmail.com>
Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: X1a0t <405028157@qq.com>
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: Qi <474021214@qq.com>
Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
Alex Yang
2023-08-29 05:07:05 -05:00
committed by GitHub
parent d0145c6f38
commit 2f6c4e3696
414 changed files with 19469 additions and 7591 deletions

View File

@@ -7,6 +7,7 @@
"./type": "./src/type.ts",
"./migration": "./src/migration/index.ts",
"./local/crud": "./src/local/crud.ts",
"./affine/gql": "./src/affine/gql.ts",
"./providers": "./src/providers/index.ts"
},
"peerDependencies": {
@@ -17,6 +18,8 @@
"@affine-test/fixtures": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
"@affine/y-provider": "workspace:*",
"@toeverything/hooks": "workspace:*",
"@toeverything/y-indexeddb": "workspace:*",
"async-call-rpc": "^6.3.1",
@@ -26,6 +29,8 @@
"lib0": "^0.2.83",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "^4.7.1",
"swr": "^2.2.1",
"y-protocols": "^1.0.5",
"yjs": "^13.6.7",
"zod": "^3.22.2"

View File

@@ -0,0 +1,146 @@
/**
* @vitest-environment happy-dom
*/
import { uploadAvatarMutation } from '@affine/graphql';
import { render } from '@testing-library/react';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMutation, useQuery } from '../gql';
let fetch: Mock;
describe('GraphQL wrapper for SWR', () => {
beforeEach(() => {
fetch = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ data: { hello: 1 } }), {
headers: {
'content-type': 'application/json',
},
})
)
);
vi.stubGlobal('fetch', fetch);
});
afterEach(() => {
fetch.mockReset();
});
describe('useQuery', () => {
const Component = ({ id }: { id: number }) => {
const { data, isLoading, error } = useQuery({
query: {
id: 'query',
query: `
query {
hello
}
`,
operationName: 'query',
definitionName: 'query',
},
// @ts-expect-error forgive the fake variables
variables: { id },
});
if (isLoading) {
return <div>loading</div>;
}
if (error) {
return <div>error</div>;
}
// @ts-expect-error
return <div>number: {data!.hello}</div>;
};
it('should send query correctly', async () => {
const component = <Component id={1} />;
const renderer = render(component);
const el = await renderer.findByText('number: 1');
expect(el).toMatchInlineSnapshot(`
<div>
number:${' '}
1
</div>
`);
});
it('should not send request if cache hit', async () => {
const component = <Component id={2} />;
const renderer = render(component);
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(fetch).toBeCalledTimes(1);
render(<Component id={3} />);
expect(fetch).toBeCalledTimes(2);
});
});
describe('useMutation', () => {
const Component = () => {
const { trigger, error, isMutating } = useMutation({
mutation: {
id: 'mutation',
query: `
mutation {
hello
}
`,
operationName: 'mutation',
definitionName: 'mutation',
},
});
if (isMutating) {
return <div>mutating</div>;
}
if (error) {
return <div>error</div>;
}
return (
<div>
<button onClick={() => trigger()}>click</button>
</div>
);
};
it('should trigger mutation', async () => {
const component = <Component />;
const renderer = render(component);
const button = await renderer.findByText('click');
button.click();
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(renderer.asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div>
mutating
</div>
</DocumentFragment>
`);
});
it('should get rid of generated types', async () => {
function _NotActuallyRunDefinedForTypeTesting() {
const { trigger } = useMutation({
mutation: uploadAvatarMutation,
});
trigger({
id: '1',
avatar: new File([''], 'avatar.png'),
});
}
expect(_NotActuallyRunDefinedForTypeTesting).toBeTypeOf('function');
});
});
});

View File

@@ -0,0 +1,131 @@
import { setupGlobal } from '@affine/env/global';
import type {
GraphQLQuery,
MutationOptions,
QueryOptions,
QueryResponse,
QueryVariables,
} from '@affine/graphql';
import { gqlFetcherFactory } from '@affine/graphql';
import type { GraphQLError } from 'graphql';
import type { Key, SWRConfiguration, SWRResponse } from 'swr';
import useSWR from 'swr';
import type {
SWRMutationConfiguration,
SWRMutationResponse,
} from 'swr/mutation';
import useSWRMutation from 'swr/mutation';
setupGlobal();
export const fetcher = gqlFetcherFactory(
runtimeConfig.serverUrlPrefix + '/graphql'
);
/**
* A `useSWR` wrapper for sending graphql queries
*
* @example
*
* ```ts
* import { someQuery, someQueryWithNoVars } from '@affine/graphql'
*
* const swrResponse1 = useQuery({
* query: workspaceByIdQuery,
* variables: { id: '1' }
* })
*
* const swrResponse2 = useQuery({
* query: someQueryWithNoVars
* })
* ```
*/
export function useQuery<Query extends GraphQLQuery>(
options: QueryOptions<Query>
): SWRResponse<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
{
suspense: true;
}
>;
export function useQuery<Query extends GraphQLQuery>(
options: QueryOptions<Query>,
config: Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
typeof fetcher<Query>
>,
'fetcher'
>
): SWRResponse<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
{
suspense: true;
}
>;
export function useQuery<Query extends GraphQLQuery>(
options: QueryOptions<Query>,
config?: any
) {
return useSWR(
() => ['cloud', options.query.id, options.variables],
() => fetcher(options),
config
);
}
/**
* A useSWRMutation wrapper for sending graphql mutations
*
* @example
*
* ```ts
* import { someMutation } from '@affine/graphql'
*
* const { trigger } = useMutation({
* mutation: someMutation,
* })
*
* trigger({ name: 'John Doe' })
*/
export function useMutation<Mutation extends GraphQLQuery, K extends Key = Key>(
options: Omit<MutationOptions<Mutation>, 'variables'>
): SWRMutationResponse<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
K,
QueryVariables<Mutation>
>;
export function useMutation<Mutation extends GraphQLQuery, K extends Key = Key>(
options: Omit<MutationOptions<Mutation>, 'variables'>,
config: Omit<
SWRMutationConfiguration<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
K,
QueryVariables<Mutation>
>,
'fetcher'
>
): SWRMutationResponse<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
K,
QueryVariables<Mutation>
>;
export function useMutation(
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>,
config?: any
) {
return useSWRMutation(
() => ['cloud', options.mutation.id],
(_: unknown[], { arg }: { arg: any }) =>
fetcher({ ...options, query: options.mutation, variables: arg }),
config
);
}
export const gql = fetcher;

View File

@@ -0,0 +1,184 @@
import type { DatasourceDocAdapter } from '@affine/y-provider';
import type { Socket } from 'socket.io-client';
import { Manager } from 'socket.io-client';
import {
applyAwarenessUpdate,
type Awareness,
encodeAwarenessUpdate,
} from 'y-protocols/awareness';
import type { Doc } from 'yjs';
import {
type AwarenessChanges,
base64ToUint8Array,
uint8ArrayToBase64,
} from './utils';
let ioManager: Manager | null = null;
// use lazy initialization to avoid global side effect
function getIoManager(): Manager {
if (ioManager) {
return ioManager;
}
ioManager = new Manager(runtimeConfig.serverUrlPrefix + '/', {
autoConnect: false,
});
return ioManager;
}
export const createAffineDataSource = (
id: string,
rootDoc: Doc,
awareness: Awareness
) => {
if (id !== rootDoc.guid) {
console.warn('important!! please use doc.guid as roomName');
}
const socket = getIoManager().socket('/');
return {
get socket() {
return socket;
},
queryDocState: async (guid, options) => {
const stateVector = options?.stateVector
? await uint8ArrayToBase64(options.stateVector)
: undefined;
return new Promise((resolve, reject) => {
socket.emit(
'doc-load',
{
workspaceId: rootDoc.guid,
guid,
stateVector,
},
(docState: Error | { missing: string; state: string } | null) => {
if (docState instanceof Error) {
reject(docState);
return;
}
resolve(
docState
? {
missing: base64ToUint8Array(docState.missing),
state: docState.state
? base64ToUint8Array(docState.state)
: undefined,
}
: false
);
}
);
});
},
sendDocUpdate: async (guid: string, update: Uint8Array) => {
socket.emit('client-update', {
workspaceId: rootDoc.guid,
guid,
update: await uint8ArrayToBase64(update),
});
return Promise.resolve();
},
onDocUpdate: callback => {
socket.on('connect', () => {
socket.emit('client-handshake', rootDoc.guid);
});
const onUpdate = async (message: {
workspaceId: string;
guid: string;
update: string;
}) => {
if (message.workspaceId === rootDoc.guid) {
callback(message.guid, base64ToUint8Array(message.update));
}
};
socket.on('server-update', onUpdate);
const destroyAwareness = setupAffineAwareness(socket, rootDoc, awareness);
socket.connect();
return () => {
socket.emit('client-leave', rootDoc.guid);
socket.off('server-update', onUpdate);
destroyAwareness();
socket.disconnect();
};
},
} satisfies DatasourceDocAdapter & { readonly socket: Socket };
};
function setupAffineAwareness(
conn: Socket,
rootDoc: Doc,
awareness: Awareness
) {
const awarenessBroadcast = ({
workspaceId,
awarenessUpdate,
}: {
workspaceId: string;
awarenessUpdate: string;
}) => {
if (workspaceId !== rootDoc.guid) {
return;
}
applyAwarenessUpdate(
awareness,
base64ToUint8Array(awarenessUpdate),
'server'
);
};
const awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
if (origin === 'server') {
return;
}
const changedClients = Object.values(changes).reduce((res, cur) => [
...res,
...cur,
]);
const update = encodeAwarenessUpdate(awareness, changedClients);
uint8ArrayToBase64(update)
.then(encodedUpdate => {
conn.emit('awareness-update', {
workspaceId: rootDoc.guid,
awarenessUpdate: encodedUpdate,
});
})
.catch(err => console.error(err));
};
const newClientAwarenessInitHandler = () => {
const awarenessUpdate = encodeAwarenessUpdate(awareness, [
awareness.clientID,
]);
uint8ArrayToBase64(awarenessUpdate)
.then(encodedAwarenessUpdate => {
conn.emit('awareness-update', {
guid: rootDoc.guid,
awarenessUpdate: encodedAwarenessUpdate,
});
})
.catch(err => console.error(err));
};
conn.on('server-awareness-broadcast', awarenessBroadcast);
conn.on('new-client-awareness-init', newClientAwarenessInitHandler);
awareness.on('update', awarenessUpdate);
conn.on('connect', () => {
conn.emit('awareness-init', rootDoc.guid);
});
return () => {
awareness.off('update', awarenessUpdate);
conn.off('server-awareness-broadcast', awarenessBroadcast);
conn.off('new-client-awareness-init', newClientAwarenessInitHandler);
};
}

View File

@@ -0,0 +1,45 @@
import type { Doc as YDoc } from 'yjs';
export type SubdocEvent = {
loaded: Set<YDoc>;
removed: Set<YDoc>;
added: Set<YDoc>;
};
export type UpdateHandler = (update: Uint8Array, origin: unknown) => void;
export type SubdocsHandler = (event: SubdocEvent) => void;
export type DestroyHandler = () => void;
export type AwarenessChanges = Record<
'added' | 'updated' | 'removed',
number[]
>;
export function uint8ArrayToBase64(array: Uint8Array): Promise<string> {
return new Promise<string>(resolve => {
// Create a blob from the Uint8Array
const blob = new Blob([array]);
const reader = new FileReader();
reader.onload = function () {
const dataUrl = reader.result as string | null;
if (!dataUrl) {
resolve('');
return;
}
// The result includes the `data:` URL prefix and the MIME type. We only want the Base64 data
const base64 = dataUrl.split(',')[1];
resolve(base64);
};
reader.readAsDataURL(blob);
});
}
export function base64ToUint8Array(base64: string) {
const binaryString = atob(base64);
const binaryArray = binaryString.split('').map(function (char) {
return char.charCodeAt(0);
});
return new Uint8Array(binaryArray);
}

View File

@@ -1,7 +1,11 @@
import type { WorkspaceAdapter } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import type { BlockHub } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import { assertEquals, assertExists } from '@blocksuite/global/utils';
import {
currentPageIdAtom,
currentWorkspaceIdAtom,
} from '@toeverything/infra/atom';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import { atom } from 'jotai';
import { z } from 'zod';
@@ -58,7 +62,7 @@ export const workspaceAdaptersAtom = atom<
/**
* root workspaces atom
* this atom stores the metadata of all workspaces,
* which is `id` and `flavor`, that is enough to load the real workspace data
* which is `id` and `flavor,` that is enough to load the real workspace data
*/
const METADATA_STORAGE_KEY = 'jotai-workspaces';
const rootWorkspacesMetadataPrimitiveAtom = atom<Promise<
@@ -69,10 +73,12 @@ const rootWorkspacesMetadataPromiseAtom = atom<
>(async (get, { signal }) => {
const WorkspaceAdapters = get(workspaceAdaptersAtom);
assertExists(WorkspaceAdapters, 'workspace adapter should be defined');
const maybeMetadata = get(rootWorkspacesMetadataPrimitiveAtom);
if (maybeMetadata !== null) {
return maybeMetadata;
}
const primitiveMetadata = get(rootWorkspacesMetadataPrimitiveAtom);
assertEquals(
primitiveMetadata,
null,
'rootWorkspacesMetadataPrimitiveAtom should be null'
);
if (environment.isServer) {
// return a promise in SSR to avoid the hydration mismatch
@@ -127,6 +133,13 @@ const rootWorkspacesMetadataPromiseAtom = atom<
for (const Adapter of Adapters) {
const { CRUD, flavour: currentFlavour } = Adapter;
if (
Adapter.Events['app:access'] &&
!(await Adapter.Events['app:access']())
) {
// skip the adapter if the user doesn't have access to it
continue;
}
try {
const item = await CRUD.list();
// remove the metadata that is not in the list
@@ -182,7 +195,10 @@ type SetStateAction<Value> = Value | ((prev: Value) => Value);
export const rootWorkspacesMetadataAtom = atom<
Promise<RootWorkspaceMetadata[]>,
[SetStateAction<RootWorkspaceMetadata[]>],
[
setStateAction: SetStateAction<RootWorkspaceMetadata[]>,
newWorkspaceId?: string,
],
void
>(
async get => {
@@ -192,8 +208,11 @@ export const rootWorkspacesMetadataAtom = atom<
}
return get(rootWorkspacesMetadataPromiseAtom);
},
async (get, set, action) => {
async (get, set, action, newWorkspaceId) => {
const metadataPromise = get(rootWorkspacesMetadataPromiseAtom);
const oldWorkspaceId = get(currentWorkspaceIdAtom);
const oldPageId = get(currentPageIdAtom);
// get metadata
set(rootWorkspacesMetadataPrimitiveAtom, async maybeMetadataPromise => {
let metadata: RootWorkspaceMetadata[] =
@@ -211,6 +230,17 @@ export const rootWorkspacesMetadataAtom = atom<
// write back to localStorage
rootWorkspaceMetadataArraySchema.parse(metadata);
localStorage.setItem(METADATA_STORAGE_KEY, JSON.stringify(metadata));
// if the current workspace is deleted, reset the current workspace
if (oldWorkspaceId && metadata.some(x => x.id === oldWorkspaceId)) {
set(currentWorkspaceIdAtom, oldWorkspaceId);
set(currentPageIdAtom, oldPageId);
}
if (newWorkspaceId) {
set(currentPageIdAtom, null);
set(currentWorkspaceIdAtom, newWorkspaceId);
}
return metadata;
});
}

View File

@@ -0,0 +1,51 @@
import {
deleteBlobMutation,
fetchWithReport,
listBlobsQuery,
setBlobMutation,
} from '@affine/graphql';
import type { BlobStorage } from '@blocksuite/store';
import { fetcher } from '../affine/gql';
export const createCloudBlobStorage = (workspaceId: string): BlobStorage => {
return {
crud: {
get: async key => {
return fetchWithReport(
runtimeConfig.serverUrlPrefix +
`/api/workspaces/${workspaceId}/blobs/${key}`
).then(res => res.blob());
},
set: async (key, value) => {
const result = await fetcher({
query: setBlobMutation,
variables: {
workspaceId,
blob: new File([value], key),
},
});
console.assert(result.setBlob === key, 'Blob hash mismatch');
return key;
},
list: async () => {
const result = await fetcher({
query: listBlobsQuery,
variables: {
workspaceId,
},
});
return result.listBlobs;
},
delete: async (key: string) => {
await fetcher({
query: deleteBlobMutation,
variables: {
workspaceId,
hash: key,
},
});
},
},
};
};

View File

@@ -25,10 +25,14 @@ export const createStaticStorage = (): BlobStorage => {
get: async (key: string) => {
if (key.startsWith('/static/')) {
const response = await fetch(key);
return response.blob();
if (response.ok) {
return response.blob();
}
} else if (predefinedStaticFiles.includes(key)) {
const response = await fetch(`/static/${key}.png`);
return response.blob();
if (response.ok) {
return response.blob();
}
}
return null;
},

View File

@@ -98,26 +98,10 @@ export const CRUD: WorkspaceCRUD<WorkspaceFlavour.LOCAL> = {
list: async () => {
logger.debug('list');
const storage = getStorage();
let allWorkspaceIDs: string[] = Array.isArray(
storage.getItem(kStoreKey, [])
)
? (storage.getItem(kStoreKey, []) as z.infer<typeof schema>)
: [];
const allWorkspaceIDs: string[] = storage.getItem(kStoreKey, []) as z.infer<
typeof schema
>;
// workspaces in desktop
if (
window.apis &&
environment.isDesktop &&
runtimeConfig.enableSQLiteProvider
) {
const desktopIds = (await window.apis.workspace.list()).map(([id]) => id);
// the ids maybe a subset of the local storage
const moreWorkspaces = desktopIds.filter(
id => !allWorkspaceIDs.includes(id)
);
allWorkspaceIDs = [...allWorkspaceIDs, ...moreWorkspaces];
storage.setItem(kStoreKey, allWorkspaceIDs);
}
const workspaces = (
await Promise.all(allWorkspaceIDs.map(id => CRUD.get(id)))
).filter(item => item !== null) as LocalWorkspace[];

View File

@@ -1,6 +1,7 @@
import { isBrowser, isDesktop } from '@affine/env/constant';
import type { BlockSuiteFeatureFlags } from '@affine/env/global';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { createAffinePublicProviders } from '@affine/workspace/providers';
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import type { DocProviderCreator, StoreOptions } from '@blocksuite/store';
import {
@@ -13,6 +14,7 @@ import { INTERNAL_BLOCKSUITE_HASH_MAP } from '@toeverything/infra/__internal__/w
import type { Doc } from 'yjs';
import type { Transaction } from 'yjs';
import { createCloudBlobStorage } from '../blob/cloud-blob-storage';
import { createStaticStorage } from '../blob/local-static-storage';
import { createSQLiteStorage } from '../blob/sqlite-blob-storage';
import { createAffineProviders, createLocalProviders } from '../providers';
@@ -95,18 +97,18 @@ export function getOrCreateWorkspace(
const idGenerator = Generator.NanoID;
const blobStorages: StoreOptions['blobStorages'] = [];
if (flavour === WorkspaceFlavour.AFFINE_CLOUD) {
if (isBrowser) {
blobStorages.push(createIndexeddbStorage);
blobStorages.push(createCloudBlobStorage);
if (isDesktop && runtimeConfig.enableSQLiteProvider) {
blobStorages.push(createSQLiteStorage);
}
providerCreators.push(...createAffineProviders());
// todo(JimmFly): add support for cloud storage
}
providerCreators.push(...createAffineProviders());
} else {
} else if (flavour === WorkspaceFlavour.LOCAL) {
if (isBrowser) {
blobStorages.push(createIndexeddbStorage);
if (isDesktop && runtimeConfig.enableSQLiteProvider) {
@@ -114,6 +116,17 @@ export function getOrCreateWorkspace(
}
}
providerCreators.push(...createLocalProviders());
} else if (flavour === WorkspaceFlavour.AFFINE_PUBLIC) {
if (isBrowser) {
blobStorages.push(createIndexeddbStorage);
if (isDesktop && runtimeConfig.enableSQLiteProvider) {
blobStorages.push(createSQLiteStorage);
}
}
blobStorages.push(createCloudBlobStorage);
providerCreators.push(...createAffinePublicProviders());
} else {
throw new Error('unsupported flavour');
}
blobStorages.push(createStaticStorage);

View File

@@ -0,0 +1,103 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import type { AffineSocketIOProvider } from '@affine/env/workspace';
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import { Schema, Workspace } from '@blocksuite/store';
import { describe, expect, test } from 'vitest';
import * as awarenessProtocol from 'y-protocols/awareness';
import { Doc } from 'yjs';
import { createAffineSocketIOProvider } from '..';
const schema = new Schema();
schema.register(AffineSchemas).register(__unstableSchemas);
describe('sockio provider', () => {
test.skip('test storage', async () => {
const workspaceId = 'test-storage-ws';
{
const workspace = new Workspace({
id: workspaceId,
isSSR: true,
schema,
});
const provider = createAffineSocketIOProvider(
workspace.id,
workspace.doc,
{
awareness: workspace.awarenessStore.awareness,
}
) as AffineSocketIOProvider;
provider.connect();
const page = workspace.createPage({
id: 'page',
});
await page.waitForLoaded();
page.addBlock('affine:page', {
title: new page.Text('123123'),
});
await new Promise(resolve => setTimeout(resolve, 1000));
}
{
const workspace = new Workspace({
id: workspaceId,
isSSR: true,
schema,
});
const provider = createAffineSocketIOProvider(
workspace.id,
workspace.doc,
{
awareness: workspace.awarenessStore.awareness,
}
) as AffineSocketIOProvider;
provider.connect();
await new Promise(resolve => setTimeout(resolve, 1000));
const page = workspace.getPage('page')!;
await page.waitForLoaded();
const block = page.getBlockByFlavour('affine:page');
expect(block[0].flavour).toEqual('affine:page');
}
});
test.skip('test collaboration', async () => {
const workspaceId = 'test-collboration-ws';
{
const doc = new Doc({ guid: workspaceId });
const provider = createAffineSocketIOProvider(doc.guid, doc, {
awareness: new awarenessProtocol.Awareness(doc),
}) as AffineSocketIOProvider;
const doc2 = new Doc({ guid: workspaceId });
const provider2 = createAffineSocketIOProvider(doc2.guid, doc2, {
awareness: new awarenessProtocol.Awareness(doc2),
}) as AffineSocketIOProvider;
provider.connect();
provider2.connect();
await new Promise(resolve => setTimeout(resolve, 500));
const subdoc = new Doc();
const folder = doc.getMap();
folder.set('subDoc', subdoc);
subdoc.getText().insert(0, 'subDoc content');
await new Promise(resolve => setTimeout(resolve, 1000));
expect(
(doc2.getMap().get('subDoc') as Doc).getText().toJSON(),
'subDoc content'
);
}
});
});

View File

@@ -0,0 +1,88 @@
import { DebugLogger } from '@affine/debug';
import { fetchWithReport } from '@affine/graphql';
import type { ActiveDocProvider, DocProviderCreator } from '@blocksuite/store';
import { Workspace } from '@blocksuite/store';
import type { Doc } from 'yjs';
const Y = Workspace.Y;
const logger = new DebugLogger('affine:cloud');
export async function downloadBinaryFromCloud(
rootGuid: string,
pageGuid: string
) {
const response = await fetchWithReport(
runtimeConfig.serverUrlPrefix +
`/api/workspaces/${rootGuid}/docs/${pageGuid}`
);
if (response.ok) {
return response.arrayBuffer();
}
return false;
}
async function downloadBinary(rootGuid: string, doc: Doc) {
const buffer = await downloadBinaryFromCloud(rootGuid, doc.guid);
if (buffer) {
Y.applyUpdate(doc, new Uint8Array(buffer), 'affine-cloud');
}
}
export const createCloudDownloadProvider: DocProviderCreator = (
id,
doc
): ActiveDocProvider => {
let _resolve: () => void;
let _reject: (error: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
_resolve = resolve;
_reject = reject;
});
return {
flavour: 'affine-cloud-download',
active: true,
sync() {
downloadBinary(id, doc)
.then(() => {
logger.info(`Downloaded ${id}`);
_resolve();
})
.catch(_reject);
},
get whenReady() {
return promise;
},
};
};
export const createMergeCloudSnapshotProvider: DocProviderCreator = (
id,
doc
): ActiveDocProvider => {
let _resolve: () => void;
const promise = new Promise<void>(resolve => {
_resolve = resolve;
});
return {
flavour: 'affine-cloud-merge-snapshot',
active: true,
sync() {
downloadBinary(id, doc)
.then(() => {
logger.info(`Downloaded ${id}`);
_resolve();
})
// ignore error
.catch(e => {
console.error(e);
_resolve();
});
},
get whenReady() {
return promise;
},
};
};

View File

@@ -1,8 +1,10 @@
import { DebugLogger } from '@affine/debug';
import type {
AffineSocketIOProvider,
LocalIndexedDBBackgroundProvider,
LocalIndexedDBDownloadProvider,
} from '@affine/env/workspace';
import { createLazyProvider } from '@affine/y-provider';
import { assertExists } from '@blocksuite/global/utils';
import type { DocProviderCreator } from '@blocksuite/store';
import { Workspace } from '@blocksuite/store';
@@ -13,6 +15,12 @@ import {
} from '@toeverything/y-indexeddb';
import type { Doc } from 'yjs';
import { createAffineDataSource } from '../affine';
import {
createCloudDownloadProvider,
createMergeCloudSnapshotProvider,
downloadBinaryFromCloud,
} from './cloud';
import {
createSQLiteDBDownloadProvider,
createSQLiteProvider,
@@ -21,6 +29,18 @@ import {
const Y = Workspace.Y;
const logger = new DebugLogger('indexeddb-provider');
const createAffineSocketIOProvider: DocProviderCreator = (
id,
doc,
{ awareness }
): AffineSocketIOProvider => {
const dataSource = createAffineDataSource(id, doc, awareness);
return {
flavour: 'affine-socket-io',
...createLazyProvider(doc, dataSource),
};
};
const createIndexedDBBackgroundProvider: DocProviderCreator = (
id,
blockSuiteWorkspace
@@ -72,6 +92,7 @@ const createIndexedDBDownloadProvider: DocProviderCreator = (
Y.applyUpdate(doc, binary, indexedDBDownloadOrigin);
}
}
return {
flavour: 'local-indexeddb',
active: true,
@@ -89,11 +110,13 @@ const createIndexedDBDownloadProvider: DocProviderCreator = (
};
export {
createAffineSocketIOProvider,
createBroadcastChannelProvider,
createIndexedDBBackgroundProvider,
createIndexedDBDownloadProvider,
createSQLiteDBDownloadProvider,
createSQLiteProvider,
downloadBinaryFromCloud,
};
export const createLocalProviders = (): DocProviderCreator[] => {
@@ -116,9 +139,16 @@ export const createLocalProviders = (): DocProviderCreator[] => {
export const createAffineProviders = (): DocProviderCreator[] => {
return (
[
...createLocalProviders(),
runtimeConfig.enableBroadcastChannelProvider &&
createBroadcastChannelProvider,
runtimeConfig.enableCloud && createAffineSocketIOProvider,
runtimeConfig.enableCloud && createMergeCloudSnapshotProvider,
createIndexedDBDownloadProvider,
] as DocProviderCreator[]
).filter(v => Boolean(v));
};
export const createAffinePublicProviders = (): DocProviderCreator[] => {
return [createCloudDownloadProvider];
};

View File

@@ -24,10 +24,18 @@ const createDatasource = (workspaceId: string): DatasourceDocAdapter => {
return {
queryDocState: async guid => {
return window.apis.db.getDocAsUpdates(
const update = await window.apis.db.getDocAsUpdates(
workspaceId,
workspaceId === guid ? undefined : guid
);
if (update) {
return {
missing: update,
};
}
return false;
},
sendDocUpdate: async (guid, update) => {
return window.apis.db.applyDocUpdate(

View File

@@ -8,9 +8,11 @@
"references": [
{ "path": "../../tests/fixtures" },
{ "path": "../y-indexeddb" },
{ "path": "../y-provider" },
{ "path": "../env" },
{ "path": "../debug" },
{ "path": "../hooks" },
{ "path": "../infra" }
{ "path": "../infra" },
{ "path": "../graphql" }
]
}