mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-10 11:28:45 +00:00
Compare commits
102 Commits
v0.15.3-be
...
v0.12.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def60f4c61 | ||
|
|
d15ec0ff77 | ||
|
|
d2acd0385a | ||
|
|
1effb2f25f | ||
|
|
9189d26332 | ||
|
|
79a8be7799 | ||
|
|
1a643cc70c | ||
|
|
4257b5f3a4 | ||
|
|
ea17e86032 | ||
|
|
48cd8999bd | ||
|
|
cdf1d9002e | ||
|
|
79b39f14d2 | ||
|
|
619420cfd1 | ||
|
|
739e914b5f | ||
|
|
5e9739eb3a | ||
|
|
0a89b7f528 | ||
|
|
0a0ee37ac2 | ||
|
|
a143379161 | ||
|
|
8e7dedfe82 | ||
|
|
d25a8547d0 | ||
|
|
4d16229fea | ||
|
|
99371be7e8 | ||
|
|
34ed8dd7a5 | ||
|
|
39b7b671b1 | ||
|
|
207b56d5af | ||
|
|
9e94e7195b | ||
|
|
de951c8779 | ||
|
|
fd37026ca5 | ||
|
|
4fd5812a89 | ||
|
|
d01e987ecc | ||
|
|
d87c218c0b | ||
|
|
a5bf5cc244 | ||
|
|
16bcd6e76b | ||
|
|
2e2ace8472 | ||
|
|
37cff8fe8d | ||
|
|
70ab3b4916 | ||
|
|
f42ba54578 | ||
|
|
a67c8181fc | ||
|
|
613efbded9 | ||
|
|
549419d102 | ||
|
|
21c42f8771 | ||
|
|
9012adda7a | ||
|
|
fb442e9055 | ||
|
|
a231474dd2 | ||
|
|
833b42000b | ||
|
|
7690c48710 | ||
|
|
579828a700 | ||
|
|
746db2ccfc | ||
|
|
eff344a9c1 | ||
|
|
c89ebab596 | ||
|
|
62f4421b7c | ||
|
|
42383dbd29 | ||
|
|
120e7397ba | ||
|
|
24123ad01c | ||
|
|
ad50320391 | ||
|
|
eb21a60dda | ||
|
|
c0e3be2d40 | ||
|
|
09d3b72358 | ||
|
|
246e16c6c0 | ||
|
|
dc279d062b | ||
|
|
47d5f9e1c2 | ||
|
|
a226eb8d5f | ||
|
|
908c4e1a6f | ||
|
|
1d0bcc80a0 | ||
|
|
50010bd824 | ||
|
|
c0ede1326d | ||
|
|
89197bacef | ||
|
|
f97d323ab5 | ||
|
|
2acb219dcc | ||
|
|
992ed89a89 | ||
|
|
d272d7922d | ||
|
|
c1cd1713b9 | ||
|
|
b20e91bee0 | ||
|
|
9a4e5ec8c3 | ||
|
|
2019838ae7 | ||
|
|
30ff25f400 | ||
|
|
e766208c18 | ||
|
|
8742f28148 | ||
|
|
cd291bb60e | ||
|
|
62c0efcfd1 | ||
|
|
87248b3337 | ||
|
|
00c940f7df | ||
|
|
931b459fbd | ||
|
|
51e71f4a0a | ||
|
|
9b631f2328 | ||
|
|
01f481a9b6 | ||
|
|
0177ab5c87 | ||
|
|
4db35d341c | ||
|
|
3c4a803c97 | ||
|
|
05154dc7ca | ||
|
|
c90b477f60 | ||
|
|
6f18ddbe85 | ||
|
|
dde779a71d | ||
|
|
bd9f66fbc7 | ||
|
|
92f1f40bfa | ||
|
|
48dc1049b3 | ||
|
|
9add530370 | ||
|
|
b77460d871 | ||
|
|
42db41776b | ||
|
|
075439c74f | ||
|
|
fc6c553ece | ||
|
|
59cb3d5df1 |
2
.github/deployment/front/Dockerfile
vendored
2
.github/deployment/front/Dockerfile
vendored
@@ -1,6 +1,6 @@
|
|||||||
FROM openresty/openresty:1.21.4.3-0-buster
|
FROM openresty/openresty:1.21.4.3-0-buster
|
||||||
WORKDIR /app
|
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/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
||||||
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf
|
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf
|
||||||
|
|
||||||
|
|||||||
@@ -265,7 +265,9 @@ model Snapshot {
|
|||||||
seq Int @default(0) @db.Integer
|
seq Int @default(0) @db.Integer
|
||||||
state Bytes? @db.ByteA
|
state Bytes? @db.ByteA
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
// the `updated_at` field will not record the time of record changed,
|
||||||
|
// but the created time of last seen update that has been merged into snapshot.
|
||||||
|
updatedAt DateTime @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@id([id, workspaceId])
|
@@id([id, workspaceId])
|
||||||
@@map("snapshots")
|
@@map("snapshots")
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { chunk } from 'lodash-es';
|
|||||||
import { defer, retry } from 'rxjs';
|
import { defer, retry } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
decodeStateVector,
|
|
||||||
Doc,
|
Doc,
|
||||||
encodeStateAsUpdate,
|
encodeStateAsUpdate,
|
||||||
encodeStateVector,
|
encodeStateVector,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Cache,
|
Cache,
|
||||||
|
CallTimer,
|
||||||
Config,
|
Config,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
type EventPayload,
|
type EventPayload,
|
||||||
@@ -45,36 +45,6 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
|||||||
return compare(yBinary, yBinary2, true);
|
return compare(yBinary, yBinary2, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect whether rhs state is newer than lhs state.
|
|
||||||
*
|
|
||||||
* How could we tell a state is newer:
|
|
||||||
*
|
|
||||||
* i. if the state vector size is larger, it's newer
|
|
||||||
* ii. if the state vector size is same, compare each client's state
|
|
||||||
*/
|
|
||||||
function isStateNewer(lhs: Buffer, rhs: Buffer): boolean {
|
|
||||||
const lhsVector = decodeStateVector(lhs);
|
|
||||||
const rhsVector = decodeStateVector(rhs);
|
|
||||||
|
|
||||||
if (lhsVector.size < rhsVector.size) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [client, state] of lhsVector) {
|
|
||||||
const rstate = rhsVector.get(client);
|
|
||||||
if (!rstate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state < rstate) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEmptyBuffer(buf: Buffer): boolean {
|
export function isEmptyBuffer(buf: Buffer): boolean {
|
||||||
return (
|
return (
|
||||||
buf.length === 0 ||
|
buf.length === 0 ||
|
||||||
@@ -119,6 +89,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.destroy();
|
this.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CallTimer('doc', 'yjs_recover_updates_to_doc')
|
||||||
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
|
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
|
||||||
const doc = new Doc();
|
const doc = new Doc();
|
||||||
const chunks = chunk(updates, 10);
|
const chunks = chunk(updates, 10);
|
||||||
@@ -382,7 +353,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
const updates = await this.getUpdates(workspaceId, guid);
|
const updates = await this.getUpdates(workspaceId, guid);
|
||||||
|
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
const doc = await this.squash(updates, snapshot);
|
const doc = await this.squash(snapshot, updates);
|
||||||
return Buffer.from(encodeStateVector(doc));
|
return Buffer.from(encodeStateVector(doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,80 +434,92 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns whether the snapshot is updated to the latest, `undefined` means the doc to be upserted is outdated.
|
||||||
|
*/
|
||||||
|
@CallTimer('doc', 'upsert')
|
||||||
private async upsert(
|
private async upsert(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
guid: string,
|
guid: string,
|
||||||
doc: Doc,
|
doc: Doc,
|
||||||
// we always delay the snapshot update to avoid db overload,
|
// we always delay the snapshot update to avoid db overload,
|
||||||
// so the value of `updatedAt` will not be accurate to user's real action time
|
// so the value of auto updated `updatedAt` by db will never be accurate to user's real action time
|
||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
initialSeq?: number
|
seq: number
|
||||||
) {
|
) {
|
||||||
return this.lockSnapshotForUpsert(workspaceId, guid, async () => {
|
|
||||||
const blob = Buffer.from(encodeStateAsUpdate(doc));
|
const blob = Buffer.from(encodeStateAsUpdate(doc));
|
||||||
|
|
||||||
if (isEmptyBuffer(blob)) {
|
if (isEmptyBuffer(blob)) {
|
||||||
return false;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Buffer.from(encodeStateVector(doc));
|
const state = Buffer.from(encodeStateVector(doc));
|
||||||
|
|
||||||
return await this.db.$transaction(async db => {
|
// CONCERNS:
|
||||||
const snapshot = await db.snapshot.findUnique({
|
// i. Because we save the real user's last seen action time as `updatedAt`,
|
||||||
where: {
|
// it's possible to simply compare the `updatedAt` to determine if the snapshot is older than the one we are going to save.
|
||||||
id_workspaceId: {
|
//
|
||||||
id: guid,
|
// ii. Prisma doesn't support `upsert` with additional `where` condition along side unique constraint.
|
||||||
workspaceId,
|
// In our case, we need to manually check the `updatedAt` to avoid overriding the newer snapshot.
|
||||||
},
|
// where: { id_workspaceId: {}, updatedAt: { lt: updatedAt } }
|
||||||
},
|
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
});
|
//
|
||||||
|
// iii. Only set the seq number when creating the snapshot.
|
||||||
|
// For updating scenario, the seq number will be updated when updates pushed to db.
|
||||||
|
try {
|
||||||
|
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
|
||||||
|
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "state", "seq", "created_at", "updated_at")
|
||||||
|
VALUES (${workspaceId}, ${guid}, ${blob}, ${state}, ${seq}, DEFAULT, ${updatedAt})
|
||||||
|
ON CONFLICT ("workspace_id", "guid")
|
||||||
|
DO UPDATE SET "blob" = ${blob}, "state" = ${state}, "updated_at" = ${updatedAt}, "seq" = ${seq}
|
||||||
|
WHERE "snapshots"."workspace_id" = ${workspaceId} AND "snapshots"."guid" = ${guid} AND "snapshots"."updated_at" <= ${updatedAt}
|
||||||
|
RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
|
||||||
|
`;
|
||||||
|
|
||||||
// update
|
// const result = await this.db.snapshot.upsert({
|
||||||
if (snapshot) {
|
// select: {
|
||||||
// only update if state is newer
|
// updatedAt: true,
|
||||||
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) {
|
// seq: true,
|
||||||
await db.snapshot.update({
|
// },
|
||||||
select: {
|
// where: {
|
||||||
seq: true,
|
// id_workspaceId: {
|
||||||
},
|
// workspaceId,
|
||||||
where: {
|
// id: guid,
|
||||||
id_workspaceId: {
|
// },
|
||||||
workspaceId,
|
// ⬇️ NOT SUPPORTED BY PRISMA YET
|
||||||
id: guid,
|
// updatedAt: {
|
||||||
},
|
// lt: updatedAt,
|
||||||
},
|
// },
|
||||||
data: {
|
// },
|
||||||
blob,
|
// update: {
|
||||||
state,
|
// blob,
|
||||||
updatedAt,
|
// state,
|
||||||
},
|
// updatedAt,
|
||||||
});
|
// },
|
||||||
|
// create: {
|
||||||
|
// workspaceId,
|
||||||
|
// id: guid,
|
||||||
|
// blob,
|
||||||
|
// state,
|
||||||
|
// updatedAt,
|
||||||
|
// seq,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if the condition `snapshot.updatedAt > updatedAt` is true, by which means the snapshot has already been updated by other process,
|
||||||
|
// the updates has been applied to current `doc` must have been seen by the other process as well.
|
||||||
|
// The `updatedSnapshot` will be `undefined` in this case.
|
||||||
|
const updatedSnapshot = result.at(0);
|
||||||
|
|
||||||
|
if (!updatedSnapshot) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} catch (e) {
|
||||||
|
this.logger.error('Failed to upsert snapshot', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// create
|
|
||||||
await db.snapshot.create({
|
|
||||||
select: {
|
|
||||||
seq: true,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
id: guid,
|
|
||||||
workspaceId,
|
|
||||||
blob,
|
|
||||||
state,
|
|
||||||
seq: initialSeq,
|
|
||||||
createdAt: updatedAt,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _get(
|
private async _get(
|
||||||
@@ -548,7 +531,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
return {
|
return {
|
||||||
doc: await this.squash(updates, snapshot),
|
doc: await this.squash(snapshot, updates),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,17 +542,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Squash updates into a single update and save it as snapshot,
|
* Squash updates into a single update and save it as snapshot,
|
||||||
* and delete the updates records at the same time.
|
* and delete the updates records at the same time.
|
||||||
*/
|
*/
|
||||||
private async squash(updates: Update[], snapshot: Snapshot | null) {
|
@CallTimer('doc', 'squash')
|
||||||
|
private async squash(snapshot: Snapshot | null, updates: Update[]) {
|
||||||
if (!updates.length) {
|
if (!updates.length) {
|
||||||
throw new Error('No updates to squash');
|
throw new Error('No updates to squash');
|
||||||
}
|
}
|
||||||
const first = updates[0];
|
|
||||||
const last = updates[updates.length - 1];
|
|
||||||
|
|
||||||
const { id, workspaceId } = first;
|
const last = updates[updates.length - 1];
|
||||||
|
const { id, workspaceId } = last;
|
||||||
|
|
||||||
const doc = await this.applyUpdates(
|
const doc = await this.applyUpdates(
|
||||||
first.id,
|
id,
|
||||||
snapshot ? snapshot.blob : Buffer.from([0, 0]),
|
snapshot ? snapshot.blob : Buffer.from([0, 0]),
|
||||||
...updates.map(u => u.blob)
|
...updates.map(u => u.blob)
|
||||||
);
|
);
|
||||||
@@ -600,6 +583,10 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// we will keep the updates only if the upsert failed on unknown reason
|
||||||
|
// `done === undefined` means the updates is outdated(have already been merged by other process), safe to be deleted
|
||||||
|
// `done === true` means the upsert is successful, safe to be deleted
|
||||||
|
if (done !== false) {
|
||||||
// always delete updates
|
// always delete updates
|
||||||
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
||||||
const { count } = await this.db.update.deleteMany({
|
const { count } = await this.db.update.deleteMany({
|
||||||
@@ -613,6 +600,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
||||||
|
}
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -761,18 +749,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)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
async reportUpdatesQueueCount() {
|
async reportUpdatesQueueCount() {
|
||||||
metrics.doc
|
metrics.doc
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export class WorkspaceResolver {
|
|||||||
id: workspace.id,
|
id: workspace.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
blob: buffer,
|
blob: buffer,
|
||||||
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Args,
|
Args,
|
||||||
Context,
|
Context,
|
||||||
Field,
|
Field,
|
||||||
|
InputType,
|
||||||
Int,
|
Int,
|
||||||
Mutation,
|
Mutation,
|
||||||
ObjectType,
|
ObjectType,
|
||||||
@@ -125,6 +126,31 @@ class UserInvoiceType implements Partial<UserInvoice> {
|
|||||||
updatedAt!: Date;
|
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()
|
@Auth()
|
||||||
@Resolver(() => UserSubscriptionType)
|
@Resolver(() => UserSubscriptionType)
|
||||||
export class SubscriptionResolver {
|
export class SubscriptionResolver {
|
||||||
@@ -182,7 +208,11 @@ export class SubscriptionResolver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Mutation(() => String, {
|
@Mutation(() => String, {
|
||||||
|
deprecationReason: 'use `createCheckoutSession` instead',
|
||||||
description: 'Create a subscription checkout link of stripe',
|
description: 'Create a subscription checkout link of stripe',
|
||||||
})
|
})
|
||||||
async checkout(
|
async checkout(
|
||||||
@@ -193,6 +223,7 @@ export class SubscriptionResolver {
|
|||||||
) {
|
) {
|
||||||
const session = await this.service.createCheckoutSession({
|
const session = await this.service.createCheckoutSession({
|
||||||
user,
|
user,
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring,
|
recurring,
|
||||||
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
@@ -210,6 +241,36 @@ export class SubscriptionResolver {
|
|||||||
return session.url;
|
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, {
|
@Mutation(() => String, {
|
||||||
description: 'Create a stripe customer portal to manage payment methods',
|
description: 'Create a stripe customer portal to manage payment methods',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,13 +69,15 @@ export class SubscriptionService {
|
|||||||
async createCheckoutSession({
|
async createCheckoutSession({
|
||||||
user,
|
user,
|
||||||
recurring,
|
recurring,
|
||||||
|
plan,
|
||||||
|
promotionCode,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
plan = SubscriptionPlan.Pro,
|
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
plan?: SubscriptionPlan;
|
|
||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
promotionCode?: string | null;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -95,7 +97,28 @@ export class SubscriptionService {
|
|||||||
`${idempotencyKey}-getOrCreateCustomer`,
|
`${idempotencyKey}-getOrCreateCustomer`,
|
||||||
user
|
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(
|
return await this.stripe.checkout.sessions.create(
|
||||||
{
|
{
|
||||||
@@ -108,13 +131,11 @@ export class SubscriptionService {
|
|||||||
tax_id_collection: {
|
tax_id_collection: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
...(coupon
|
...(discount
|
||||||
? {
|
? {
|
||||||
discounts: [{ coupon }],
|
discounts: [discount],
|
||||||
}
|
}
|
||||||
: {
|
: { allow_promotion_codes: true }),
|
||||||
allow_promotion_codes: true,
|
|
||||||
}),
|
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
success_url: redirectUrl,
|
success_url: redirectUrl,
|
||||||
customer: customer.stripeCustomerId,
|
customer: customer.stripeCustomerId,
|
||||||
@@ -643,4 +664,33 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
# 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.
|
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!
|
changePassword(newPassword: String!, token: String!): UserType!
|
||||||
|
|
||||||
"""Create a subscription checkout link of stripe"""
|
"""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"""
|
"""Create a stripe customer portal to manage payment methods"""
|
||||||
createCustomerPortal: String!
|
createCustomerPortal: String!
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { TestingModule } from '@nestjs/testing';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import test from 'ava';
|
import test from 'ava';
|
||||||
import * as Sinon from 'sinon';
|
import * as Sinon from 'sinon';
|
||||||
import {
|
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||||
applyUpdate,
|
|
||||||
decodeStateVector,
|
|
||||||
Doc as YDoc,
|
|
||||||
encodeStateAsUpdate,
|
|
||||||
} from 'yjs';
|
|
||||||
|
|
||||||
import { DocManager, DocModule } from '../src/core/doc';
|
import { DocManager, DocModule } from '../src/core/doc';
|
||||||
import { QuotaModule } from '../src/core/quota';
|
import { QuotaModule } from '../src/core/quota';
|
||||||
@@ -277,72 +272,120 @@ test('should throw if meet max retry times', async t => {
|
|||||||
t.is(stub.callCount, 5);
|
t.is(stub.callCount, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not update snapshot if state is outdated', async t => {
|
test('should be able to insert the snapshot if it is new created', async t => {
|
||||||
const db = m.get(PrismaClient);
|
|
||||||
const manager = m.get(DocManager);
|
const manager = m.get(DocManager);
|
||||||
|
|
||||||
await db.snapshot.create({
|
|
||||||
data: {
|
|
||||||
id: '2',
|
|
||||||
workspaceId: '2',
|
|
||||||
blob: Buffer.from([0, 0]),
|
|
||||||
seq: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const doc = new YDoc();
|
const doc = new YDoc();
|
||||||
const text = doc.getText('content');
|
const text = doc.getText('content');
|
||||||
const updates: Buffer[] = [];
|
text.insert(0, 'hello');
|
||||||
|
const update = encodeStateAsUpdate(doc);
|
||||||
|
|
||||||
doc.on('update', update => {
|
await manager.push('1', '1', Buffer.from(update));
|
||||||
updates.push(Buffer.from(update));
|
|
||||||
|
const updates = await manager.getUpdates('1', '1');
|
||||||
|
t.is(updates.length, 1);
|
||||||
|
// @ts-expect-error private
|
||||||
|
const snapshot = await manager.squash(null, updates);
|
||||||
|
|
||||||
|
t.truthy(snapshot);
|
||||||
|
t.is(snapshot.getText('content').toString(), 'hello');
|
||||||
|
|
||||||
|
const restUpdates = await manager.getUpdates('1', '1');
|
||||||
|
|
||||||
|
t.is(restUpdates.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should be able to merge updates into snapshot', async t => {
|
||||||
|
const manager = m.get(DocManager);
|
||||||
|
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
doc.on('update', data => {
|
||||||
|
updates.push(Buffer.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = doc.getText('content');
|
||||||
text.insert(0, 'hello');
|
text.insert(0, 'hello');
|
||||||
text.insert(5, 'world');
|
text.insert(5, 'world');
|
||||||
text.insert(5, ' ');
|
text.insert(5, ' ');
|
||||||
|
|
||||||
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
|
||||||
|
|
||||||
const updateWith3Records = await manager.getUpdates('2', '2');
|
|
||||||
text.insert(11, '!');
|
text.insert(11, '!');
|
||||||
await manager.push('2', '2', updates[3]);
|
|
||||||
const updateWith4Records = await manager.getUpdates('2', '2');
|
|
||||||
|
|
||||||
// Simulation:
|
|
||||||
// Node A get 3 updates and squash them at time 1, will finish at time 10
|
|
||||||
// Node B get 4 updates and squash them at time 3, will finish at time 8
|
|
||||||
// Node B finish the squash first, and update the snapshot
|
|
||||||
// Node A finish the squash later, and update the snapshot to an outdated state
|
|
||||||
// Time: ---------------------->
|
|
||||||
// A: ^get ^upsert
|
|
||||||
// B: ^get ^upsert
|
|
||||||
//
|
|
||||||
// We should avoid such situation
|
|
||||||
// @ts-expect-error private
|
|
||||||
await manager.squash(updateWith4Records, null);
|
|
||||||
// @ts-expect-error private
|
|
||||||
await manager.squash(updateWith3Records, null);
|
|
||||||
|
|
||||||
const result = await db.snapshot.findUnique({
|
|
||||||
where: {
|
|
||||||
id_workspaceId: {
|
|
||||||
id: '2',
|
|
||||||
workspaceId: '2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
t.fail('snapshot not found');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = decodeStateVector(result.state!);
|
{
|
||||||
t.is(state.get(doc.clientID), 12);
|
await manager.batchPush('1', '1', updates.slice(0, 2));
|
||||||
|
// do the merge
|
||||||
|
const doc = (await manager.get('1', '1'))!;
|
||||||
|
|
||||||
const d = new YDoc();
|
t.is(doc.getText('content').toString(), 'helloworld');
|
||||||
applyUpdate(d, result.blob!);
|
}
|
||||||
|
|
||||||
const dtext = d.getText('content');
|
{
|
||||||
t.is(dtext.toString(), 'hello world!');
|
await manager.batchPush('1', '1', updates.slice(2));
|
||||||
|
const doc = (await manager.get('1', '1'))!;
|
||||||
|
|
||||||
|
t.is(doc.getText('content').toString(), 'hello world!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const restUpdates = await manager.getUpdates('1', '1');
|
||||||
|
|
||||||
|
t.is(restUpdates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not update snapshot if doc is outdated', async t => {
|
||||||
|
const manager = m.get(DocManager);
|
||||||
|
const db = m.get(PrismaClient);
|
||||||
|
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
doc.on('update', data => {
|
||||||
|
updates.push(Buffer.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = doc.getText('content');
|
||||||
|
text.insert(0, 'hello');
|
||||||
|
text.insert(5, 'world');
|
||||||
|
text.insert(5, ' ');
|
||||||
|
text.insert(11, '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.batchPush('2', '1', updates.slice(0, 2)); // 'helloworld'
|
||||||
|
// merge updates into snapshot
|
||||||
|
await manager.get('2', '1');
|
||||||
|
// fake the snapshot is a lot newer
|
||||||
|
await db.snapshot.update({
|
||||||
|
where: {
|
||||||
|
id_workspaceId: {
|
||||||
|
workspaceId: '2',
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
updatedAt: new Date(Date.now() + 10000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
const snapshot = await manager.getSnapshot('2', '1');
|
||||||
|
await manager.batchPush('2', '1', updates.slice(2)); // 'hello world!'
|
||||||
|
const updateRecords = await manager.getUpdates('2', '1');
|
||||||
|
|
||||||
|
// @ts-expect-error private
|
||||||
|
const doc = await manager.squash(snapshot, updateRecords);
|
||||||
|
|
||||||
|
// all updated will merged into doc not matter it's timestamp is outdated or not,
|
||||||
|
// but the snapshot record will not be updated
|
||||||
|
t.is(doc.getText('content').toString(), 'hello world!');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
applyUpdate(doc, (await manager.getSnapshot('2', '1'))!.blob);
|
||||||
|
// the snapshot will not get touched if the new doc's timestamp is outdated
|
||||||
|
t.is(doc.getText('content').toString(), 'helloworld');
|
||||||
|
|
||||||
|
// the updates are known as outdated, so they will be deleted
|
||||||
|
t.is((await manager.getUpdates('2', '1')).length, 0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { AuthPanelProps } from './index';
|
|||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
||||||
import { Captcha, useCaptcha } from './use-captcha';
|
import { Captcha, useCaptcha } from './use-captcha';
|
||||||
|
import { useSubscriptionSearch } from './use-subscription';
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return emailRegex.test(email);
|
return emailRegex.test(email);
|
||||||
@@ -34,6 +35,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const loginStatus = useCurrentLoginStatus();
|
const loginStatus = useCurrentLoginStatus();
|
||||||
const [verifyToken, challenge] = useCaptcha();
|
const [verifyToken, challenge] = useCaptcha();
|
||||||
|
const subscriptionData = useSubscriptionSearch();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isMutating: isSigningIn,
|
isMutating: isSigningIn,
|
||||||
@@ -81,7 +83,8 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
if (verifyToken) {
|
if (verifyToken) {
|
||||||
if (user) {
|
if (user) {
|
||||||
// provider password sign-in if user has by default
|
// 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');
|
setAuthState('signInWithPassword');
|
||||||
} else {
|
} else {
|
||||||
const res = await signIn(email, verifyToken, challenge);
|
const res = await signIn(email, verifyToken, challenge);
|
||||||
@@ -101,6 +104,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
subscriptionData,
|
||||||
challenge,
|
challenge,
|
||||||
email,
|
email,
|
||||||
setAuthEmail,
|
setAuthEmail,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Button } from '@affine/component/ui/button';
|
|||||||
import { Loading } from '@affine/component/ui/loading';
|
import { Loading } from '@affine/component/ui/loading';
|
||||||
import { AffineShapeIcon } from '@affine/core/components/page-list';
|
import { AffineShapeIcon } from '@affine/core/components/page-list';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import type { SubscriptionRecurring } from '@affine/graphql';
|
import type { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
changePasswordMutation,
|
changePasswordMutation,
|
||||||
checkoutMutation,
|
createCheckoutSessionMutation,
|
||||||
subscriptionQuery,
|
subscriptionQuery,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
@@ -30,18 +30,25 @@ const usePaymentRedirect = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recurring = searchData.recurring as SubscriptionRecurring;
|
const recurring = searchData.recurring as SubscriptionRecurring;
|
||||||
|
const plan = searchData.plan as SubscriptionPlan;
|
||||||
|
const coupon = searchData.coupon;
|
||||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||||
const { trigger: checkoutSubscription } = useMutation({
|
const { trigger: checkoutSubscription } = useMutation({
|
||||||
mutation: checkoutMutation,
|
mutation: createCheckoutSessionMutation,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useAsyncCallback(async () => {
|
return useAsyncCallback(async () => {
|
||||||
const { checkout } = await checkoutSubscription({
|
const { createCheckoutSession: checkoutUrl } = await checkoutSubscription({
|
||||||
|
input: {
|
||||||
recurring,
|
recurring,
|
||||||
|
plan,
|
||||||
|
coupon,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
|
successCallbackLink: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
window.open(checkout, '_self', 'norefferer');
|
window.open(checkoutUrl, '_self', 'norefferer');
|
||||||
}, [recurring, idempotencyKey, checkoutSubscription]);
|
}, [recurring, plan, coupon, idempotencyKey, checkoutSubscription]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CenterLoading = () => {
|
const CenterLoading = () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
enum SubscriptionKey {
|
enum SubscriptionKey {
|
||||||
Recurring = 'subscription_recurring',
|
Recurring = 'subscription_recurring',
|
||||||
Plan = 'subscription_plan',
|
Plan = 'subscription_plan',
|
||||||
|
Coupon = 'coupon',
|
||||||
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
|
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
|
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 recurring = searchParams.get(SubscriptionKey.Recurring);
|
||||||
const plan = searchParams.get(SubscriptionKey.Plan);
|
const plan = searchParams.get(SubscriptionKey.Plan);
|
||||||
|
const coupon = searchParams.get(SubscriptionKey.Coupon);
|
||||||
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
||||||
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
||||||
return {
|
return {
|
||||||
recurring,
|
recurring,
|
||||||
plan,
|
plan,
|
||||||
|
coupon,
|
||||||
withSignUp,
|
withSignUp,
|
||||||
passwordToken,
|
passwordToken,
|
||||||
getRedirectUrl(signUp?: boolean) {
|
getRedirectUrl(signUp?: boolean) {
|
||||||
@@ -35,6 +38,10 @@ export function useSubscriptionSearch() {
|
|||||||
[SubscriptionKey.Plan, plan ?? ''],
|
[SubscriptionKey.Plan, plan ?? ''],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (coupon) {
|
||||||
|
paymentParams.set(SubscriptionKey.Coupon, coupon);
|
||||||
|
}
|
||||||
|
|
||||||
if (signUp) {
|
if (signUp) {
|
||||||
paymentParams.set(SubscriptionKey.SignUp, '1');
|
paymentParams.set(SubscriptionKey.SignUp, '1');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
SubscriptionMutator,
|
SubscriptionMutator,
|
||||||
} from '@affine/core/hooks/use-subscription';
|
} from '@affine/core/hooks/use-subscription';
|
||||||
import {
|
import {
|
||||||
checkoutMutation,
|
createCheckoutSessionMutation,
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -359,7 +359,7 @@ const Upgrade = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const { isMutating, trigger } = useMutation({
|
const { isMutating, trigger } = useMutation({
|
||||||
mutation: checkoutMutation,
|
mutation: createCheckoutSessionMutation,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newTabRef = useRef<Window | null>(null);
|
const newTabRef = useRef<Window | null>(null);
|
||||||
@@ -383,13 +383,21 @@ const Upgrade = ({
|
|||||||
newTabRef.current.focus();
|
newTabRef.current.focus();
|
||||||
} else {
|
} else {
|
||||||
await trigger(
|
await trigger(
|
||||||
{ recurring, idempotencyKey },
|
{
|
||||||
|
input: {
|
||||||
|
recurring,
|
||||||
|
idempotencyKey,
|
||||||
|
plan: SubscriptionPlan.Pro, // Only support prod plan now.
|
||||||
|
coupon: null,
|
||||||
|
successCallbackLink: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
// FIXME: safari prevents from opening new tab by window api
|
// FIXME: safari prevents from opening new tab by window api
|
||||||
// TODO(@xp): what if electron?
|
// TODO(@xp): what if electron?
|
||||||
const newTab = window.open(
|
const newTab = window.open(
|
||||||
data.checkout,
|
data.createCheckoutSession,
|
||||||
'_blank',
|
'_blank',
|
||||||
'noopener noreferrer'
|
'noopener noreferrer'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
|||||||
import { Schema, Workspace } from '@blocksuite/store';
|
import { Schema, Workspace } from '@blocksuite/store';
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
import { initEmptyPage } from '@toeverything/infra/blocksuite';
|
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 { useBlockSuitePageMeta } from '../use-block-suite-page-meta';
|
||||||
import { useBlockSuiteWorkspaceHelper } from '../use-block-suite-workspace-helper';
|
import { useBlockSuiteWorkspaceHelper } from '../use-block-suite-workspace-helper';
|
||||||
@@ -17,18 +17,26 @@ let blockSuiteWorkspace: Workspace;
|
|||||||
const schema = new Schema();
|
const schema = new Schema();
|
||||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
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 () => {
|
beforeEach(async () => {
|
||||||
blockSuiteWorkspace = new Workspace({
|
blockSuiteWorkspace = new Workspace({
|
||||||
id: 'test',
|
id: 'test',
|
||||||
schema,
|
schema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
blockSuiteWorkspace.doc.emit('sync', []);
|
||||||
|
|
||||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
||||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
|
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
|
||||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
|
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useBlockSuiteWorkspaceHelper', () => {
|
describe('useBlockSuiteWorkspaceHelper', () => {
|
||||||
test('should create page', () => {
|
test('should create page', async () => {
|
||||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
||||||
const helperHook = renderHook(() =>
|
const helperHook = renderHook(() =>
|
||||||
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace)
|
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace)
|
||||||
@@ -36,6 +44,7 @@ describe('useBlockSuiteWorkspaceHelper', () => {
|
|||||||
const pageMetaHook = renderHook(() =>
|
const pageMetaHook = renderHook(() =>
|
||||||
useBlockSuitePageMeta(blockSuiteWorkspace)
|
useBlockSuitePageMeta(blockSuiteWorkspace)
|
||||||
);
|
);
|
||||||
|
await new Promise(resolve => setTimeout(resolve));
|
||||||
expect(pageMetaHook.result.current.length).toBe(3);
|
expect(pageMetaHook.result.current.length).toBe(3);
|
||||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
||||||
const page = helperHook.result.current.createPage('page4');
|
const page = helperHook.result.current.createPage('page4');
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type { Atom } from 'jotai';
|
|||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useJournalHelper } from './use-journal';
|
||||||
|
|
||||||
const weakMap = new WeakMap<Workspace, Atom<PageMeta[]>>();
|
const weakMap = new WeakMap<Workspace, Atom<PageMeta[]>>();
|
||||||
|
|
||||||
export function useBlockSuitePageMeta(
|
export function useAllBlockSuitePageMeta(
|
||||||
blockSuiteWorkspace: Workspace
|
blockSuiteWorkspace: Workspace
|
||||||
): PageMeta[] {
|
): PageMeta[] {
|
||||||
if (!weakMap.has(blockSuiteWorkspace)) {
|
if (!weakMap.has(blockSuiteWorkspace)) {
|
||||||
@@ -26,6 +28,18 @@ export function useBlockSuitePageMeta(
|
|||||||
return useAtomValue(weakMap.get(blockSuiteWorkspace) as Atom<PageMeta[]>);
|
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) {
|
export function usePageMetaHelper(blockSuiteWorkspace: Workspace) {
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => {
|
|||||||
(maybeDate: MaybeDate) => {
|
(maybeDate: MaybeDate) => {
|
||||||
const title = dayjs(maybeDate).format(JOURNAL_DATE_FORMAT);
|
const title = dayjs(maybeDate).format(JOURNAL_DATE_FORMAT);
|
||||||
const page = bsWorkspaceHelper.createPage();
|
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 =>
|
initEmptyPage(page, title).catch(err =>
|
||||||
console.error('Failed to load journal page', err)
|
console.error('Failed to load journal page', err)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { MoveToTrash } from '@affine/core/components/page-list';
|
import { MoveToTrash } from '@affine/core/components/page-list';
|
||||||
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
|
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 { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
|
||||||
import {
|
import {
|
||||||
useJournalHelper,
|
useJournalHelper,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
useJournalRouteHelper,
|
useJournalRouteHelper,
|
||||||
} from '@affine/core/hooks/use-journal';
|
} from '@affine/core/hooks/use-journal';
|
||||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||||
|
import type { BlockSuiteWorkspace } from '@affine/core/shared';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import {
|
import {
|
||||||
EdgelessIcon,
|
EdgelessIcon,
|
||||||
@@ -20,7 +22,7 @@ import {
|
|||||||
PageIcon,
|
PageIcon,
|
||||||
TodayIcon,
|
TodayIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import type { Page } from '@blocksuite/store';
|
import type { Page, PageMeta } from '@blocksuite/store';
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -41,21 +43,28 @@ const CountDisplay = ({
|
|||||||
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
|
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
|
||||||
};
|
};
|
||||||
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
|
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
page: Page;
|
pageMeta: PageMeta;
|
||||||
|
workspace: BlockSuiteWorkspace;
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
}
|
}
|
||||||
const PageItem = ({ page, right, className, ...attrs }: PageItemProps) => {
|
const PageItem = ({
|
||||||
const { isJournal } = useJournalInfoHelper(page.workspace, page.id);
|
pageMeta,
|
||||||
const title = useBlockSuiteWorkspacePageTitle(page.workspace, page.id);
|
workspace,
|
||||||
|
right,
|
||||||
|
className,
|
||||||
|
...attrs
|
||||||
|
}: PageItemProps) => {
|
||||||
|
const { isJournal } = useJournalInfoHelper(workspace, pageMeta.id);
|
||||||
|
const title = useBlockSuiteWorkspacePageTitle(workspace, pageMeta.id);
|
||||||
|
|
||||||
const Icon = isJournal
|
const Icon = isJournal
|
||||||
? TodayIcon
|
? TodayIcon
|
||||||
: page.meta.mode === 'edgeless'
|
: pageMeta.mode === 'edgeless'
|
||||||
? EdgelessIcon
|
? EdgelessIcon
|
||||||
: PageIcon;
|
: PageIcon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-label={page.meta.title}
|
aria-label={pageMeta.title}
|
||||||
className={clsx(className, styles.pageItem)}
|
className={clsx(className, styles.pageItem)}
|
||||||
{...attrs}
|
{...attrs}
|
||||||
>
|
>
|
||||||
@@ -114,15 +123,12 @@ const EditorJournalPanel = (props: EditorExtensionProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sortPagesByDate = (
|
const sortPagesByDate = (
|
||||||
pages: Page[],
|
pages: PageMeta[],
|
||||||
field: 'updatedDate' | 'createDate',
|
field: 'updatedDate' | 'createDate',
|
||||||
order: 'asc' | 'desc' = 'desc'
|
order: 'asc' | 'desc' = 'desc'
|
||||||
) => {
|
) => {
|
||||||
return [...pages].sort((a, b) => {
|
return [...pages].sort((a, b) => {
|
||||||
return (
|
return (order === 'asc' ? 1 : -1) * dayjs(b[field]).diff(dayjs(a[field]));
|
||||||
(order === 'asc' ? 1 : -1) *
|
|
||||||
dayjs(b.meta[field]).diff(dayjs(a.meta[field]))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,21 +147,21 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
|
|||||||
const nodeRef = useRef<HTMLDivElement>(null);
|
const nodeRef = useRef<HTMLDivElement>(null);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
|
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
|
||||||
|
const pageMetas = useBlockSuitePageMeta(workspace);
|
||||||
|
|
||||||
const navigateHelper = useNavigateHelper();
|
const navigateHelper = useNavigateHelper();
|
||||||
|
|
||||||
const getTodaysPages = useCallback(
|
const getTodaysPages = useCallback(
|
||||||
(field: 'createDate' | 'updatedDate') => {
|
(field: 'createDate' | 'updatedDate') => {
|
||||||
const pages: Page[] = [];
|
return sortPagesByDate(
|
||||||
Array.from(workspace.pages.values()).forEach(page => {
|
pageMetas.filter(pageMeta => {
|
||||||
if (page.meta.trash) return;
|
if (pageMeta.trash) return false;
|
||||||
if (page.meta[field] && dayjs(page.meta[field]).isSame(date, 'day')) {
|
return pageMeta[field] && dayjs(pageMeta[field]).isSame(date, 'day');
|
||||||
pages.push(page);
|
}),
|
||||||
}
|
field
|
||||||
});
|
);
|
||||||
return sortPagesByDate(pages, field);
|
|
||||||
},
|
},
|
||||||
[date, workspace.pages]
|
[date, pageMetas]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdToday = useMemo(
|
const createdToday = useMemo(
|
||||||
@@ -224,14 +230,15 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
|
|||||||
<Scrollable.Scrollbar />
|
<Scrollable.Scrollbar />
|
||||||
<Scrollable.Viewport>
|
<Scrollable.Viewport>
|
||||||
<div className={styles.dailyCountContent} ref={nodeRef}>
|
<div className={styles.dailyCountContent} ref={nodeRef}>
|
||||||
{renderList.map((page, index) => (
|
{renderList.map((pageMeta, index) => (
|
||||||
<PageItem
|
<PageItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigateHelper.openPage(workspace.id, page.id)
|
navigateHelper.openPage(workspace.id, pageMeta.id)
|
||||||
}
|
}
|
||||||
tabIndex={name === activeItem ? 0 : -1}
|
tabIndex={name === activeItem ? 0 : -1}
|
||||||
key={index}
|
key={index}
|
||||||
page={page}
|
pageMeta={pageMeta}
|
||||||
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -282,7 +289,8 @@ const ConflictList = ({
|
|||||||
<PageItem
|
<PageItem
|
||||||
aria-label={page.meta.title}
|
aria-label={page.meta.title}
|
||||||
aria-selected={isCurrent}
|
aria-selected={isCurrent}
|
||||||
page={page}
|
pageMeta={page.meta}
|
||||||
|
workspace={workspace}
|
||||||
key={page.id}
|
key={page.id}
|
||||||
right={
|
right={
|
||||||
<Menu
|
<Menu
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
} from '@affine/core/modules/workspace';
|
} from '@affine/core/modules/workspace';
|
||||||
import { type Workspace } from '@affine/workspace';
|
import { type Workspace } from '@affine/workspace';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { type ReactElement, Suspense, useEffect, useMemo } from 'react';
|
import {
|
||||||
|
type ReactElement,
|
||||||
|
Suspense,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { Outlet, useParams } from 'react-router-dom';
|
import { Outlet, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
|
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
|
||||||
@@ -67,12 +73,34 @@ export const Component = (): ReactElement => {
|
|||||||
localStorage.setItem('last_workspace_id', workspace.id);
|
localStorage.setItem('last_workspace_id', workspace.id);
|
||||||
}, [setCurrentWorkspace, meta, workspaceManager, workspace]);
|
}, [setCurrentWorkspace, meta, workspaceManager, workspace]);
|
||||||
|
|
||||||
|
const [workspaceIsLoading, setWorkspaceIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// hotfix: avoid doing operation, before workspace is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspace) {
|
||||||
|
setWorkspaceIsLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metaYMap = workspace.blockSuiteWorkspace.doc.getMap('meta');
|
||||||
|
|
||||||
|
const handleYMapChanged = () => {
|
||||||
|
setWorkspaceIsLoading(metaYMap.size === 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleYMapChanged();
|
||||||
|
|
||||||
|
metaYMap.observe(handleYMapChanged);
|
||||||
|
return () => {
|
||||||
|
metaYMap.unobserve(handleYMapChanged);
|
||||||
|
};
|
||||||
|
}, [workspace]);
|
||||||
|
|
||||||
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
|
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
|
||||||
if (listLoading === false && meta === undefined) {
|
if (listLoading === false && meta === undefined) {
|
||||||
return <PageNotFound />;
|
return <PageNotFound />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace || workspaceIsLoading) {
|
||||||
return <WorkspaceFallback key="workspaceLoading" />;
|
return <WorkspaceFallback key="workspaceLoading" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
mutation checkout(
|
mutation createCheckoutSession($input: CreateCheckoutSessionInput!) {
|
||||||
$recurring: SubscriptionRecurring!
|
createCheckoutSession(input: $input)
|
||||||
$idempotencyKey: String!
|
|
||||||
) {
|
|
||||||
checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,14 +127,14 @@ mutation changePassword($token: String!, $newPassword: String!) {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkoutMutation = {
|
export const createCheckoutSessionMutation = {
|
||||||
id: 'checkoutMutation' as const,
|
id: 'createCheckoutSessionMutation' as const,
|
||||||
operationName: 'checkout',
|
operationName: 'createCheckoutSession',
|
||||||
definitionName: 'checkout',
|
definitionName: 'createCheckoutSession',
|
||||||
containsFile: false,
|
containsFile: false,
|
||||||
query: `
|
query: `
|
||||||
mutation checkout($recurring: SubscriptionRecurring!, $idempotencyKey: String!) {
|
mutation createCheckoutSession($input: CreateCheckoutSessionInput!) {
|
||||||
checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
|
createCheckoutSession(input: $input)
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -192,12 +192,14 @@ export type ChangePasswordMutation = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CheckoutMutationVariables = Exact<{
|
export type CreateCheckoutSessionMutationVariables = Exact<{
|
||||||
recurring: SubscriptionRecurring;
|
input: CreateCheckoutSessionInput;
|
||||||
idempotencyKey: Scalars['String']['input'];
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
|
export type CreateCheckoutSessionMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
createCheckoutSession: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateCustomerPortalMutationVariables = Exact<{
|
export type CreateCustomerPortalMutationVariables = Exact<{
|
||||||
[key: string]: never;
|
[key: string]: never;
|
||||||
@@ -1041,9 +1043,9 @@ export type Mutations =
|
|||||||
response: ChangePasswordMutation;
|
response: ChangePasswordMutation;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: 'checkoutMutation';
|
name: 'createCheckoutSessionMutation';
|
||||||
variables: CheckoutMutationVariables;
|
variables: CreateCheckoutSessionMutationVariables;
|
||||||
response: CheckoutMutation;
|
response: CreateCheckoutSessionMutation;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: 'createCustomerPortalMutation';
|
name: 'createCustomerPortalMutation';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"nanoid": "^5.0.3",
|
"nanoid": "^5.0.3",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"y-protocols": "^1.0.6",
|
"y-protocols": "^1.0.6",
|
||||||
"yjs": "^13.6.10"
|
"yjs": "^13.6.10"
|
||||||
|
|||||||
@@ -18,26 +18,70 @@ export function createCloudAwarenessProvider(
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
awareness: Awareness
|
awareness: Awareness
|
||||||
): AwarenessProvider {
|
): 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,
|
workspaceId: wsId,
|
||||||
awarenessUpdate,
|
awarenessUpdate,
|
||||||
}: {
|
}: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
awarenessUpdate: string;
|
awarenessUpdate: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (wsId !== workspaceId) {
|
if (wsId !== this.workspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyAwarenessUpdate(
|
applyAwarenessUpdate(
|
||||||
awareness,
|
this.awareness,
|
||||||
base64ToUint8Array(awarenessUpdate),
|
base64ToUint8Array(awarenessUpdate),
|
||||||
'remote'
|
'remote'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
|
awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
|
||||||
if (origin === 'remote') {
|
if (origin === 'remote') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -46,63 +90,41 @@ export function createCloudAwarenessProvider(
|
|||||||
res.concat(cur)
|
res.concat(cur)
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = encodeAwarenessUpdate(awareness, changedClients);
|
const update = encodeAwarenessUpdate(this.awareness, changedClients);
|
||||||
uint8ArrayToBase64(update)
|
uint8ArrayToBase64(update)
|
||||||
.then(encodedUpdate => {
|
.then(encodedUpdate => {
|
||||||
socket.emit('awareness-update', {
|
this.socket.emit('awareness-update', {
|
||||||
workspaceId: workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
awarenessUpdate: encodedUpdate,
|
awarenessUpdate: encodedUpdate,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(err => logger.error(err));
|
.catch(err => logger.error(err));
|
||||||
};
|
};
|
||||||
|
|
||||||
const newClientAwarenessInitHandler = () => {
|
newClientAwarenessInitHandler = () => {
|
||||||
const awarenessUpdate = encodeAwarenessUpdate(awareness, [
|
const awarenessUpdate = encodeAwarenessUpdate(this.awareness, [
|
||||||
awareness.clientID,
|
this.awareness.clientID,
|
||||||
]);
|
]);
|
||||||
uint8ArrayToBase64(awarenessUpdate)
|
uint8ArrayToBase64(awarenessUpdate)
|
||||||
.then(encodedAwarenessUpdate => {
|
.then(encodedAwarenessUpdate => {
|
||||||
socket.emit('awareness-update', {
|
this.socket.emit('awareness-update', {
|
||||||
guid: workspaceId,
|
guid: this.workspaceId,
|
||||||
awarenessUpdate: encodedAwarenessUpdate,
|
awarenessUpdate: encodedAwarenessUpdate,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(err => logger.error(err));
|
.catch(err => logger.error(err));
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowBeforeUnloadHandler = () => {
|
windowBeforeUnloadHandler = () => {
|
||||||
removeAwarenessStates(awareness, [awareness.clientID], 'window unload');
|
removeAwarenessStates(
|
||||||
|
this.awareness,
|
||||||
|
[this.awareness.clientID],
|
||||||
|
'window unload'
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleConnect() {
|
handleConnect = () => {
|
||||||
socket.emit('client-handshake-awareness', workspaceId);
|
this.socket.emit('client-handshake-awareness', this.workspaceId);
|
||||||
socket.emit('awareness-init', workspaceId);
|
this.socket.emit('awareness-init', this.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);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +1,106 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
import { fetchWithTraceReport } from '@affine/graphql';
|
import { fetchWithTraceReport } from '@affine/graphql';
|
||||||
import type { SyncStorage } from '@affine/workspace';
|
import { type SyncStorage } from '@affine/workspace';
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
distinctUntilChanged,
|
||||||
|
filter,
|
||||||
|
lastValueFrom,
|
||||||
|
take,
|
||||||
|
timeout,
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
import { getIoManager } from '../../utils/affine-io';
|
import { getIoManager } from '../../utils/affine-io';
|
||||||
import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
|
import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
|
||||||
import { MultipleBatchSyncSender } from './batch-sync-sender';
|
|
||||||
|
|
||||||
const logger = new DebugLogger('affine:storage:socketio');
|
const logger = new DebugLogger('affine:storage:socketio');
|
||||||
|
|
||||||
export function createAffineStorage(
|
export class AffineSyncStorage implements SyncStorage {
|
||||||
workspaceId: string
|
name = 'affine-cloud';
|
||||||
): SyncStorage & { disconnect: () => void } {
|
|
||||||
logger.debug('createAffineStorage', workspaceId);
|
|
||||||
const socket = getIoManager().socket('/');
|
|
||||||
|
|
||||||
const syncSender = new MultipleBatchSyncSender(async (guid, updates) => {
|
SEND_TIMEOUT = 30000;
|
||||||
const payload = await Promise.all(
|
|
||||||
updates.map(update => uint8ArrayToBase64(update))
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Promise(resolve => {
|
socket = getIoManager().socket('/');
|
||||||
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({
|
connected = new BehaviorSubject(false);
|
||||||
accepted: !response.error,
|
handshook = new BehaviorSubject(false);
|
||||||
// TODO: reuse `EventError` with server
|
|
||||||
retry: response.error?.code === 'INTERNAL',
|
constructor(private readonly workspaceId: string) {
|
||||||
});
|
const handleConnect = () => {
|
||||||
}
|
this.connected.next(true);
|
||||||
);
|
};
|
||||||
|
this.socket.on('connect', handleConnect);
|
||||||
|
this.connected.next(this.socket.connected);
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
this.connected.next(false);
|
||||||
|
this.handshook.next(false);
|
||||||
|
};
|
||||||
|
this.socket.on('disconnect', handleDisconnect);
|
||||||
|
|
||||||
|
this.connected.pipe(distinctUntilChanged()).subscribe(connected => {
|
||||||
|
if (connected) {
|
||||||
|
this.socket
|
||||||
|
.timeout(this.SEND_TIMEOUT)
|
||||||
|
.emitWithAck('client-handshake-sync', this.workspaceId)
|
||||||
|
.then(() => {
|
||||||
|
this.handshook.next(true);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
logger.error('client-handshake-sync error', {
|
||||||
|
workspaceId: this.workspaceId,
|
||||||
|
error: err,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleConnect() {
|
this.disconnect = () => {
|
||||||
socket.emit(
|
this.socket.emit('client-leave-sync', this.workspaceId);
|
||||||
'client-handshake-sync',
|
this.socket.off('connect', handleConnect);
|
||||||
workspaceId,
|
this.socket.off('disconnect', handleDisconnect);
|
||||||
(response: { error?: any }) => {
|
};
|
||||||
if (!response.error) {
|
|
||||||
syncSender.start();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitForHandshake() {
|
||||||
|
await lastValueFrom(
|
||||||
|
this.handshook.pipe(
|
||||||
|
filter(v => v),
|
||||||
|
timeout({ first: this.SEND_TIMEOUT }),
|
||||||
|
take(1)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('connect', handleConnect);
|
handleConnect = () => {
|
||||||
|
this.socket.emit('client-handshake-sync', this.workspaceId);
|
||||||
|
};
|
||||||
|
|
||||||
socket.connect();
|
async pull(
|
||||||
|
docId: string,
|
||||||
socket.emit(
|
state: Uint8Array
|
||||||
'client-handshake-sync',
|
): Promise<{ data: Uint8Array; state?: Uint8Array } | null> {
|
||||||
workspaceId,
|
await this.waitForHandshake();
|
||||||
(response: { error?: any }) => {
|
|
||||||
if (!response.error) {
|
|
||||||
syncSender.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'affine-cloud',
|
|
||||||
async pull(docId, state) {
|
|
||||||
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
|
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
logger.debug('doc-load-v2', {
|
logger.debug('doc-load-v2', {
|
||||||
workspaceId: workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
guid: docId,
|
guid: docId,
|
||||||
stateVector,
|
stateVector,
|
||||||
});
|
});
|
||||||
socket.emit(
|
|
||||||
'doc-load-v2',
|
const response:
|
||||||
{
|
| { error: any }
|
||||||
workspaceId: workspaceId,
|
| { data: { missing: string; state: string } } = await this.socket
|
||||||
|
.timeout(this.SEND_TIMEOUT)
|
||||||
|
.emitWithAck('doc-load-v2', {
|
||||||
|
workspaceId: this.workspaceId,
|
||||||
guid: docId,
|
guid: docId,
|
||||||
stateVector,
|
stateVector,
|
||||||
},
|
});
|
||||||
(
|
|
||||||
response: // TODO: reuse `EventError` with server
|
|
||||||
{ error: any } | { data: { missing: string; state: string } }
|
|
||||||
) => {
|
|
||||||
logger.debug('doc-load callback', {
|
logger.debug('doc-load callback', {
|
||||||
workspaceId: workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
guid: docId,
|
guid: docId,
|
||||||
stateVector,
|
stateVector,
|
||||||
response,
|
response,
|
||||||
@@ -109,62 +109,93 @@ export function createAffineStorage(
|
|||||||
if ('error' in response) {
|
if ('error' in response) {
|
||||||
// TODO: result `EventError` with server
|
// TODO: result `EventError` with server
|
||||||
if (response.error.code === 'DOC_NOT_FOUND') {
|
if (response.error.code === 'DOC_NOT_FOUND') {
|
||||||
resolve(null);
|
return null;
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(response.error.message));
|
throw new Error(response.error.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resolve({
|
return {
|
||||||
data: base64ToUint8Array(response.data.missing),
|
data: base64ToUint8Array(response.data.missing),
|
||||||
state: response.data.state
|
state: response.data.state
|
||||||
? base64ToUint8Array(response.data.state)
|
? base64ToUint8Array(response.data.state)
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
|
||||||
});
|
async push(docId: string, update: Uint8Array) {
|
||||||
},
|
await this.waitForHandshake();
|
||||||
async push(docId, update) {
|
|
||||||
logger.debug('client-update-v2', {
|
logger.debug('client-update-v2', {
|
||||||
workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
guid: docId,
|
guid: docId,
|
||||||
update,
|
update,
|
||||||
});
|
});
|
||||||
|
|
||||||
await syncSender.send(docId, update);
|
const payload = await uint8ArrayToBase64(update);
|
||||||
},
|
|
||||||
async subscribe(cb, disconnect) {
|
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: {
|
const handleUpdate = async (message: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
guid: string;
|
guid: string;
|
||||||
updates: string[];
|
updates: string[];
|
||||||
}) => {
|
}) => {
|
||||||
if (message.workspaceId === workspaceId) {
|
if (message.workspaceId === this.workspaceId) {
|
||||||
message.updates.forEach(update => {
|
message.updates.forEach(update => {
|
||||||
cb(message.guid, base64ToUint8Array(update));
|
cb(message.guid, base64ToUint8Array(update));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
socket.on('server-updates', handleUpdate);
|
const handleDisconnect = (reason: string) => {
|
||||||
|
this.socket.off('server-updates', handleUpdate);
|
||||||
socket.on('disconnect', reason => {
|
|
||||||
socket.off('server-updates', handleUpdate);
|
|
||||||
disconnect(reason);
|
disconnect(reason);
|
||||||
});
|
};
|
||||||
|
this.socket.on('server-updates', handleUpdate);
|
||||||
|
|
||||||
|
this.socket.on('disconnect', handleDisconnect);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('server-updates', handleUpdate);
|
this.socket.off('server-updates', handleUpdate);
|
||||||
};
|
this.socket.off('disconnect', handleDisconnect);
|
||||||
},
|
|
||||||
disconnect() {
|
|
||||||
syncSender.stop();
|
|
||||||
socket.emit('client-leave-sync', workspaceId);
|
|
||||||
socket.off('connect', handleConnect);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAffineStorage(
|
||||||
|
workspaceId: string
|
||||||
|
): SyncStorage & { disconnect: () => void } {
|
||||||
|
return new AffineSyncStorage(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
export function createAffineStaticStorage(workspaceId: string): SyncStorage {
|
export function createAffineStaticStorage(workspaceId: string): SyncStorage {
|
||||||
logger.debug('createAffineStaticStorage', workspaceId);
|
logger.debug('createAffineStaticStorage', workspaceId);
|
||||||
|
|
||||||
|
|||||||
@@ -812,6 +812,7 @@ __metadata:
|
|||||||
lodash-es: "npm:^4.17.21"
|
lodash-es: "npm:^4.17.21"
|
||||||
nanoid: "npm:^5.0.3"
|
nanoid: "npm:^5.0.3"
|
||||||
next-auth: "npm:^4.24.5"
|
next-auth: "npm:^4.24.5"
|
||||||
|
rxjs: "npm:^7.8.1"
|
||||||
socket.io-client: "npm:^4.7.2"
|
socket.io-client: "npm:^4.7.2"
|
||||||
vitest: "npm:1.1.3"
|
vitest: "npm:1.1.3"
|
||||||
ws: "npm:^8.14.2"
|
ws: "npm:^8.14.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user