Compare commits

...

99 Commits

Author SHA1 Message Date
EYHN
1effb2f25f fix(workspace): fix sync stuck (#5762) (#5772) 2024-02-01 17:41:49 +08:00
Joooye_34
9189d26332 feat: support sign-in with subscription coupon (#5768) 2024-02-01 17:03:29 +08:00
liuyi
79a8be7799 feat(server): allow pass coupon to checkout session (#5749) 2024-02-01 17:03:16 +08:00
liuyi
1a643cc70c fix(server): doc upsert race condition (#5755) 2024-01-31 21:36:35 +08:00
Cats Juice
4257b5f3a4 fix(core): set createDate to journal's date when journal created (#5701) 2024-01-30 23:13:02 +08:00
Cats Juice
ea17e86032 feat(core): ignore empty journals for page lists (#5744) 2024-01-30 17:21:20 +08:00
Joooye_34
48cd8999bd fix: static resource not found in web server (#5745) 2024-01-30 17:21:05 +08:00
李华桥
cdf1d9002e Merge branch 'canary' into stable 2024-01-29 17:53:10 +08:00
李华桥
79b39f14d2 Merge branch 'canary' into stable 2024-01-25 13:46:21 +08:00
李华桥
619420cfd1 chore: recover yarn.lock 2024-01-25 00:38:29 +08:00
李华桥
739e914b5f Merge branch 'canary' into stable 2024-01-25 00:33:28 +08:00
liuyi
5e9739eb3a fix(server): del staled update count cache if unmatch (#5674) 2024-01-23 16:55:49 +08:00
liuyi
0a89b7f528 fix(server): standalone early access users detection (#5601) 2024-01-16 11:39:36 +08:00
DarkSky
0a0ee37ac2 fix: return empty resp if user not exists in login preflight (#5588) 2024-01-13 23:30:01 +08:00
Peng Xiao
a143379161 fix(electron): remove cors headers hack (#5581) 2024-01-12 16:49:16 +08:00
regischen
8e7dedfe82 feat: bump blocksuite (#5575) 2024-01-12 12:43:56 +08:00
EYHN
d25a8547d0 refactor(core): move page list to core (#5556) 2024-01-12 12:43:45 +08:00
Peng Xiao
4d16229fea chore(core): remove affine/cmdk package (#5552)
patch cmdk based on https://github.com/pengx17/cmdk/tree/patch-1
fix https://github.com/toeverything/AFFiNE/issues/5548
2024-01-12 12:43:35 +08:00
EYHN
99371be7e8 fix(core): workspace not found after import (#5571)
close TOV-393
2024-01-12 11:05:59 +08:00
李华桥
34ed8dd7a5 Merge branch 'canary' into stable 2024-01-10 10:59:28 +08:00
李华桥
39b7b671b1 Merge branch 'canary' into stable 2024-01-09 19:44:52 +08:00
李华桥
207b56d5af Merge branch 'canary' into stable 2024-01-09 17:16:17 +08:00
DarkSky
9e94e7195b fix: use absolute path in gql client (#5454) (#5462) 2023-12-29 16:02:29 +08:00
Peng Xiao
de951c8779 fix(core): enable page history for beta/stable (#5415) 2023-12-27 14:39:59 +08:00
EYHN
fd37026ca5 fix(component): fix font display on safari (#5393)
before

![CleanShot 2023-12-25 at 13.09.26.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/4fe08951-67bb-4050-ba14-94391db1cac1.png)

after

![CleanShot 2023-12-25 at 13.09.13.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/fbfb17ec-b871-4746-9d3c-d24f850ecca1.png)
2023-12-27 14:39:50 +08:00
JimmFly
4fd5812a89 fix(core): avatars are not aligned (#5404) 2023-12-26 20:43:08 +08:00
Peng Xiao
d01e987ecc fix(core): trash page footer display issue (#5402)
Before

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/eb5e5b18-c4a2-469b-8763-be34c39ba736.png)

After

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7b3ef339-0cb5-44fe-9e75-cec0e97d28b7.png)
2023-12-26 20:42:54 +08:00
Joooye_34
d87c218c0b fix(electron): set stable base url to app.affine.pro (#5401)
close TOV-282
2023-12-26 20:42:41 +08:00
Peng Xiao
a5bf5cc244 fix(core): about setting blink issue (#5399) 2023-12-26 20:42:33 +08:00
Peng Xiao
16bcd6e76b fix(core): workpace list blink issue on open (#5400) 2023-12-26 20:42:19 +08:00
JimmFly
2e2ace8472 chore(core): add background color to questionnaire (#5396) 2023-12-26 20:42:06 +08:00
Cats Juice
37cff8fe8d fix(core): correct title of onboarding article-2 (#5387) 2023-12-26 20:41:58 +08:00
DarkSky
70ab3b4916 fix: use prefix in electron to prevent formdata bug (#5395) 2023-12-26 20:41:47 +08:00
EYHN
f42ba54578 fix(core): fix flickering workspace list (#5391) 2023-12-26 20:41:36 +08:00
EYHN
a67c8181fc fix(workspace): fix svg file with xml header (#5388) 2023-12-26 20:41:28 +08:00
regischen
613efbded9 feat: bump blocksuite (#5386) 2023-12-26 20:41:18 +08:00
李华桥
549419d102 Merge branch 'canary' into stable 2023-12-22 16:29:51 +08:00
李华桥
21c42f8771 Merge branch 'canary' into stable 2023-12-22 01:29:30 +08:00
李华桥
9012adda7a Merge branch 'canary' into stable 2023-12-21 18:42:56 +08:00
李华桥
fb442e9055 Merge branch 'canary' into stable 2023-12-21 16:22:57 +08:00
李华桥
a231474dd2 Merge branch 'canary' into stable 2023-12-21 14:26:01 +08:00
李华桥
833b42000b Merge branch 'canary' into stable 2023-12-20 16:36:44 +08:00
李华桥
7690c48710 Merge branch 'canary' into stable 2023-12-20 16:32:36 +08:00
DarkSky
579828a700 fix: use secure websocket (#5297) 2023-12-13 22:28:04 +08:00
DarkSky
746db2ccfc feat: only follow serverUrlPrefix at redirect to client (#5295) 2023-12-13 20:37:20 +08:00
李华桥
eff344a9c1 Merge branch 'canary' into stable 2023-12-12 16:45:47 +08:00
李华桥
c89ebab596 Merge branch 'canary' into stable 2023-12-12 11:04:33 +08:00
liuyi
62f4421b7c fix(server): avoid updates persist forever (#5258) 2023-12-11 17:42:25 +08:00
李华桥
42383dbd29 Merge branch 'canary' into stable 2023-12-10 21:04:15 +08:00
李华桥
120e7397ba Merge branch 'canary' into stable 2023-12-01 16:12:17 +08:00
李华桥
24123ad01c Revert "Revert "Merge remote-tracking branch 'origin/canary' into stable""
This reverts commit 89197bacef.
2023-12-01 13:29:43 +08:00
李华桥
ad50320391 v0.10.3 2023-12-01 12:52:15 +08:00
李华桥
eb21a60dda v0.10.3-beta.7 2023-12-01 12:12:20 +08:00
Joooye_34
c0e3be2d40 fix(core): rerender error boundary when route change and improve sentry report (#5147) 2023-12-01 04:04:44 +00:00
李华桥
09d3b72358 v0.10.3-beta.6 2023-11-30 23:02:26 +08:00
Joooye_34
246e16c6c0 fix(infra): compatibility logic follow blocksuite (#5143) 2023-11-30 23:01:38 +08:00
李华桥
dc279d062b v0.10.3-beta.5 2023-11-30 16:49:55 +08:00
Joooye_34
47d5f9e1c2 fix(infra): use blocksuite api to check compatibility (#5137) 2023-11-30 08:48:13 +00:00
Joooye_34
a226eb8d5f fix(core): expose catched editor load error (#5133) 2023-11-29 20:31:35 +08:00
Joooye_34
908c4e1a6f ci: add sentry env when frontend assets build (#5131) 2023-11-29 10:03:49 +00:00
李华桥
1d0bcc80a0 v0.10.3-beta.4 2023-11-29 16:14:06 +08:00
Joooye_34
50010bd824 fix(core): implement editor timeout and report error from boundary (#5105) 2023-11-29 08:10:38 +00:00
liuyi
c0ede1326d fix(server): wrong OTEL config (#5084) 2023-11-29 11:19:13 +08:00
李华桥
89197bacef Revert "Merge remote-tracking branch 'origin/canary' into stable"
This reverts commit 992ed89a89, reversing
changes made to d272d7922d.
2023-11-29 11:18:45 +08:00
李华桥
f97d323ab5 Revert "Revert "refactor(server): standarderlize metrics and trace with OTEL (#5054)""
This reverts commit c1cd1713b9.
2023-11-29 11:07:28 +08:00
EYHN
2acb219dcc fix(workspace): filter awareness from other workspace (#5093) 2023-11-28 16:47:45 +08:00
LongYinan
992ed89a89 Merge remote-tracking branch 'origin/canary' into stable 2023-11-28 15:12:52 +08:00
李华桥
d272d7922d v0.10.3-beta.2 2023-11-25 23:50:40 +08:00
李华桥
c1cd1713b9 Revert "refactor(server): standarderlize metrics and trace with OTEL (#5054)"
This reverts commit 91efca107a.
2023-11-25 23:50:39 +08:00
李华桥
b20e91bee0 v0.10.3-beta.1 2023-11-25 14:14:40 +08:00
李华桥
9a4e5ec8c3 Merge branch 'canary' into stable 2023-11-25 14:14:14 +08:00
李华桥
2019838ae7 v0.10.3-beta.0 2023-11-24 11:39:23 +08:00
李华桥
30ff25f400 Merge branch 'canary' into stable 2023-11-23 23:40:32 +08:00
李华桥
e766208c18 chore: reset merge wrong codes 2023-11-23 22:53:06 +08:00
李华桥
8742f28148 Merge branch 'canary' into stable 2023-11-23 21:31:42 +08:00
LongYinan
cd291bb60e build: remove useless source-map-loader to speedup webpack (#4910) 2023-11-20 10:52:28 +08:00
LongYinan
62c0efcfd1 fix(core): handle the getSession network error properly (#4909)
If network offline or API error happens, the `session` returned by the `useSession` hook will be null, so we can't assume it is not null.

There should be following changes:
1. create a page in ErrorBoundary to let the user refetch the session.
2. The `SessionProvider` stop to pull the new session once the session is null, we need to figure out a way to pull the new session when the network is back or the user click the refetch button.
2023-11-17 16:50:48 +08:00
liuyi
87248b3337 fix(server): all viewers can share public link (#4968) 2023-11-17 12:34:15 +08:00
Joooye_34
00c940f7df chore: bump affine version to 0.10.2 (#4959) 2023-11-16 15:48:37 +08:00
Flrande
931b459fbd chore: bump blocksuite (#4958) 2023-11-16 14:27:39 +08:00
LongYinan
51e71f4a0a ci: prevent error if rust build is cached by nx (#4951)
If Rust build was cached by nx, only the output file will be presented. The chmod command will be failed in this case like: https://github.com/toeverything/AFFiNE/actions/runs/6874496337/job/18697360212
2023-11-16 10:31:51 +08:00
Peng Xiao
9b631f2328 fix(infra): page id compat fix for page ids in workspace.meta (#4950)
since we strip `page:` in keys of workspacedoc.spaces, we should also strip the prefix in meta.pages as well.
2023-11-15 17:36:08 +08:00
LongYinan
01f481a9b6 ci: only disable postinstall on macOS in nightly desktop build (#4938) 2023-11-14 23:00:30 +08:00
Joooye_34
0177ab5c87 fix(infra): workspace migration without blockVersions (#4936) 2023-11-14 14:38:11 +01:00
Peng Xiao
4db35d341c perf(component): use png instead of svg for rendering noise svg (#4935) 2023-11-14 11:52:51 +00:00
DarkSky
3c4a803c97 fix: change password token check (#4934) (#4932) 2023-11-14 11:15:54 +00:00
LongYinan
05154dc7ca ci: disable postinstall in nightly desktop build (#4930)
Should be part of https://github.com/toeverything/AFFiNE/pull/4885
2023-11-14 14:13:55 +08:00
Peng Xiao
c90b477f60 fix(core): change server url of stable to insider (#4902) (#4926) 2023-11-14 12:05:52 +08:00
李华桥
6f18ddbe85 v0.10.1 2023-11-13 19:49:26 +08:00
LongYinan
dde779a71d test(e2e): add subdoc migration test (#4921)
test(e2e): add subdoc migration test

fix: remove .only
2023-11-13 18:00:40 +08:00
Peng Xiao
bd9f66fbc7 fix(infra): compatibility fix for space prefix (#4912)
It seems there are some cases that [this upstream PR](https://github.com/toeverything/blocksuite/pull/4747) will cause data loss.

Because of some historical reasons, the page id could be different with its doc id.
It might be caused by subdoc migration in the following (not 100% sure if all white screen issue is caused by it) 0714c12703/packages/common/infra/src/blocksuite/index.ts (L538-L540)

In version 0.10, page id in spaces no longer has prefix "space:"
The data flow for fetching a doc's updates is:
- page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid`
if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId
- because of guid logic change, the doc that previously prefixed with `space:` will not be found in `doc.spaces`
- when fetching the rows of this doc using the doc id === page id,
  it will return EMPTY since there is no updates associated with the page id

The provided fix in the PR will patch the `spaces` field of the root doc so that after 0.10 the page doc can still be found in the `spaces` map. It shall apply to both of the idb & sqlite datasources.

Special thanks to @lawvs 's db file for investigation!
2023-11-13 17:57:56 +08:00
liuyi
92f1f40bfa fix(server): wrap updates applying in a transaction (#4922) 2023-11-13 08:49:30 +00:00
LongYinan
48dc1049b3 Merge pull request #4913 from toeverything/darksky/cleanup-depolyment
chore: cleanup deployment
2023-11-12 11:20:02 +08:00
DarkSky
9add530370 chore: cleanup deployment 2023-11-12 11:03:25 +08:00
LongYinan
b77460d871 Merge pull request #4908 from toeverything/61/hotfix-websocket-payload
fix(server): increase server acceptable websocket payload size
2023-11-10 22:01:48 +08:00
forehalo
42db41776b fix(server): increase server acceptable websocket payload size 2023-11-10 21:31:45 +08:00
李华桥
075439c74f fix(core): change server url of stable to insider 2023-11-10 18:32:53 +08:00
Yifeng Wang
fc6c553ece chore: bump theme (#4904)
Co-authored-by: 李华桥 <joooye1991@gmail.com>
2023-11-10 15:40:38 +08:00
Joooye_34
59cb3d5df1 fix(core): change server url of stable to insider (#4902) 2023-11-10 14:50:57 +08:00
19 changed files with 521 additions and 436 deletions

View File

@@ -1,6 +1,6 @@
FROM openresty/openresty:1.21.4.3-0-buster
WORKDIR /app
COPY ./packages/frontend/core/dist/index.html ./dist/index.html
COPY ./packages/frontend/core/dist ./dist
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf

View File

@@ -19,6 +19,7 @@ import {
import {
Cache,
CallTimer,
Config,
EventEmitter,
type EventPayload,
@@ -463,6 +464,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
});
}
@CallTimer('doc', 'upsert')
private async upsert(
workspaceId: string,
guid: string,
@@ -472,73 +474,87 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
updatedAt: Date,
initialSeq?: number
) {
return this.lockSnapshotForUpsert(workspaceId, guid, async () => {
const blob = Buffer.from(encodeStateAsUpdate(doc));
const blob = Buffer.from(encodeStateAsUpdate(doc));
if (isEmptyBuffer(blob)) {
return false;
if (isEmptyBuffer(blob)) {
return false;
}
const state = Buffer.from(encodeStateVector(doc));
await this.db.$queryRaw`BEGIN;`;
let committed = false;
const commit = async () => {
if (!committed) {
committed = true;
await this.db.$queryRaw`COMMIT;`;
}
};
try {
const [snapshot]: {
workspace_id: string;
id: string;
blob: Buffer;
state?: Buffer;
}[] = await this.db.$queryRaw`
-- LOCK TABLE "Snapshot" IN SHARE ROW EXCLUSIVE MODE;
SELECT * FROM snapshots WHERE workspace_id = ${workspaceId} AND guid = ${guid} limit 1
FOR UPDATE;
`;
const state = Buffer.from(encodeStateVector(doc));
return await this.db.$transaction(async db => {
const snapshot = await db.snapshot.findUnique({
where: {
id_workspaceId: {
id: guid,
workspaceId,
},
},
});
// update
if (snapshot) {
// only update if state is newer
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) {
await db.snapshot.update({
select: {
seq: true,
},
where: {
id_workspaceId: {
workspaceId,
id: guid,
},
},
data: {
blob,
state,
updatedAt,
},
});
return true;
} else {
return false;
}
} else {
// create
await db.snapshot.create({
// update
if (snapshot) {
// only update if state is newer
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) {
await this.db.snapshot.update({
select: {
seq: true,
},
where: {
id_workspaceId: {
workspaceId,
id: guid,
},
},
data: {
id: guid,
workspaceId,
blob,
state,
seq: initialSeq,
createdAt: updatedAt,
updatedAt,
},
});
return true;
} else {
return false;
}
});
});
}
} else {
// create
// no record exists, should commit the previous row lock first
await commit();
await this.db.snapshot.create({
select: {
seq: true,
},
data: {
id: guid,
workspaceId,
blob,
state,
seq: initialSeq,
createdAt: updatedAt,
updatedAt,
},
});
return true;
}
} catch (e) {
await this.db.$queryRaw`ROLLBACK;`;
throw e;
} finally {
await commit();
}
}
private async _get(
workspaceId: string,
guid: string
@@ -559,6 +575,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
* Squash updates into a single update and save it as snapshot,
* and delete the updates records at the same time.
*/
@CallTimer('doc', 'squash')
private async squash(updates: Update[], snapshot: Snapshot | null) {
if (!updates.length) {
throw new Error('No updates to squash');
@@ -761,18 +778,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
);
}
async lockSnapshotForUpsert<T>(
workspaceId: string,
guid: string,
job: () => Promise<T>
) {
return this.doWithLock(
'doc:manager:snapshot',
`${workspaceId}::${guid}`,
job
);
}
@Cron(CronExpression.EVERY_MINUTE)
async reportUpdatesQueueCount() {
metrics.doc

View File

@@ -3,6 +3,7 @@ import {
Args,
Context,
Field,
InputType,
Int,
Mutation,
ObjectType,
@@ -125,6 +126,31 @@ class UserInvoiceType implements Partial<UserInvoice> {
updatedAt!: Date;
}
@InputType()
class CreateCheckoutSessionInput {
@Field(() => SubscriptionRecurring, {
nullable: true,
defaultValue: SubscriptionRecurring.Yearly,
})
recurring!: SubscriptionRecurring;
@Field(() => SubscriptionPlan, {
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan!: SubscriptionPlan;
@Field(() => String, { nullable: true })
coupon!: string | null;
@Field(() => String, { nullable: true })
successCallbackLink!: string | null;
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
@Field(() => String)
idempotencyKey!: string;
}
@Auth()
@Resolver(() => UserSubscriptionType)
export class SubscriptionResolver {
@@ -182,7 +208,11 @@ export class SubscriptionResolver {
});
}
/**
* @deprecated
*/
@Mutation(() => String, {
deprecationReason: 'use `createCheckoutSession` instead',
description: 'Create a subscription checkout link of stripe',
})
async checkout(
@@ -193,6 +223,7 @@ export class SubscriptionResolver {
) {
const session = await this.service.createCheckoutSession({
user,
plan: SubscriptionPlan.Pro,
recurring,
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
idempotencyKey,
@@ -210,6 +241,36 @@ export class SubscriptionResolver {
return session.url;
}
@Mutation(() => String, {
description: 'Create a subscription checkout link of stripe',
})
async createCheckoutSession(
@CurrentUser() user: User,
@Args({ name: 'input', type: () => CreateCheckoutSessionInput })
input: CreateCheckoutSessionInput
) {
const session = await this.service.createCheckoutSession({
user,
plan: input.plan,
recurring: input.recurring,
promotionCode: input.coupon,
redirectUrl:
input.successCallbackLink ?? `${this.config.baseUrl}/upgrade-success`,
idempotencyKey: input.idempotencyKey,
});
if (!session.url) {
throw new GraphQLError('Failed to create checkout session', {
extensions: {
status: HttpStatus[HttpStatus.BAD_GATEWAY],
code: HttpStatus.BAD_GATEWAY,
},
});
}
return session.url;
}
@Mutation(() => String, {
description: 'Create a stripe customer portal to manage payment methods',
})

View File

@@ -69,13 +69,15 @@ export class SubscriptionService {
async createCheckoutSession({
user,
recurring,
plan,
promotionCode,
redirectUrl,
idempotencyKey,
plan = SubscriptionPlan.Pro,
}: {
user: User;
plan?: SubscriptionPlan;
recurring: SubscriptionRecurring;
plan: SubscriptionPlan;
promotionCode?: string | null;
redirectUrl: string;
idempotencyKey: string;
}) {
@@ -95,7 +97,28 @@ export class SubscriptionService {
`${idempotencyKey}-getOrCreateCustomer`,
user
);
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
let discount: { coupon?: string; promotion_code?: string } | undefined;
if (promotionCode) {
const code = await this.getAvailablePromotionCode(
promotionCode,
customer.stripeCustomerId
);
if (code) {
discount ??= {};
discount.promotion_code = code;
}
} else {
const coupon = await this.getAvailableCoupon(
user,
CouponType.EarlyAccess
);
if (coupon) {
discount ??= {};
discount.coupon = coupon;
}
}
return await this.stripe.checkout.sessions.create(
{
@@ -108,13 +131,11 @@ export class SubscriptionService {
tax_id_collection: {
enabled: true,
},
...(coupon
...(discount
? {
discounts: [{ coupon }],
discounts: [discount],
}
: {
allow_promotion_codes: true,
}),
: { allow_promotion_codes: true }),
mode: 'subscription',
success_url: redirectUrl,
customer: customer.stripeCustomerId,
@@ -643,4 +664,33 @@ export class SubscriptionService {
return null;
}
private async getAvailablePromotionCode(
userFacingPromotionCode: string,
customer?: string
) {
const list = await this.stripe.promotionCodes.list({
code: userFacingPromotionCode,
active: true,
limit: 1,
});
const code = list.data[0];
if (!code) {
return null;
}
let available = false;
if (code.customer) {
available =
typeof code.customer === 'string'
? code.customer === customer
: code.customer.id === customer;
} else {
available = true;
}
return available ? code.id : null;
}
}

View File

@@ -2,6 +2,14 @@
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
input CreateCheckoutSessionInput {
coupon: String
idempotencyKey: String!
plan: SubscriptionPlan = Pro
recurring: SubscriptionRecurring = Yearly
successCallbackLink: String
}
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
@@ -107,7 +115,10 @@ type Mutation {
changePassword(newPassword: String!, token: String!): UserType!
"""Create a subscription checkout link of stripe"""
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String!
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String! @deprecated(reason: "use `createCheckoutSession` instead")
"""Create a subscription checkout link of stripe"""
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
"""Create a stripe customer portal to manage payment methods"""
createCustomerPortal: String!

View File

@@ -20,6 +20,7 @@ import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
import { useSubscriptionSearch } from './use-subscription';
function validateEmail(email: string) {
return emailRegex.test(email);
@@ -34,6 +35,7 @@ export const SignIn: FC<AuthPanelProps> = ({
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const [verifyToken, challenge] = useCaptcha();
const subscriptionData = useSubscriptionSearch();
const {
isMutating: isSigningIn,
@@ -81,7 +83,8 @@ export const SignIn: FC<AuthPanelProps> = ({
if (verifyToken) {
if (user) {
// provider password sign-in if user has by default
if (user.hasPassword) {
// If with payment, onl support email sign in to avoid redirect to affine app
if (user.hasPassword && !subscriptionData) {
setAuthState('signInWithPassword');
} else {
const res = await signIn(email, verifyToken, challenge);
@@ -101,6 +104,7 @@ export const SignIn: FC<AuthPanelProps> = ({
}
}
}, [
subscriptionData,
challenge,
email,
setAuthEmail,

View File

@@ -3,10 +3,10 @@ import { Button } from '@affine/component/ui/button';
import { Loading } from '@affine/component/ui/loading';
import { AffineShapeIcon } from '@affine/core/components/page-list';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { SubscriptionRecurring } from '@affine/graphql';
import type { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import {
changePasswordMutation,
checkoutMutation,
createCheckoutSessionMutation,
subscriptionQuery,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -30,18 +30,25 @@ const usePaymentRedirect = () => {
}
const recurring = searchData.recurring as SubscriptionRecurring;
const plan = searchData.plan as SubscriptionPlan;
const coupon = searchData.coupon;
const idempotencyKey = useMemo(() => nanoid(), []);
const { trigger: checkoutSubscription } = useMutation({
mutation: checkoutMutation,
mutation: createCheckoutSessionMutation,
});
return useAsyncCallback(async () => {
const { checkout } = await checkoutSubscription({
recurring,
idempotencyKey,
const { createCheckoutSession: checkoutUrl } = await checkoutSubscription({
input: {
recurring,
plan,
coupon,
idempotencyKey,
successCallbackLink: null,
},
});
window.open(checkout, '_self', 'norefferer');
}, [recurring, idempotencyKey, checkoutSubscription]);
window.open(checkoutUrl, '_self', 'norefferer');
}, [recurring, plan, coupon, idempotencyKey, checkoutSubscription]);
};
const CenterLoading = () => {

View File

@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
enum SubscriptionKey {
Recurring = 'subscription_recurring',
Plan = 'subscription_plan',
Coupon = 'coupon',
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
Token = 'token', // When signup, there should have a token to set password
}
@@ -22,11 +23,13 @@ export function useSubscriptionSearch() {
const recurring = searchParams.get(SubscriptionKey.Recurring);
const plan = searchParams.get(SubscriptionKey.Plan);
const coupon = searchParams.get(SubscriptionKey.Coupon);
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
const passwordToken = searchParams.get(SubscriptionKey.Token);
return {
recurring,
plan,
coupon,
withSignUp,
passwordToken,
getRedirectUrl(signUp?: boolean) {
@@ -35,6 +38,10 @@ export function useSubscriptionSearch() {
[SubscriptionKey.Plan, plan ?? ''],
]);
if (coupon) {
paymentParams.set(SubscriptionKey.Coupon, coupon);
}
if (signUp) {
paymentParams.set(SubscriptionKey.SignUp, '1');
}

View File

@@ -6,7 +6,7 @@ import type {
SubscriptionMutator,
} from '@affine/core/hooks/use-subscription';
import {
checkoutMutation,
createCheckoutSessionMutation,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
@@ -359,7 +359,7 @@ const Upgrade = ({
}) => {
const t = useAFFiNEI18N();
const { isMutating, trigger } = useMutation({
mutation: checkoutMutation,
mutation: createCheckoutSessionMutation,
});
const newTabRef = useRef<Window | null>(null);
@@ -383,13 +383,21 @@ const Upgrade = ({
newTabRef.current.focus();
} else {
await trigger(
{ recurring, idempotencyKey },
{
input: {
recurring,
idempotencyKey,
plan: SubscriptionPlan.Pro, // Only support prod plan now.
coupon: null,
successCallbackLink: null,
},
},
{
onSuccess: data => {
// FIXME: safari prevents from opening new tab by window api
// TODO(@xp): what if electron?
const newTab = window.open(
data.checkout,
data.createCheckoutSession,
'_blank',
'noopener noreferrer'
);

View File

@@ -7,7 +7,7 @@ import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import { Schema, Workspace } from '@blocksuite/store';
import { renderHook } from '@testing-library/react';
import { initEmptyPage } from '@toeverything/infra/blocksuite';
import { beforeEach, describe, expect, test } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { useBlockSuitePageMeta } from '../use-block-suite-page-meta';
import { useBlockSuiteWorkspaceHelper } from '../use-block-suite-workspace-helper';
@@ -17,18 +17,26 @@ let blockSuiteWorkspace: Workspace;
const schema = new Schema();
schema.register(AffineSchemas).register(__unstableSchemas);
// todo: this module has some side-effects that will break the tests
vi.mock('@affine/workspace-impl', () => ({
default: {},
}));
beforeEach(async () => {
blockSuiteWorkspace = new Workspace({
id: 'test',
schema,
});
blockSuiteWorkspace.doc.emit('sync', []);
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
});
describe('useBlockSuiteWorkspaceHelper', () => {
test('should create page', () => {
test('should create page', async () => {
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
const helperHook = renderHook(() =>
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace)
@@ -36,6 +44,7 @@ describe('useBlockSuiteWorkspaceHelper', () => {
const pageMetaHook = renderHook(() =>
useBlockSuitePageMeta(blockSuiteWorkspace)
);
await new Promise(resolve => setTimeout(resolve));
expect(pageMetaHook.result.current.length).toBe(3);
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
const page = helperHook.result.current.createPage('page4');

View File

@@ -5,9 +5,11 @@ import type { Atom } from 'jotai';
import { atom, useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { useJournalHelper } from './use-journal';
const weakMap = new WeakMap<Workspace, Atom<PageMeta[]>>();
export function useBlockSuitePageMeta(
export function useAllBlockSuitePageMeta(
blockSuiteWorkspace: Workspace
): PageMeta[] {
if (!weakMap.has(blockSuiteWorkspace)) {
@@ -26,6 +28,18 @@ export function useBlockSuitePageMeta(
return useAtomValue(weakMap.get(blockSuiteWorkspace) as Atom<PageMeta[]>);
}
export function useBlockSuitePageMeta(blocksuiteWorkspace: Workspace) {
const pageMetas = useAllBlockSuitePageMeta(blocksuiteWorkspace);
const { isPageJournal } = useJournalHelper(blocksuiteWorkspace);
return useMemo(
() =>
pageMetas.filter(
pageMeta => !isPageJournal(pageMeta.id) || !!pageMeta.updatedDate
),
[isPageJournal, pageMetas]
);
}
export function usePageMetaHelper(blockSuiteWorkspace: Workspace) {
return useMemo(
() => ({

View File

@@ -33,6 +33,10 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => {
(maybeDate: MaybeDate) => {
const title = dayjs(maybeDate).format(JOURNAL_DATE_FORMAT);
const page = bsWorkspaceHelper.createPage();
// set created date to match the journal date
page.workspace.setPageMeta(page.id, {
createDate: dayjs(maybeDate).toDate().getTime(),
});
initEmptyPage(page, title).catch(err =>
console.error('Failed to load journal page', err)
);

View File

@@ -6,6 +6,7 @@ import {
} from '@affine/component';
import { MoveToTrash } from '@affine/core/components/page-list';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import {
useJournalHelper,
@@ -13,6 +14,7 @@ import {
useJournalRouteHelper,
} from '@affine/core/hooks/use-journal';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import type { BlockSuiteWorkspace } from '@affine/core/shared';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
EdgelessIcon,
@@ -20,7 +22,7 @@ import {
PageIcon,
TodayIcon,
} from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import type { Page, PageMeta } from '@blocksuite/store';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import dayjs from 'dayjs';
@@ -41,21 +43,28 @@ const CountDisplay = ({
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
};
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
page: Page;
pageMeta: PageMeta;
workspace: BlockSuiteWorkspace;
right?: ReactNode;
}
const PageItem = ({ page, right, className, ...attrs }: PageItemProps) => {
const { isJournal } = useJournalInfoHelper(page.workspace, page.id);
const title = useBlockSuiteWorkspacePageTitle(page.workspace, page.id);
const PageItem = ({
pageMeta,
workspace,
right,
className,
...attrs
}: PageItemProps) => {
const { isJournal } = useJournalInfoHelper(workspace, pageMeta.id);
const title = useBlockSuiteWorkspacePageTitle(workspace, pageMeta.id);
const Icon = isJournal
? TodayIcon
: page.meta.mode === 'edgeless'
: pageMeta.mode === 'edgeless'
? EdgelessIcon
: PageIcon;
return (
<div
aria-label={page.meta.title}
aria-label={pageMeta.title}
className={clsx(className, styles.pageItem)}
{...attrs}
>
@@ -114,15 +123,12 @@ const EditorJournalPanel = (props: EditorExtensionProps) => {
};
const sortPagesByDate = (
pages: Page[],
pages: PageMeta[],
field: 'updatedDate' | 'createDate',
order: 'asc' | 'desc' = 'desc'
) => {
return [...pages].sort((a, b) => {
return (
(order === 'asc' ? 1 : -1) *
dayjs(b.meta[field]).diff(dayjs(a.meta[field]))
);
return (order === 'asc' ? 1 : -1) * dayjs(b[field]).diff(dayjs(a[field]));
});
};
@@ -141,21 +147,21 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
const nodeRef = useRef<HTMLDivElement>(null);
const t = useAFFiNEI18N();
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
const pageMetas = useBlockSuitePageMeta(workspace);
const navigateHelper = useNavigateHelper();
const getTodaysPages = useCallback(
(field: 'createDate' | 'updatedDate') => {
const pages: Page[] = [];
Array.from(workspace.pages.values()).forEach(page => {
if (page.meta.trash) return;
if (page.meta[field] && dayjs(page.meta[field]).isSame(date, 'day')) {
pages.push(page);
}
});
return sortPagesByDate(pages, field);
return sortPagesByDate(
pageMetas.filter(pageMeta => {
if (pageMeta.trash) return false;
return pageMeta[field] && dayjs(pageMeta[field]).isSame(date, 'day');
}),
field
);
},
[date, workspace.pages]
[date, pageMetas]
);
const createdToday = useMemo(
@@ -224,14 +230,15 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<div className={styles.dailyCountContent} ref={nodeRef}>
{renderList.map((page, index) => (
{renderList.map((pageMeta, index) => (
<PageItem
onClick={() =>
navigateHelper.openPage(workspace.id, page.id)
navigateHelper.openPage(workspace.id, pageMeta.id)
}
tabIndex={name === activeItem ? 0 : -1}
key={index}
page={page}
pageMeta={pageMeta}
workspace={workspace}
/>
))}
</div>
@@ -282,7 +289,8 @@ const ConflictList = ({
<PageItem
aria-label={page.meta.title}
aria-selected={isCurrent}
page={page}
pageMeta={page.meta}
workspace={workspace}
key={page.id}
right={
<Menu

View File

@@ -1,6 +1,3 @@
mutation checkout(
$recurring: SubscriptionRecurring!
$idempotencyKey: String!
) {
checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
mutation createCheckoutSession($input: CreateCheckoutSessionInput!) {
createCheckoutSession(input: $input)
}

View File

@@ -127,14 +127,14 @@ mutation changePassword($token: String!, $newPassword: String!) {
}`,
};
export const checkoutMutation = {
id: 'checkoutMutation' as const,
operationName: 'checkout',
definitionName: 'checkout',
export const createCheckoutSessionMutation = {
id: 'createCheckoutSessionMutation' as const,
operationName: 'createCheckoutSession',
definitionName: 'createCheckoutSession',
containsFile: false,
query: `
mutation checkout($recurring: SubscriptionRecurring!, $idempotencyKey: String!) {
checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
mutation createCheckoutSession($input: CreateCheckoutSessionInput!) {
createCheckoutSession(input: $input)
}`,
};

View File

@@ -192,12 +192,14 @@ export type ChangePasswordMutation = {
};
};
export type CheckoutMutationVariables = Exact<{
recurring: SubscriptionRecurring;
idempotencyKey: Scalars['String']['input'];
export type CreateCheckoutSessionMutationVariables = Exact<{
input: CreateCheckoutSessionInput;
}>;
export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
export type CreateCheckoutSessionMutation = {
__typename?: 'Mutation';
createCheckoutSession: string;
};
export type CreateCustomerPortalMutationVariables = Exact<{
[key: string]: never;
@@ -1041,9 +1043,9 @@ export type Mutations =
response: ChangePasswordMutation;
}
| {
name: 'checkoutMutation';
variables: CheckoutMutationVariables;
response: CheckoutMutation;
name: 'createCheckoutSessionMutation';
variables: CreateCheckoutSessionMutationVariables;
response: CreateCheckoutSessionMutation;
}
| {
name: 'createCustomerPortalMutation';

View File

@@ -18,26 +18,70 @@ export function createCloudAwarenessProvider(
workspaceId: string,
awareness: Awareness
): AwarenessProvider {
const socket = getIoManager().socket('/');
return new AffineCloudAwarenessProvider(workspaceId, awareness);
}
const awarenessBroadcast = ({
class AffineCloudAwarenessProvider implements AwarenessProvider {
socket = getIoManager().socket('/');
constructor(
private readonly workspaceId: string,
private readonly awareness: Awareness
) {}
connect(): void {
this.socket.on('server-awareness-broadcast', this.awarenessBroadcast);
this.socket.on(
'new-client-awareness-init',
this.newClientAwarenessInitHandler
);
this.awareness.on('update', this.awarenessUpdate);
window.addEventListener('beforeunload', this.windowBeforeUnloadHandler);
this.socket.on('connect', () => this.handleConnect());
if (this.socket.connected) {
this.handleConnect();
} else {
this.socket.connect();
}
}
disconnect(): void {
removeAwarenessStates(
this.awareness,
[this.awareness.clientID],
'disconnect'
);
this.awareness.off('update', this.awarenessUpdate);
this.socket.emit('client-leave-awareness', this.workspaceId);
this.socket.off('server-awareness-broadcast', this.awarenessBroadcast);
this.socket.off(
'new-client-awareness-init',
this.newClientAwarenessInitHandler
);
this.socket.off('connect', this.handleConnect);
window.removeEventListener('unload', this.windowBeforeUnloadHandler);
}
awarenessBroadcast = ({
workspaceId: wsId,
awarenessUpdate,
}: {
workspaceId: string;
awarenessUpdate: string;
}) => {
if (wsId !== workspaceId) {
if (wsId !== this.workspaceId) {
return;
}
applyAwarenessUpdate(
awareness,
this.awareness,
base64ToUint8Array(awarenessUpdate),
'remote'
);
};
const awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
if (origin === 'remote') {
return;
}
@@ -46,63 +90,41 @@ export function createCloudAwarenessProvider(
res.concat(cur)
);
const update = encodeAwarenessUpdate(awareness, changedClients);
const update = encodeAwarenessUpdate(this.awareness, changedClients);
uint8ArrayToBase64(update)
.then(encodedUpdate => {
socket.emit('awareness-update', {
workspaceId: workspaceId,
this.socket.emit('awareness-update', {
workspaceId: this.workspaceId,
awarenessUpdate: encodedUpdate,
});
})
.catch(err => logger.error(err));
};
const newClientAwarenessInitHandler = () => {
const awarenessUpdate = encodeAwarenessUpdate(awareness, [
awareness.clientID,
newClientAwarenessInitHandler = () => {
const awarenessUpdate = encodeAwarenessUpdate(this.awareness, [
this.awareness.clientID,
]);
uint8ArrayToBase64(awarenessUpdate)
.then(encodedAwarenessUpdate => {
socket.emit('awareness-update', {
guid: workspaceId,
this.socket.emit('awareness-update', {
guid: this.workspaceId,
awarenessUpdate: encodedAwarenessUpdate,
});
})
.catch(err => logger.error(err));
};
const windowBeforeUnloadHandler = () => {
removeAwarenessStates(awareness, [awareness.clientID], 'window unload');
windowBeforeUnloadHandler = () => {
removeAwarenessStates(
this.awareness,
[this.awareness.clientID],
'window unload'
);
};
function handleConnect() {
socket.emit('client-handshake-awareness', workspaceId);
socket.emit('awareness-init', workspaceId);
}
return {
connect: () => {
socket.on('server-awareness-broadcast', awarenessBroadcast);
socket.on('new-client-awareness-init', newClientAwarenessInitHandler);
awareness.on('update', awarenessUpdate);
window.addEventListener('beforeunload', windowBeforeUnloadHandler);
socket.connect();
socket.on('connect', handleConnect);
socket.emit('client-handshake-awareness', workspaceId);
socket.emit('awareness-init', workspaceId);
},
disconnect: () => {
removeAwarenessStates(awareness, [awareness.clientID], 'disconnect');
awareness.off('update', awarenessUpdate);
socket.emit('client-leave-awareness', workspaceId);
socket.off('server-awareness-broadcast', awarenessBroadcast);
socket.off('new-client-awareness-init', newClientAwarenessInitHandler);
socket.off('connect', handleConnect);
window.removeEventListener('unload', windowBeforeUnloadHandler);
},
handleConnect = () => {
this.socket.emit('client-handshake-awareness', this.workspaceId);
this.socket.emit('awareness-init', this.workspaceId);
};
}

View File

@@ -1,107 +0,0 @@
interface SyncUpdateSender {
(
guid: string,
updates: Uint8Array[]
): Promise<{
accepted: boolean;
retry: boolean;
}>;
}
/**
* BatchSyncSender is simple wrapper with vanilla update sync with several advanced features:
* - ACK mechanism, send updates sequentially with previous sync request correctly responds with ACK
* - batching updates, when waiting for previous ACK, new updates will be buffered and sent in single sync request
* - retryable, allow retry when previous sync request failed but with retry flag been set to true
*/
export class BatchSyncSender {
private readonly buffered: Uint8Array[] = [];
private job: Promise<void> | null = null;
private started = true;
constructor(
private readonly guid: string,
private readonly rawSender: SyncUpdateSender
) {}
send(update: Uint8Array) {
this.buffered.push(update);
this.next();
return Promise.resolve();
}
stop() {
this.started = false;
}
start() {
this.started = true;
this.next();
}
private next() {
if (!this.started || this.job || !this.buffered.length) {
return;
}
const lastIndex = Math.min(
this.buffered.length - 1,
99 /* max batch updates size */
);
const updates = this.buffered.slice(0, lastIndex + 1);
if (updates.length) {
this.job = this.rawSender(this.guid, updates)
.then(({ accepted, retry }) => {
// remove pending updates if updates are accepted
if (accepted) {
this.buffered.splice(0, lastIndex + 1);
}
// stop when previous sending failed and non-recoverable
if (accepted || retry) {
// avoid call stack overflow
setTimeout(() => {
this.next();
}, 0);
} else {
this.stop();
}
})
.catch(() => {
this.stop();
})
.finally(() => {
this.job = null;
});
}
}
}
export class MultipleBatchSyncSender {
private senders: Record<string, BatchSyncSender> = {};
constructor(private readonly rawSender: SyncUpdateSender) {}
async send(guid: string, update: Uint8Array) {
return this.getSender(guid).send(update);
}
private getSender(guid: string) {
let sender = this.senders[guid];
if (!sender) {
sender = new BatchSyncSender(guid, this.rawSender);
this.senders[guid] = sender;
}
return sender;
}
start() {
Object.values(this.senders).forEach(sender => sender.start());
}
stop() {
Object.values(this.senders).forEach(sender => sender.stop());
}
}

View File

@@ -4,165 +4,148 @@ import type { SyncStorage } from '@affine/workspace';
import { getIoManager } from '../../utils/affine-io';
import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
import { MultipleBatchSyncSender } from './batch-sync-sender';
const logger = new DebugLogger('affine:storage:socketio');
export class AffineSyncStorage implements SyncStorage {
name = 'affine-cloud';
SEND_TIMEOUT = 30000;
socket = getIoManager().socket('/');
constructor(private readonly workspaceId: string) {
this.socket.on('connect', this.handleConnect);
if (this.socket.connected) {
this.socket.emit('client-handshake-sync', this.workspaceId);
} else {
this.socket.connect();
}
}
handleConnect = () => {
this.socket.emit('client-handshake-sync', this.workspaceId);
};
async pull(
docId: string,
state: Uint8Array
): Promise<{ data: Uint8Array; state?: Uint8Array } | null> {
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
logger.debug('doc-load-v2', {
workspaceId: this.workspaceId,
guid: docId,
stateVector,
});
const response:
| { error: any }
| { data: { missing: string; state: string } } = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('doc-load-v2', {
workspaceId: this.workspaceId,
guid: docId,
stateVector,
});
logger.debug('doc-load callback', {
workspaceId: this.workspaceId,
guid: docId,
stateVector,
response,
});
if ('error' in response) {
// TODO: result `EventError` with server
if (response.error.code === 'DOC_NOT_FOUND') {
return null;
} else {
throw new Error(response.error.message);
}
} else {
return {
data: base64ToUint8Array(response.data.missing),
state: response.data.state
? base64ToUint8Array(response.data.state)
: undefined,
};
}
}
async push(docId: string, update: Uint8Array) {
logger.debug('client-update-v2', {
workspaceId: this.workspaceId,
guid: docId,
update,
});
const payload = await uint8ArrayToBase64(update);
const response: {
// TODO: reuse `EventError` with server
error?: any;
data: any;
} = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('client-update-v2', {
workspaceId: this.workspaceId,
guid: docId,
updates: [payload],
});
// TODO: raise error with different code to users
if (response.error) {
logger.error('client-update-v2 error', {
workspaceId: this.workspaceId,
guid: docId,
response,
});
throw new Error(response.error);
}
}
async subscribe(
cb: (docId: string, data: Uint8Array) => void,
disconnect: (reason: string) => void
) {
const handleUpdate = async (message: {
workspaceId: string;
guid: string;
updates: string[];
}) => {
if (message.workspaceId === this.workspaceId) {
message.updates.forEach(update => {
cb(message.guid, base64ToUint8Array(update));
});
}
};
const handleDisconnect = (reason: string) => {
this.socket.off('server-updates', handleUpdate);
disconnect(reason);
};
this.socket.on('server-updates', handleUpdate);
this.socket.on('disconnect', handleDisconnect);
return () => {
this.socket.off('server-updates', handleUpdate);
this.socket.off('disconnect', handleDisconnect);
};
}
disconnect() {
this.socket.emit('client-leave-sync', this.workspaceId);
this.socket.off('connect', this.handleConnect);
}
}
export function createAffineStorage(
workspaceId: string
): SyncStorage & { disconnect: () => void } {
logger.debug('createAffineStorage', workspaceId);
const socket = getIoManager().socket('/');
const syncSender = new MultipleBatchSyncSender(async (guid, updates) => {
const payload = await Promise.all(
updates.map(update => uint8ArrayToBase64(update))
);
return new Promise(resolve => {
socket.emit(
'client-update-v2',
{
workspaceId,
guid,
updates: payload,
},
(response: {
// TODO: reuse `EventError` with server
error?: any;
data: any;
}) => {
// TODO: raise error with different code to users
if (response.error) {
logger.error('client-update-v2 error', {
workspaceId,
guid,
response,
});
}
resolve({
accepted: !response.error,
// TODO: reuse `EventError` with server
retry: response.error?.code === 'INTERNAL',
});
}
);
});
});
function handleConnect() {
socket.emit(
'client-handshake-sync',
workspaceId,
(response: { error?: any }) => {
if (!response.error) {
syncSender.start();
}
}
);
}
socket.on('connect', handleConnect);
socket.connect();
socket.emit(
'client-handshake-sync',
workspaceId,
(response: { error?: any }) => {
if (!response.error) {
syncSender.start();
}
}
);
return {
name: 'affine-cloud',
async pull(docId, state) {
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
return new Promise((resolve, reject) => {
logger.debug('doc-load-v2', {
workspaceId: workspaceId,
guid: docId,
stateVector,
});
socket.emit(
'doc-load-v2',
{
workspaceId: workspaceId,
guid: docId,
stateVector,
},
(
response: // TODO: reuse `EventError` with server
{ error: any } | { data: { missing: string; state: string } }
) => {
logger.debug('doc-load callback', {
workspaceId: workspaceId,
guid: docId,
stateVector,
response,
});
if ('error' in response) {
// TODO: result `EventError` with server
if (response.error.code === 'DOC_NOT_FOUND') {
resolve(null);
} else {
reject(new Error(response.error.message));
}
} else {
resolve({
data: base64ToUint8Array(response.data.missing),
state: response.data.state
? base64ToUint8Array(response.data.state)
: undefined,
});
}
}
);
});
},
async push(docId, update) {
logger.debug('client-update-v2', {
workspaceId,
guid: docId,
update,
});
await syncSender.send(docId, update);
},
async subscribe(cb, disconnect) {
const handleUpdate = async (message: {
workspaceId: string;
guid: string;
updates: string[];
}) => {
if (message.workspaceId === workspaceId) {
message.updates.forEach(update => {
cb(message.guid, base64ToUint8Array(update));
});
}
};
socket.on('server-updates', handleUpdate);
socket.on('disconnect', reason => {
socket.off('server-updates', handleUpdate);
disconnect(reason);
});
return () => {
socket.off('server-updates', handleUpdate);
};
},
disconnect() {
syncSender.stop();
socket.emit('client-leave-sync', workspaceId);
socket.off('connect', handleConnect);
},
};
return new AffineSyncStorage(workspaceId);
}
export function createAffineStaticStorage(workspaceId: string): SyncStorage {