mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4c9e3c36d | |||
| 1a8d884f8e | |||
| 91acb88a2d | |||
| 43704d60fb | |||
| 46e7e35357 | |||
| b98ab495bb | |||
| 99b07c2ee1 | |||
| e1e0ac2345 | |||
| bdccf4e9fd | |||
| 11cf1928b5 | |||
| 5215c73166 |
@@ -62,6 +62,18 @@
|
||||
"concurrency": 10
|
||||
}
|
||||
},
|
||||
"queues.calendar": {
|
||||
"type": "object",
|
||||
"description": "The config for calendar job queue\n@default {\"concurrency\":4}",
|
||||
"properties": {
|
||||
"concurrency": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"concurrency": 4
|
||||
}
|
||||
},
|
||||
"queues.doc": {
|
||||
"type": "object",
|
||||
"description": "The config for doc job queue\n@default {\"concurrency\":1}",
|
||||
@@ -843,7 +855,7 @@
|
||||
"properties": {
|
||||
"google": {
|
||||
"type": "object",
|
||||
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\"}\n@link https://developers.google.com/calendar/api/guides/push",
|
||||
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
@@ -859,6 +871,9 @@
|
||||
},
|
||||
"webhookVerificationToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestTimeoutMs": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
@@ -866,7 +881,8 @@
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"externalWebhookUrl": "",
|
||||
"webhookVerificationToken": ""
|
||||
"webhookVerificationToken": "",
|
||||
"requestTimeoutMs": 10000
|
||||
}
|
||||
},
|
||||
"caldav": {
|
||||
|
||||
@@ -48,6 +48,7 @@ testem.log
|
||||
/typings
|
||||
tsconfig.tsbuildinfo
|
||||
.context
|
||||
*.md
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
||||
Generated
-2
@@ -135,12 +135,10 @@ dependencies = [
|
||||
"napi-derive",
|
||||
"once_cell",
|
||||
"serde_json",
|
||||
"sha3",
|
||||
"sqlx",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"y-octo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/sync": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
||||
"@lottiefiles/dotlottie-wc": "^0.9.4",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/hast": "^3.0.4",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.3",
|
||||
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
||||
"@lottiefiles/dotlottie-wc": "^0.9.4",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE
|
||||
"calendar_subscriptions"
|
||||
ADD
|
||||
COLUMN "next_sync_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD
|
||||
COLUMN "sync_retry_count" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE
|
||||
"calendar_subscriptions" AS s
|
||||
SET
|
||||
"next_sync_at" = CASE
|
||||
WHEN s."last_sync_at" IS NULL THEN CURRENT_TIMESTAMP
|
||||
ELSE s."last_sync_at" + make_interval(
|
||||
mins => COALESCE(a."refresh_interval_minutes", 30)
|
||||
)
|
||||
END
|
||||
FROM
|
||||
"calendar_accounts" AS a
|
||||
WHERE
|
||||
a."id" = s."account_id";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_subscriptions_custom_channel_id_idx" ON "calendar_subscriptions"("custom_channel_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_subscriptions_enabled_next_sync_at_idx" ON "calendar_subscriptions"("enabled", "next_sync_at");
|
||||
@@ -110,7 +110,7 @@
|
||||
"react-dom": "19.2.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.3",
|
||||
"semver": "^7.7.4",
|
||||
"ses": "^1.14.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^17.7.0",
|
||||
@@ -126,7 +126,6 @@
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/testing": "patch:@nestjs/testing@npm%3A10.4.15#~/.yarn/patches/@nestjs-testing-npm-10.4.15-d591a1705a.patch",
|
||||
"@react-email/preview-server": "^4.3.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/express-serve-static-core": "^5.0.6",
|
||||
@@ -139,8 +138,7 @@
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/on-headers": "^1.0.3",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"ava": "^6.4.0",
|
||||
|
||||
@@ -1037,6 +1037,8 @@ model CalendarSubscription {
|
||||
enabled Boolean @default(true)
|
||||
syncToken String? @map("sync_token") @db.Text
|
||||
lastSyncAt DateTime? @map("last_sync_at") @db.Timestamptz(3)
|
||||
nextSyncAt DateTime @default(now()) @map("next_sync_at") @db.Timestamptz(3)
|
||||
syncRetryCount Int @default(0) @map("sync_retry_count")
|
||||
customChannelId String? @map("custom_channel_id") @db.VarChar
|
||||
customResourceId String? @map("custom_resource_id") @db.VarChar
|
||||
channelExpiration DateTime? @map("channel_expiration") @db.Timestamptz(3)
|
||||
@@ -1050,6 +1052,8 @@ model CalendarSubscription {
|
||||
@@unique([accountId, externalCalendarId])
|
||||
@@index([accountId])
|
||||
@@index([provider, externalCalendarId])
|
||||
@@index([customChannelId])
|
||||
@@index([enabled, nextSyncAt])
|
||||
@@map("calendar_subscriptions")
|
||||
}
|
||||
|
||||
|
||||
@@ -858,7 +858,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com invited you to join Test Workspace
|
||||
> You were invited to join a workspace on AFFiNE
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -973,7 +973,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com accepted your invitation
|
||||
> Your workspace invitation was accepted
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1072,7 +1072,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com left Test Workspace
|
||||
> A workspace member left
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1132,7 +1132,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> New request to join Test Workspace
|
||||
> New request to join a workspace
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1239,7 +1239,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your request to join Test Workspace has been approved
|
||||
> Your request to join a workspace has been approved
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1294,7 +1294,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your request to join Test Workspace was declined
|
||||
> Your request to join a workspace was declined
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1348,7 +1348,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> You have been removed from Test Workspace
|
||||
> You have been removed from a workspace
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1401,7 +1401,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your ownership of Test Workspace has been transferred
|
||||
> Your workspace ownership has been transferred
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1454,7 +1454,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> You are now the owner of Test Workspace
|
||||
> You are now the owner of a workspace
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1506,7 +1506,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com mentioned you in Test Doc
|
||||
> You were mentioned in AFFiNE
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
@@ -1601,7 +1601,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com commented on Test Doc
|
||||
> New comment in AFFiNE
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
@@ -1695,7 +1695,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com mentioned you in a comment on Test Doc
|
||||
> You were mentioned in a comment
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
@@ -1894,7 +1894,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> You are now an admin of Test Workspace
|
||||
> You are now a workspace admin
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -1993,7 +1993,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your role has been changed in Test Workspace
|
||||
> Your workspace role has been changed
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2094,7 +2094,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> [Action Required] Final warning: Your workspace Test Workspace will be deleted in 24 hours
|
||||
> [Action Required] Final warning: Your workspace will be deleted in 24 hours
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2208,7 +2208,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> [Action Required] Important: Your workspace Test Workspace will be deleted soon
|
||||
> [Action Required] Important: Your workspace will be deleted soon
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2324,7 +2324,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your workspace Test Workspace has been deleted
|
||||
> Your workspace has been deleted
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2408,7 +2408,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> [Action Required] Your Test Workspace team workspace will expire soon
|
||||
> [Action Required] Your team workspace will expire soon
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2511,7 +2511,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your Test Workspace team workspace has expired
|
||||
> Your team workspace has expired
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<link␊
|
||||
@@ -2689,7 +2689,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should render mention email with empty doc title
|
||||
|
||||
> test@test.com mentioned you in
|
||||
> You were mentioned in AFFiNE
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { normalizeSMTPHeloHostname } from '../core/mail/utils';
|
||||
import { Renderers } from '../mails';
|
||||
import { TEST_DOC, TEST_USER } from '../mails/common';
|
||||
|
||||
@@ -21,3 +22,23 @@ test('should render mention email with empty doc title', async t => {
|
||||
});
|
||||
t.snapshot(content.html, content.subject);
|
||||
});
|
||||
|
||||
test('should normalize valid SMTP HELO hostnames', t => {
|
||||
t.is(normalizeSMTPHeloHostname('mail.example.com'), 'mail.example.com');
|
||||
t.is(normalizeSMTPHeloHostname(' localhost '), 'localhost');
|
||||
t.is(normalizeSMTPHeloHostname('[127.0.0.1]'), '[127.0.0.1]');
|
||||
t.is(normalizeSMTPHeloHostname('[IPv6:2001:db8::1]'), '[IPv6:2001:db8::1]');
|
||||
});
|
||||
|
||||
test('should reject invalid SMTP HELO hostnames', t => {
|
||||
t.is(normalizeSMTPHeloHostname(), undefined);
|
||||
t.is(normalizeSMTPHeloHostname(''), undefined);
|
||||
t.is(normalizeSMTPHeloHostname(' '), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('-example.com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('example-.com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('example..com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[bad host]'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[foo]'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[IPv6:foo]'), undefined);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export class MockEventBus {
|
||||
|
||||
emit = this.stub.emitAsync;
|
||||
emitAsync = this.stub.emitAsync;
|
||||
emitDetached = this.stub.emitAsync;
|
||||
broadcast = this.stub.broadcast;
|
||||
|
||||
last<Event extends EventName>(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { JobQueue } from '../../base';
|
||||
export class MockJobQueue {
|
||||
add = Sinon.createStubInstance(JobQueue).add.resolves();
|
||||
remove = Sinon.createStubInstance(JobQueue).remove.resolves();
|
||||
removeWhere = Sinon.createStubInstance(JobQueue).removeWhere.resolves([]);
|
||||
|
||||
last<Job extends JobName>(name: Job): { name: Job; payload: Jobs[Job] } {
|
||||
const addJobName = this.add.lastCall?.args[0];
|
||||
|
||||
@@ -437,6 +437,37 @@ test('should throw if user has subscription already', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow checkout after local subscription period ended', async t => {
|
||||
const { service, u1, db, stripe } = t.context;
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: u1.id,
|
||||
stripeSubscriptionId: 'sub_expired_ai',
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date('2026-05-04T13:11:45.000Z'),
|
||||
end: new Date('2026-05-11T13:11:45.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
);
|
||||
|
||||
t.true(stripe.checkout.sessions.create.calledOnce);
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: AI_YEARLY,
|
||||
coupon: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('should get correct pro plan price for checking out', async t => {
|
||||
const { app, service, u1, stripe, feature } = t.context;
|
||||
// non-ea user
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppModule } from '../app.module';
|
||||
import {
|
||||
CANARY_CLIENT_VERSION_MAX_AGE_DAYS,
|
||||
ConfigFactory,
|
||||
hasNewerVersion,
|
||||
UseNamedGuard,
|
||||
} from '../base';
|
||||
import { Public } from '../core/auth/guard';
|
||||
@@ -249,3 +250,11 @@ test('should reject old canary date version in canary namespace', async t => {
|
||||
env.NAMESPACE = prevNamespace;
|
||||
}
|
||||
});
|
||||
|
||||
test('should compare release versions for available upgrades', t => {
|
||||
t.false(hasNewerVersion('0.26.5', '0.26.4'));
|
||||
t.false(hasNewerVersion('0.26.5', '0.26.5'));
|
||||
t.true(hasNewerVersion('0.26.5', '0.26.6'));
|
||||
t.true(hasNewerVersion('0.26.5', '0.26.6-beta.1'));
|
||||
t.false(hasNewerVersion('0.26.6-beta.2', '0.26.6-beta.1'));
|
||||
});
|
||||
|
||||
@@ -88,12 +88,21 @@ export class EventBus
|
||||
emit<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.debug(`Dispatch event: ${event}`);
|
||||
|
||||
// NOTE(@forehalo):
|
||||
// Because all event handlers are wrapped in promisified metrics and cls context, they will always run in standalone tick.
|
||||
// In which way, if handler throws, an unhandled rejection will be triggered and end up with process exiting.
|
||||
// So we catch it here with `emitAsync`
|
||||
this.emitter.emitAsync(event, payload).catch(e => {
|
||||
this.emitter.emit('error', { event, payload, error: e });
|
||||
this.dispatchAsync(event, payload);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event in detached cls context to avoid inheriting current transaction.
|
||||
*/
|
||||
emitDetached<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.debug(`Dispatch event: ${event} (detached)`);
|
||||
|
||||
const requestId = this.cls.getId();
|
||||
this.cls.run({ ifNested: 'override' }, () => {
|
||||
this.cls.set(CLS_ID, requestId ?? genRequestId('event'));
|
||||
this.dispatchAsync(event, payload);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -166,6 +175,16 @@ export class EventBus
|
||||
return this.emitter.waitFor(name, timeout);
|
||||
}
|
||||
|
||||
private dispatchAsync<T extends EventName>(event: T, payload: Events[T]) {
|
||||
// NOTE:
|
||||
// Because all event handlers are wrapped in promisified metrics and cls context, they will always run in standalone tick.
|
||||
// In which way, if handler throws, an unhandled rejection will be triggered and end up with process exiting.
|
||||
// So we catch it here with `emitAsync`
|
||||
this.emitter.emitAsync(event, payload).catch(e => {
|
||||
this.emitter.emit('error', { event, payload, error: e });
|
||||
});
|
||||
}
|
||||
|
||||
private readonly bindEventHandlers = once(() => {
|
||||
this.scanner.scan().forEach(({ event, handler, opts }) => {
|
||||
this.on(event, handler, opts);
|
||||
|
||||
@@ -117,6 +117,22 @@ test('should remove job from queue', async t => {
|
||||
t.is(nullData, undefined);
|
||||
t.is(nullJob, undefined);
|
||||
});
|
||||
|
||||
test('should remove jobs by payload predicate', async t => {
|
||||
const keep = await queue.add('nightly.__test__job', { name: 'keep' });
|
||||
const remove = await queue.add('nightly.__test__job', { name: 'remove' });
|
||||
const other = await queue.add('nightly.__test__job2', { name: 'remove' });
|
||||
|
||||
const removed = await queue.removeWhere(
|
||||
'nightly.__test__job',
|
||||
job => job.name === 'remove'
|
||||
);
|
||||
|
||||
t.deepEqual(removed, [{ name: 'remove' }]);
|
||||
t.truthy(await queue.get(keep.id!, 'nightly.__test__job'));
|
||||
t.is(await queue.get(remove.id!, 'nightly.__test__job'), undefined);
|
||||
t.truthy(await queue.get(other.id!, 'nightly.__test__job2'));
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region executor
|
||||
|
||||
@@ -55,6 +55,14 @@ defineModuleConfig('job', {
|
||||
schema,
|
||||
},
|
||||
|
||||
'queues.calendar': {
|
||||
desc: 'The config for calendar job queue',
|
||||
default: {
|
||||
concurrency: 4,
|
||||
},
|
||||
schema,
|
||||
},
|
||||
|
||||
'queues.doc': {
|
||||
desc: 'The config for doc job queue',
|
||||
default: {
|
||||
|
||||
@@ -28,6 +28,7 @@ export enum Queue {
|
||||
DOC = 'doc',
|
||||
COPILOT = 'copilot',
|
||||
INDEXER = 'indexer',
|
||||
CALENDAR = 'calendar',
|
||||
}
|
||||
|
||||
export const QUEUES = Object.values(Queue);
|
||||
|
||||
@@ -55,6 +55,39 @@ export class JobQueue {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async removeWhere<T extends JobName>(
|
||||
jobName: T,
|
||||
predicate: (payload: Jobs[T]) => boolean | Promise<boolean>
|
||||
): Promise<Jobs[T][]> {
|
||||
const ns = namespace(jobName);
|
||||
const queue = this.getQueue(ns);
|
||||
const jobs = (await queue.getJobs([
|
||||
'waiting',
|
||||
'delayed',
|
||||
'prioritized',
|
||||
'paused',
|
||||
'waiting-children',
|
||||
])) as Job<JobData<T>>[];
|
||||
const removed: Jobs[T][] = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.name !== jobName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = job.data.payload;
|
||||
if (!(await predicate(payload))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await job.remove();
|
||||
this.logger.log(`Job ${jobName}(id=${job.id}) removed from queue ${ns}`);
|
||||
removed.push(payload);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
async get<T extends JobName>(jobId: string, jobName: T) {
|
||||
const ns = namespace(jobName);
|
||||
const queue = this.getQueue(ns);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import semver from 'semver';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Example: 2026.2.6-canary.015
|
||||
@@ -89,3 +91,26 @@ export function checkCanaryDateClientVersion(
|
||||
normalized: parsed.normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeComparableVersion(version: string): string | null {
|
||||
const canary = parseCanaryDateClientVersion(version);
|
||||
return semver.valid(canary?.normalized ?? version.trim(), {
|
||||
loose: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasNewerVersion(
|
||||
currentVersion: string,
|
||||
nextVersion: string
|
||||
): boolean {
|
||||
const current = normalizeComparableVersion(currentVersion);
|
||||
const next = normalizeComparableVersion(nextVersion);
|
||||
|
||||
if (!current || !next) {
|
||||
return currentVersion.trim() !== nextVersion.trim();
|
||||
}
|
||||
|
||||
return semver.gt(next, current, {
|
||||
loose: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Config, URLHelper } from '../../base';
|
||||
import { Config, hasNewerVersion, URLHelper } from '../../base';
|
||||
import { Namespace } from '../../env';
|
||||
import { Feature, type WorkspaceFeatureName } from '../../models';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
@@ -143,7 +143,7 @@ export class ServerConfigResolver {
|
||||
}>;
|
||||
|
||||
const latest = releases.at(0);
|
||||
if (!latest || latest.name === env.version) {
|
||||
if (!latest || !hasNewerVersion(env.version, latest.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ test('should update doc content to database when doc is updated', async t => {
|
||||
|
||||
const docId = randomUUID();
|
||||
await adapter.pushDocUpdates(workspace.id, docId, updates);
|
||||
await adapter.getDoc(workspace.id, docId);
|
||||
await adapter.getDocBinNative(workspace.id, docId);
|
||||
|
||||
mock.method(docReader, 'parseDocContent', () => {
|
||||
return {
|
||||
@@ -181,3 +181,22 @@ test('should ignore update workspace content to database when parse workspace co
|
||||
t.is(content!.name, null);
|
||||
t.is(content!.avatarKey, null);
|
||||
});
|
||||
|
||||
test('should ignore stale workspace when updating doc meta from snapshot event', async t => {
|
||||
const { docReader, listener, models } = t.context;
|
||||
const docId = randomUUID();
|
||||
mock.method(docReader, 'parseDocContent', () => ({
|
||||
title: 'test title',
|
||||
summary: 'test summary',
|
||||
}));
|
||||
|
||||
await models.workspace.delete(workspace.id);
|
||||
|
||||
await t.notThrowsAsync(async () => {
|
||||
await listener.markDocContentCacheStale({
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
blob: Buffer.from([0x01]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
});
|
||||
|
||||
if (isNewDoc) {
|
||||
this.event.emit('doc.created', {
|
||||
this.event.emitDetached('doc.created', {
|
||||
workspaceId,
|
||||
docId,
|
||||
editor: editorId,
|
||||
@@ -334,7 +334,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
});
|
||||
|
||||
if (updatedSnapshot) {
|
||||
this.event.emit('doc.snapshot.updated', {
|
||||
this.event.emitDetached('doc.snapshot.updated', {
|
||||
workspaceId: snapshot.spaceId,
|
||||
docId: snapshot.docId,
|
||||
blob,
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { OnEvent } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
|
||||
import { DocReader } from './reader';
|
||||
|
||||
const IGNORED_PRISMA_CODES = new Set(['P2003', 'P2025', 'P2028']);
|
||||
|
||||
function isIgnorableDocEventError(error: unknown) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return IGNORED_PRISMA_CODES.has(error.code);
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
return /transaction is aborted|transaction already closed/i.test(
|
||||
error.message
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocEventsListener {
|
||||
private readonly logger = new Logger(DocEventsListener.name);
|
||||
|
||||
constructor(
|
||||
private readonly docReader: DocReader,
|
||||
private readonly models: Models,
|
||||
@@ -20,21 +37,39 @@ export class DocEventsListener {
|
||||
blob,
|
||||
}: Events['doc.snapshot.updated']) {
|
||||
await this.docReader.markDocContentCacheStale(workspaceId, docId);
|
||||
const workspace = await this.models.workspace.get(workspaceId);
|
||||
if (!workspace) {
|
||||
this.logger.warn(
|
||||
`Skip stale doc snapshot event for missing workspace ${workspaceId}/${docId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const isDoc = workspaceId !== docId;
|
||||
// update doc content to database
|
||||
if (isDoc) {
|
||||
const content = this.docReader.parseDocContent(blob);
|
||||
if (!content) {
|
||||
try {
|
||||
if (isDoc) {
|
||||
const content = this.docReader.parseDocContent(blob);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
await this.models.doc.upsertMeta(workspaceId, docId, content);
|
||||
} else {
|
||||
// update workspace content to database
|
||||
const content = this.docReader.parseWorkspaceContent(blob);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
await this.models.workspace.update(workspaceId, content);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isIgnorableDocEventError(error)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
`Ignore stale doc snapshot event for ${workspaceId}/${docId}: ${message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.models.doc.upsertMeta(workspaceId, docId, content);
|
||||
} else {
|
||||
// update workspace content to database
|
||||
const content = this.docReader.parseWorkspaceContent(blob);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
await this.models.workspace.update(workspaceId, content);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { createTestingModule } from '../../../__tests__/utils';
|
||||
import { Cache } from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { MailJob } from '../job';
|
||||
import { MailSender } from '../sender';
|
||||
|
||||
let module: Awaited<ReturnType<typeof createTestingModule>>;
|
||||
let cache: Cache;
|
||||
let mailJob: MailJob;
|
||||
let sender: MailSender;
|
||||
let models: Models;
|
||||
|
||||
test.before(async () => {
|
||||
module = await createTestingModule();
|
||||
cache = module.get(Cache);
|
||||
mailJob = module.get(MailJob);
|
||||
sender = module.get(MailSender);
|
||||
models = module.get(Models);
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test('should clear pending mail records when user is deleted', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const another = await module.create(Mockers.User);
|
||||
const sendMailKey = 'mailjob:sendMail';
|
||||
const retryMailKey = 'mailjob:sendMail:retry';
|
||||
const userKey = `${sendMailKey}:SignIn:${user.email}`;
|
||||
const userRetryKey = `${sendMailKey}:VerifyEmail:${user.email}`;
|
||||
const anotherKey = `${sendMailKey}:SignIn:${another.email}`;
|
||||
|
||||
await cache.mapSet(sendMailKey, userKey, 1);
|
||||
await cache.mapSet(sendMailKey, anotherKey, 1);
|
||||
await cache.mapSet(
|
||||
retryMailKey,
|
||||
userRetryKey,
|
||||
JSON.stringify({
|
||||
startTime: Date.now(),
|
||||
name: 'VerifyEmail',
|
||||
to: user.email,
|
||||
props: { url: 'https://affine.pro/verify' },
|
||||
})
|
||||
);
|
||||
|
||||
await mailJob.onUserDeleted({ ...user, ownedWorkspaces: [] });
|
||||
|
||||
t.true(module.queue.removeWhere.calledOnce);
|
||||
t.is(module.queue.removeWhere.firstCall.args[0], 'notification.sendMail');
|
||||
const shouldRemove = module.queue.removeWhere.firstCall.args[1];
|
||||
t.true(
|
||||
await shouldRemove({
|
||||
to: user.email,
|
||||
} as Jobs['notification.sendMail'])
|
||||
);
|
||||
t.false(
|
||||
await shouldRemove({
|
||||
to: another.email,
|
||||
} as Jobs['notification.sendMail'])
|
||||
);
|
||||
t.is(await cache.mapGet(sendMailKey, userKey), undefined);
|
||||
t.is(await cache.mapGet(retryMailKey, userRetryKey), undefined);
|
||||
t.is(await cache.mapGet(sendMailKey, anotherKey), 1);
|
||||
});
|
||||
|
||||
test('should skip queued mail for disabled recipient', async t => {
|
||||
const user = await module.create(Mockers.User, { disabled: true });
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now(),
|
||||
name: 'SignIn',
|
||||
to: user.email,
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
t.truthy(await models.user.get(user.id, { withDisabled: true }));
|
||||
});
|
||||
|
||||
test('should drop expired mail retry', async t => {
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now() - 25 * 60 * 60 * 1000,
|
||||
name: 'SignIn',
|
||||
to: 'expired-retry@example.com',
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should drop time-sensitive mail after its business expiration', async t => {
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now() - 31 * 60 * 1000,
|
||||
name: 'SignIn',
|
||||
to: 'expired-sign-in@example.com',
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should use explicit mail expiration when provided', async t => {
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now(),
|
||||
expiresAt: Date.now() - 1,
|
||||
name: 'MemberInvitation',
|
||||
to: 'expired-invitation@example.com',
|
||||
props: {
|
||||
user: {
|
||||
$$userId: 'owner-id',
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: 'workspace-id',
|
||||
},
|
||||
url: 'https://affine.pro/invite/test',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should drop mail retry after max attempts', async t => {
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now(),
|
||||
retryCount: 12,
|
||||
name: 'SignIn',
|
||||
to: 'max-retry@example.com',
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should requeue legacy stringified retry mail', async t => {
|
||||
const retryMailKey = 'mailjob:sendMail:retry';
|
||||
const job: Jobs['notification.sendMail'] = {
|
||||
startTime: Date.now(),
|
||||
name: 'SignIn',
|
||||
to: 'legacy-retry@example.com',
|
||||
props: {
|
||||
url: 'https://affine.pro/sign-in',
|
||||
otp: '123456',
|
||||
},
|
||||
};
|
||||
const cacheKey = `${retryMailKey}:SignIn:${job.to}`;
|
||||
|
||||
Sinon.stub(cache, 'mapRandomKey')
|
||||
.onFirstCall()
|
||||
.resolves(cacheKey)
|
||||
.onSecondCall()
|
||||
.resolves(undefined);
|
||||
await cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job));
|
||||
await mailJob.sendRetryMails();
|
||||
|
||||
t.true(module.queue.add.calledWith('notification.sendMail', job));
|
||||
t.is(await cache.mapGet(retryMailKey, cacheKey), undefined);
|
||||
});
|
||||
|
||||
test('should skip member invitation mail when rendered workspace name contains domain', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const member = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
name: 'BTC https://spam.example',
|
||||
});
|
||||
const send = Sinon.stub(sender, 'send').resolves(true);
|
||||
|
||||
await mailJob.sendMail({
|
||||
startTime: Date.now(),
|
||||
name: 'MemberInvitation',
|
||||
to: member.email,
|
||||
props: {
|
||||
user: {
|
||||
$$userId: owner.id,
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: workspace.id,
|
||||
},
|
||||
url: 'https://affine.pro/invite/test',
|
||||
},
|
||||
});
|
||||
|
||||
t.false(send.called);
|
||||
});
|
||||
|
||||
test('should keep dynamic mail props untouched for retry', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const member = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
name: 'Safe Workspace',
|
||||
});
|
||||
Sinon.stub(sender, 'send').resolves(false);
|
||||
const job: Jobs['notification.sendMail'] = {
|
||||
startTime: Date.now(),
|
||||
name: 'MemberInvitation',
|
||||
to: member.email,
|
||||
props: {
|
||||
user: {
|
||||
$$userId: owner.id,
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: workspace.id,
|
||||
},
|
||||
url: 'https://affine.pro/invite/test',
|
||||
},
|
||||
};
|
||||
|
||||
await mailJob.sendMail(job);
|
||||
|
||||
t.deepEqual(job.props.user, { $$userId: owner.id });
|
||||
t.deepEqual(job.props.workspace, { $$workspaceId: workspace.id });
|
||||
});
|
||||
@@ -2,12 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { Cache, JOB_SIGNAL, JobQueue, OnJob, sleep } from '../../base';
|
||||
import { Cache, JOB_SIGNAL, JobQueue, OnEvent, OnJob, sleep } from '../../base';
|
||||
import { type MailName, MailProps, Renderers } from '../../mails';
|
||||
import { UserProps, WorkspaceProps } from '../../mails/components';
|
||||
import { Models } from '../../models';
|
||||
import { DocReader } from '../doc/reader';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { containsUrlOrDomain } from '../workspaces/abuse';
|
||||
import { MailSender, SendOptions } from './sender';
|
||||
|
||||
type DynamicallyFetchedProps<Props> = {
|
||||
@@ -35,7 +36,11 @@ type SendMailJob<Mail extends MailName = MailName, Props = MailProps<Mail>> = {
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'notification.sendMail': { startTime: number } & {
|
||||
'notification.sendMail': {
|
||||
startTime: number;
|
||||
retryCount?: number;
|
||||
expiresAt?: number;
|
||||
} & {
|
||||
[K in MailName]: SendMailJob<K>;
|
||||
}[MailName];
|
||||
}
|
||||
@@ -47,6 +52,19 @@ const sendMailCacheKey = (name: string, to: string) =>
|
||||
`${sendMailKey}:${name}:${to}`;
|
||||
const retryMaxPerTick = 20;
|
||||
const retryFirstTime = 3;
|
||||
const retryMaxAttempts = 12;
|
||||
const retryMaxAge = 24 * 60 * 60 * 1000;
|
||||
const magicLinkExpiresIn = 30 * 60 * 1000;
|
||||
|
||||
const mailExpiresIn: Partial<Record<MailName, number>> = {
|
||||
SignIn: magicLinkExpiresIn,
|
||||
SignUp: magicLinkExpiresIn,
|
||||
SetPassword: magicLinkExpiresIn,
|
||||
ChangePassword: magicLinkExpiresIn,
|
||||
VerifyEmail: magicLinkExpiresIn,
|
||||
ChangeEmail: magicLinkExpiresIn,
|
||||
VerifyChangeEmail: magicLinkExpiresIn,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MailJob {
|
||||
@@ -66,17 +84,65 @@ export class MailJob {
|
||||
return Math.min(30 * 1000, Math.round(elapsed / 2000) * 1000);
|
||||
}
|
||||
|
||||
private getRetryExhaustedReason({
|
||||
startTime,
|
||||
retryCount,
|
||||
expiresAt,
|
||||
name,
|
||||
}: Jobs['notification.sendMail']) {
|
||||
const expiredAt =
|
||||
expiresAt ?? startTime + (mailExpiresIn[name] ?? retryMaxAge);
|
||||
if (Date.now() > expiredAt) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
if ((retryCount ?? 0) > retryMaxAttempts) {
|
||||
return 'max attempts reached';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private async shouldSkipRecipient(to: string) {
|
||||
const user = await this.models.user.getUserByEmail(to, {
|
||||
withDisabled: true,
|
||||
});
|
||||
|
||||
return user?.disabled === true;
|
||||
}
|
||||
|
||||
private async deleteRecipientMailCache(to: string) {
|
||||
const suffix = `:${to}`;
|
||||
|
||||
await Promise.all(
|
||||
[sendMailKey, retryMailKey].map(async map => {
|
||||
const keys = await this.cache.mapKeys(map);
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter(key => key.endsWith(suffix))
|
||||
.map(key => this.cache.mapDelete(map, key))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async sendMailInternal({
|
||||
startTime,
|
||||
name,
|
||||
to,
|
||||
props,
|
||||
}: Jobs['notification.sendMail']) {
|
||||
let options: Partial<SendOptions> = {};
|
||||
if (await this.shouldSkipRecipient(to)) {
|
||||
this.logger.debug(`Skip mail [${name}] to disabled user [${to}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key in props) {
|
||||
let options: Partial<SendOptions> = {};
|
||||
const renderedProps = { ...props };
|
||||
|
||||
for (const key in renderedProps) {
|
||||
// @ts-expect-error allow
|
||||
const val = props[key];
|
||||
const val = renderedProps[key];
|
||||
if (val && typeof val === 'object') {
|
||||
if ('$$workspaceId' in val) {
|
||||
const workspaceProps = await this.fetchWorkspaceProps(
|
||||
@@ -87,6 +153,16 @@ export class MailJob {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'MemberInvitation' &&
|
||||
containsUrlOrDomain(workspaceProps.name)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Skip mail [${name}] to [${to}], reason=workspace name contains url or domain`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceProps.avatar) {
|
||||
options.attachments = [
|
||||
{
|
||||
@@ -99,7 +175,7 @@ export class MailJob {
|
||||
workspaceProps.avatar = 'cid:workspaceAvatar';
|
||||
}
|
||||
// @ts-expect-error replacement
|
||||
props[key] = workspaceProps;
|
||||
renderedProps[key] = workspaceProps;
|
||||
} else if ('$$userId' in val) {
|
||||
const userProps = await this.fetchUserProps(val.$$userId);
|
||||
|
||||
@@ -108,17 +184,30 @@ export class MailJob {
|
||||
}
|
||||
|
||||
// @ts-expect-error replacement
|
||||
props[key] = userProps;
|
||||
renderedProps[key] = userProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'MemberInvitation' &&
|
||||
'workspace' in renderedProps &&
|
||||
containsUrlOrDomain(
|
||||
(renderedProps.workspace as WorkspaceProps | undefined)?.name
|
||||
)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Skip mail [${name}] to [${to}], reason=workspace name contains url or domain`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.sender.send(name, {
|
||||
to,
|
||||
...(await Renderers[name](
|
||||
// @ts-expect-error the job trigger part has been typechecked
|
||||
props
|
||||
renderedProps
|
||||
)),
|
||||
...options,
|
||||
});
|
||||
@@ -130,7 +219,7 @@ export class MailJob {
|
||||
}
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to send mail [${name}] to [${to}]`, e);
|
||||
this.logger.error(`Failed to send mail [${name}] to [${to}]`, e, props);
|
||||
// wait for a while before retrying
|
||||
const retryDelay = this.calculateRetryDelay(startTime);
|
||||
await sleep(retryDelay);
|
||||
@@ -177,17 +266,41 @@ export class MailJob {
|
||||
@OnJob('notification.sendMail')
|
||||
async sendMail(job: Jobs['notification.sendMail']) {
|
||||
const cacheKey = sendMailCacheKey(job.name, job.to);
|
||||
job.retryCount = (job.retryCount ?? 0) + 1;
|
||||
const exhaustedReason = this.getRetryExhaustedReason(job);
|
||||
if (exhaustedReason) {
|
||||
this.logger.warn(
|
||||
`Drop mail [${job.name}] to [${job.to}], reason=${exhaustedReason}`
|
||||
);
|
||||
await Promise.all([
|
||||
this.cache.mapDelete(sendMailKey, cacheKey),
|
||||
this.cache.mapDelete(retryMailKey, cacheKey),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const retried = await this.cache.mapIncrease(sendMailKey, cacheKey, 1);
|
||||
if (retried <= retryFirstTime) {
|
||||
const ret = await this.sendMailInternal(job);
|
||||
if (!ret) await this.cache.mapDelete(sendMailKey, cacheKey);
|
||||
return ret;
|
||||
}
|
||||
await this.cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job));
|
||||
await this.cache.mapSet(retryMailKey, cacheKey, job);
|
||||
await this.cache.mapDelete(sendMailKey, cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: Events['user.deleted']) {
|
||||
await Promise.all([
|
||||
this.deleteRecipientMailCache(user.email),
|
||||
this.queue.removeWhere(
|
||||
'notification.sendMail',
|
||||
job => job.to === user.email
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async sendRetryMails() {
|
||||
// pick random one from the retry map
|
||||
@@ -195,9 +308,14 @@ export class MailJob {
|
||||
let key = await this.cache.mapRandomKey(retryMailKey);
|
||||
while (key && processed < retryMaxPerTick) {
|
||||
try {
|
||||
const job = await this.cache.mapGet<string>(retryMailKey, key);
|
||||
const job = await this.cache.mapGet<
|
||||
Jobs['notification.sendMail'] | string
|
||||
>(retryMailKey, key);
|
||||
if (job) {
|
||||
const jobData = JSON.parse(job) as Jobs['notification.sendMail'];
|
||||
const jobData =
|
||||
typeof job === 'string'
|
||||
? (JSON.parse(job) as Jobs['notification.sendMail'])
|
||||
: job;
|
||||
await this.queue.add('notification.sendMail', jobData);
|
||||
// wait for a while before retrying
|
||||
const retryDelay = this.calculateRetryDelay(jobData.startTime);
|
||||
|
||||
@@ -140,7 +140,11 @@ export class MailSender {
|
||||
return true;
|
||||
} catch (e) {
|
||||
metrics.mail.counter('failed_total').add(1, { name });
|
||||
this.logger.error(`Failed to send mail [${name}].`, e);
|
||||
this.logger.error(`Failed to send mail [${name}].`, e, {
|
||||
subject: options.subject,
|
||||
from: options.from,
|
||||
to: options.to,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { isIP } from 'node:net';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
|
||||
const hostnameLabelRegexp = /^[A-Za-z0-9-]+$/;
|
||||
|
||||
function isValidSMTPAddressLiteral(hostname: string) {
|
||||
if (!hostname.startsWith('[') || !hostname.endsWith(']')) return false;
|
||||
|
||||
const literal = hostname.slice(1, -1);
|
||||
if (!literal || literal.includes(' ')) return false;
|
||||
if (isIP(literal) === 4) return true;
|
||||
|
||||
if (literal.startsWith('IPv6:')) {
|
||||
return isIP(literal.slice('IPv6:'.length)) === 6;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function normalizeSMTPHeloHostname(hostname?: string) {
|
||||
if (!hostname) return undefined;
|
||||
|
||||
const normalized = hostname.trim().replace(/\.$/, '');
|
||||
if (!normalized) return undefined;
|
||||
if (isValidSMTPAddressLiteral(normalized)) return normalized;
|
||||
if (normalized.length > 253) return undefined;
|
||||
|
||||
const labels = normalized.split('.');
|
||||
for (const label of labels) {
|
||||
if (!label || label.length > 63) return undefined;
|
||||
if (
|
||||
!hostnameLabelRegexp.test(label) ||
|
||||
label.startsWith('-') ||
|
||||
label.endsWith('-')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readSystemHostname() {
|
||||
try {
|
||||
return getHostname();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSMTPHeloHostname(configuredName: string) {
|
||||
const normalizedConfiguredName = normalizeSMTPHeloHostname(configuredName);
|
||||
if (normalizedConfiguredName) return normalizedConfiguredName;
|
||||
return normalizeSMTPHeloHostname(readSystemHostname());
|
||||
}
|
||||
@@ -87,6 +87,29 @@ test('should create invitation notification and email', async t => {
|
||||
t.is(invitationMail.payload.name, 'MemberInvitation');
|
||||
});
|
||||
|
||||
test('should not send invitation email when workspace name contains domain', async t => {
|
||||
const spamWorkspace = await module.create(Mockers.Workspace, {
|
||||
owner: {
|
||||
id: owner.id,
|
||||
},
|
||||
name: 'BTC https://spam.example',
|
||||
});
|
||||
const inviteId = randomUUID();
|
||||
const invitationMailCount = module.queue.count('notification.sendMail');
|
||||
|
||||
const notification = await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
workspaceId: spamWorkspace.id,
|
||||
createdByUserId: owner.id,
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(module.queue.count('notification.sendMail'), invitationMailCount);
|
||||
});
|
||||
|
||||
test('should not send invitation email if user setting is not to receive invitation email', async t => {
|
||||
const inviteId = randomUUID();
|
||||
await module.create(Mockers.UserSettings, {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
generateWorkspaceSettingsPath,
|
||||
WorkspaceSettingsTab,
|
||||
} from '../utils/workspace';
|
||||
import { containsUrlOrDomain } from '../workspaces/abuse';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
@@ -151,6 +152,16 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
private async sendInvitationEmail(input: InvitationNotificationCreate) {
|
||||
const workspace = await this.docReader.getWorkspaceContent(
|
||||
input.body.workspaceId
|
||||
);
|
||||
if (containsUrlOrDomain(workspace?.name)) {
|
||||
this.logger.warn(
|
||||
`Skip invitation email for workspace ${input.body.workspaceId}, reason=workspace name contains url or domain`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inviteUrl = this.url.link(`/invite/${input.body.inviteId}`);
|
||||
if (env.dev) {
|
||||
// make it easier to test in dev mode
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { DocRole, Models, User, Workspace } from '../../../models';
|
||||
import { EventsListener } from '../event';
|
||||
import { PermissionModule } from '../index';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
models: Models;
|
||||
listener: EventsListener;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
let owner: User;
|
||||
let workspace: Workspace;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [PermissionModule] });
|
||||
t.context.module = module;
|
||||
t.context.models = module.get(Models);
|
||||
t.context.listener = module.get(EventsListener);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
workspace = await t.context.models.workspace.create(owner.id);
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should ignore default owner event when workspace does not exist', async t => {
|
||||
await t.notThrowsAsync(async () => {
|
||||
await t.context.listener.setDefaultPageOwner({
|
||||
workspaceId: randomUUID(),
|
||||
docId: randomUUID(),
|
||||
editor: owner.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should ignore default owner event when editor does not exist', async t => {
|
||||
await t.notThrowsAsync(async () => {
|
||||
await t.context.listener.setDefaultPageOwner({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
editor: randomUUID(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should set owner when workspace and editor exist', async t => {
|
||||
const docId = randomUUID();
|
||||
await t.context.listener.setDefaultPageOwner({
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
editor: owner.id,
|
||||
});
|
||||
|
||||
const role = await t.context.models.docUser.get(
|
||||
workspace.id,
|
||||
docId,
|
||||
owner.id
|
||||
);
|
||||
t.is(role?.type, DocRole.Owner);
|
||||
});
|
||||
@@ -1,10 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { OnEvent } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
|
||||
const IGNORED_PRISMA_CODES = new Set(['P2003', 'P2025', 'P2028']);
|
||||
|
||||
function isIgnorablePermissionEventError(error: unknown) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return IGNORED_PRISMA_CODES.has(error.code);
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
return /transaction is aborted|transaction already closed/i.test(
|
||||
error.message
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EventsListener {
|
||||
private readonly logger = new Logger(EventsListener.name);
|
||||
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
@OnEvent('doc.created')
|
||||
@@ -15,6 +32,33 @@ export class EventsListener {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.models.docUser.setOwner(workspaceId, docId, editor);
|
||||
const workspace = await this.models.workspace.get(workspaceId);
|
||||
if (!workspace) {
|
||||
this.logger.warn(
|
||||
`Skip default doc owner event for missing workspace ${workspaceId}/${docId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.models.user.get(editor);
|
||||
if (!user) {
|
||||
this.logger.warn(
|
||||
`Skip default doc owner event for missing editor ${workspaceId}/${docId}/${editor}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.models.docUser.setOwner(workspaceId, docId, editor);
|
||||
} catch (error) {
|
||||
if (isIgnorablePermissionEventError(error)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
`Ignore stale doc owner event for ${workspaceId}/${docId}/${editor}: ${message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const SHARE_ACTION_ACCOUNT_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const URL_OR_DOMAIN_PATTERN =
|
||||
/(?:https?:\/\/|www\.|(?<![@\w-])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}(?:[/?#:]|$))/i;
|
||||
|
||||
export function containsUrlOrDomain(value: string | null | undefined) {
|
||||
return URL_OR_DOMAIN_PATTERN.test(value ?? '');
|
||||
}
|
||||
|
||||
export function isUserOldEnoughForShareActions(user: { createdAt: Date }) {
|
||||
return Date.now() - user.createdAt.getTime() >= SHARE_ACTION_ACCOUNT_AGE_MS;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
Cache,
|
||||
DocActionDenied,
|
||||
DocDefaultRoleCanNotBeOwner,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
DocRole,
|
||||
} from '../../permission';
|
||||
import { PublicUserType, WorkspaceUserType } from '../../user';
|
||||
import { isUserOldEnoughForShareActions } from '../abuse';
|
||||
import { WorkspaceType } from '../types';
|
||||
import { TimeBucket, TimeWindow } from './analytics-types';
|
||||
import {
|
||||
@@ -299,6 +301,15 @@ export class WorkspaceDocResolver {
|
||||
private readonly cache: Cache
|
||||
) {}
|
||||
|
||||
private async assertCanShare(userId: string) {
|
||||
const user = await this.models.user.get(userId);
|
||||
if (!user || !isUserOldEnoughForShareActions(user)) {
|
||||
throw new ActionForbidden(
|
||||
'Sharing links is unavailable during the first 24 hours after signup.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => WorkspaceDocMeta, {
|
||||
description: 'Cloud page metadata of workspace',
|
||||
complexity: 2,
|
||||
@@ -413,6 +424,7 @@ export class WorkspaceDocResolver {
|
||||
}
|
||||
|
||||
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||
await this.assertCanShare(user.id);
|
||||
|
||||
const doc = await this.models.doc.publish(workspaceId, docId, mode);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
ActionForbiddenOnNonTeamWorkspace,
|
||||
AlreadyInSpace,
|
||||
AuthenticationRequired,
|
||||
@@ -40,6 +41,7 @@ import { AccessController, WorkspaceRole } from '../../permission';
|
||||
import { QuotaService } from '../../quota';
|
||||
import { UserType } from '../../user';
|
||||
import { validators } from '../../utils/validators';
|
||||
import { containsUrlOrDomain, isUserOldEnoughForShareActions } from '../abuse';
|
||||
import { WorkspaceService } from '../service';
|
||||
import {
|
||||
InvitationType,
|
||||
@@ -68,6 +70,24 @@ export class WorkspaceMemberResolver {
|
||||
private readonly quota: QuotaService
|
||||
) {}
|
||||
|
||||
private async assertCanInviteOrShare(userId: string) {
|
||||
const user = await this.models.user.get(userId);
|
||||
if (!user || !isUserOldEnoughForShareActions(user)) {
|
||||
throw new ActionForbidden(
|
||||
'Inviting members and creating share links are unavailable during the first 24 hours after signup.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWorkspaceNameCanInvite(workspaceId: string) {
|
||||
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
|
||||
if (containsUrlOrDomain(workspace.name)) {
|
||||
throw new ActionForbidden(
|
||||
'Workspace names containing links or domains cannot be used to invite members.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => UserType, {
|
||||
description: 'Owner of workspace',
|
||||
complexity: 2,
|
||||
@@ -141,6 +161,8 @@ export class WorkspaceMemberResolver {
|
||||
.user(me.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
await this.assertCanInviteOrShare(me.id);
|
||||
await this.assertWorkspaceNameCanInvite(workspaceId);
|
||||
|
||||
if (emails.length > 512) {
|
||||
throw new TooManyRequest();
|
||||
@@ -272,6 +294,8 @@ export class WorkspaceMemberResolver {
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
await this.assertCanInviteOrShare(user.id);
|
||||
await this.assertWorkspaceNameCanInvite(workspaceId);
|
||||
|
||||
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
|
||||
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
|
||||
|
||||
@@ -83,95 +83,70 @@ export const Renderers = {
|
||||
//#region Workspace
|
||||
MemberInvitation: make(
|
||||
Invitation,
|
||||
props => `${props.user.email} invited you to join ${props.workspace.name}`
|
||||
'You were invited to join a workspace on AFFiNE'
|
||||
),
|
||||
MemberAccepted: make(
|
||||
InvitationAccepted,
|
||||
props => `${props.user.email} accepted your invitation`
|
||||
),
|
||||
MemberLeave: make(
|
||||
MemberLeave,
|
||||
props => `${props.user.email} left ${props.workspace.name}`
|
||||
'Your workspace invitation was accepted'
|
||||
),
|
||||
MemberLeave: make(MemberLeave, 'A workspace member left'),
|
||||
LinkInvitationReviewRequest: make(
|
||||
LinkInvitationReviewRequest,
|
||||
props => `New request to join ${props.workspace.name}`
|
||||
'New request to join a workspace'
|
||||
),
|
||||
LinkInvitationApprove: make(
|
||||
LinkInvitationApproved,
|
||||
props => `Your request to join ${props.workspace.name} has been approved`
|
||||
'Your request to join a workspace has been approved'
|
||||
),
|
||||
LinkInvitationDecline: make(
|
||||
LinkInvitationReviewDeclined,
|
||||
props => `Your request to join ${props.workspace.name} was declined`
|
||||
),
|
||||
MemberRemoved: make(
|
||||
MemberRemoved,
|
||||
props => `You have been removed from ${props.workspace.name}`
|
||||
'Your request to join a workspace was declined'
|
||||
),
|
||||
MemberRemoved: make(MemberRemoved, 'You have been removed from a workspace'),
|
||||
OwnershipTransferred: make(
|
||||
OwnershipTransferred,
|
||||
props => `Your ownership of ${props.workspace.name} has been transferred`
|
||||
'Your workspace ownership has been transferred'
|
||||
),
|
||||
OwnershipReceived: make(
|
||||
OwnershipReceived,
|
||||
props => `You are now the owner of ${props.workspace.name}`
|
||||
'You are now the owner of a workspace'
|
||||
),
|
||||
//#endregion
|
||||
|
||||
//#region Doc
|
||||
Mention: make(
|
||||
Mention,
|
||||
props => `${props.user.email} mentioned you in ${props.doc.title}`
|
||||
),
|
||||
Comment: make(
|
||||
Comment,
|
||||
props => `${props.user.email} commented on ${props.doc.title}`
|
||||
),
|
||||
CommentMention: make(
|
||||
CommentMention,
|
||||
props =>
|
||||
`${props.user.email} mentioned you in a comment on ${props.doc.title}`
|
||||
),
|
||||
Mention: make(Mention, 'You were mentioned in AFFiNE'),
|
||||
Comment: make(Comment, 'New comment in AFFiNE'),
|
||||
CommentMention: make(CommentMention, 'You were mentioned in a comment'),
|
||||
//#endregion
|
||||
|
||||
//#region Team
|
||||
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
|
||||
props.isOwner
|
||||
? 'Your workspace has been upgraded to team workspace! 🎉'
|
||||
: `${props.workspace.name} has been upgraded to team workspace! 🎉`
|
||||
),
|
||||
TeamBecomeAdmin: make(
|
||||
TeamBecomeAdmin,
|
||||
props => `You are now an admin of ${props.workspace.name}`
|
||||
: 'A workspace has been upgraded to team workspace! 🎉'
|
||||
),
|
||||
TeamBecomeAdmin: make(TeamBecomeAdmin, 'You are now a workspace admin'),
|
||||
TeamBecomeCollaborator: make(
|
||||
TeamBecomeCollaborator,
|
||||
props => `Your role has been changed in ${props.workspace.name}`
|
||||
'Your workspace role has been changed'
|
||||
),
|
||||
TeamDeleteIn24Hours: make(
|
||||
TeamDeleteIn24Hours,
|
||||
props =>
|
||||
`[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours`
|
||||
'[Action Required] Final warning: Your workspace will be deleted in 24 hours'
|
||||
),
|
||||
TeamDeleteInOneMonth: make(
|
||||
TeamDeleteInOneMonth,
|
||||
props =>
|
||||
`[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon`
|
||||
'[Action Required] Important: Your workspace will be deleted soon'
|
||||
),
|
||||
TeamWorkspaceDeleted: make(
|
||||
TeamWorkspaceDeleted,
|
||||
props => `Your workspace ${props.workspace.name} has been deleted`
|
||||
'Your workspace has been deleted'
|
||||
),
|
||||
TeamWorkspaceExpireSoon: make(
|
||||
TeamExpireSoon,
|
||||
props =>
|
||||
`[Action Required] Your ${props.workspace.name} team workspace will expire soon`
|
||||
),
|
||||
TeamWorkspaceExpired: make(
|
||||
TeamExpired,
|
||||
props => `Your ${props.workspace.name} team workspace has expired`
|
||||
'[Action Required] Your team workspace will expire soon'
|
||||
),
|
||||
TeamWorkspaceExpired: make(TeamExpired, 'Your team workspace has expired'),
|
||||
//#endregion
|
||||
|
||||
//#region License
|
||||
|
||||
@@ -92,7 +92,7 @@ export class CalendarAccountModel extends BaseModel {
|
||||
scope: input.scope ?? null,
|
||||
status: input.status ?? 'active',
|
||||
lastError: input.lastError ?? null,
|
||||
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 60,
|
||||
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 30,
|
||||
};
|
||||
|
||||
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface UpsertCalendarSubscriptionInput {
|
||||
export interface UpdateCalendarSubscriptionSyncInput {
|
||||
syncToken?: string | null;
|
||||
lastSyncAt?: Date | null;
|
||||
nextSyncAt?: Date;
|
||||
syncRetryCount?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCalendarSubscriptionChannelInput {
|
||||
@@ -81,13 +83,21 @@ export class CalendarSubscriptionModel extends BaseModel {
|
||||
}
|
||||
|
||||
async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) {
|
||||
return await this.db.calendarSubscription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
syncToken: input.syncToken ?? null,
|
||||
lastSyncAt: input.lastSyncAt ?? null,
|
||||
},
|
||||
});
|
||||
const data: Prisma.CalendarSubscriptionUncheckedUpdateInput = {};
|
||||
if (input.syncToken !== undefined) {
|
||||
data.syncToken = input.syncToken ?? null;
|
||||
}
|
||||
if (input.lastSyncAt !== undefined) {
|
||||
data.lastSyncAt = input.lastSyncAt ?? null;
|
||||
}
|
||||
if (input.nextSyncAt !== undefined) {
|
||||
data.nextSyncAt = input.nextSyncAt;
|
||||
}
|
||||
if (input.syncRetryCount !== undefined) {
|
||||
data.syncRetryCount = input.syncRetryCount;
|
||||
}
|
||||
|
||||
return await this.db.calendarSubscription.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async updateChannel(
|
||||
@@ -155,10 +165,16 @@ export class CalendarSubscriptionModel extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
async listAllWithAccountForSync() {
|
||||
async listDueForSync(now: Date, limit: number) {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: { enabled: true },
|
||||
include: { account: true },
|
||||
where: {
|
||||
enabled: true,
|
||||
nextSyncAt: { lte: now },
|
||||
account: { status: 'active' },
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { nextSyncAt: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,13 +185,6 @@ export class CalendarSubscriptionModel extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
async updateLastSyncAt(id: string, lastSyncAt: Date) {
|
||||
return await this.db.calendarSubscription.update({
|
||||
where: { id },
|
||||
data: { lastSyncAt },
|
||||
});
|
||||
}
|
||||
|
||||
async clearSyncTokensByAccount(accountId: string) {
|
||||
return await this.db.calendarSubscription.updateMany({
|
||||
where: { accountId },
|
||||
@@ -200,6 +209,7 @@ export class CalendarSubscriptionModel extends BaseModel {
|
||||
data: {
|
||||
enabled: false,
|
||||
syncToken: null,
|
||||
syncRetryCount: 0,
|
||||
customChannelId: null,
|
||||
customResourceId: null,
|
||||
channelExpiration: null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
import type { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
|
||||
import { WorkspaceDocUserRole } from '@prisma/client';
|
||||
|
||||
import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
|
||||
@@ -14,31 +15,20 @@ export class DocUserModel extends BaseModel {
|
||||
* Set or update the [Owner] of a doc.
|
||||
* The old [Owner] will be changed to [Manager] if there is already an [Owner].
|
||||
*/
|
||||
@Transactional()
|
||||
@Transactional<TransactionalAdapterPrisma>({ timeout: 15000 })
|
||||
async setOwner(workspaceId: string, docId: string, userId: string) {
|
||||
const oldOwner = await this.db.workspaceDocUserRole.findFirst({
|
||||
await this.db.workspaceDocUserRole.updateMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId,
|
||||
type: DocRole.Owner,
|
||||
userId: { not: userId },
|
||||
},
|
||||
data: {
|
||||
type: DocRole.Manager,
|
||||
},
|
||||
});
|
||||
|
||||
if (oldOwner) {
|
||||
await this.db.workspaceDocUserRole.update({
|
||||
where: {
|
||||
workspaceId_docId_userId: {
|
||||
workspaceId,
|
||||
docId,
|
||||
userId: oldOwner.userId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
type: DocRole.Manager,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.db.workspaceDocUserRole.upsert({
|
||||
where: {
|
||||
workspaceId_docId_userId: {
|
||||
@@ -57,16 +47,9 @@ export class DocUserModel extends BaseModel {
|
||||
type: DocRole.Owner,
|
||||
},
|
||||
});
|
||||
|
||||
if (oldOwner) {
|
||||
this.logger.log(
|
||||
`Transfer doc owner of [${workspaceId}/${docId}] from [${oldOwner.userId}] to [${userId}]`
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Set doc owner of [${workspaceId}/${docId}] to [${userId}]`
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Set doc owner of [${workspaceId}/${docId}] to [${userId}]`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '../../../models';
|
||||
import { Models } from '../../../models';
|
||||
import { CalendarModule } from '../index';
|
||||
import { CalendarCronJobs } from '../cron';
|
||||
import {
|
||||
CalendarProvider,
|
||||
CalendarProviderFactory,
|
||||
@@ -85,6 +86,7 @@ const module = await createModule({
|
||||
],
|
||||
});
|
||||
const calendarService = module.get(CalendarService);
|
||||
const calendarCronJobs = module.get(CalendarCronJobs);
|
||||
const providerFactory = module.get(CalendarProviderFactory);
|
||||
const models = module.get(Models);
|
||||
module.get(CryptoHelper).onConfigInit();
|
||||
@@ -113,6 +115,8 @@ const createSubscription = async (
|
||||
accountId: string,
|
||||
overrides: Partial<UpsertCalendarSubscriptionInput> & {
|
||||
syncToken?: string | null;
|
||||
nextSyncAt?: Date;
|
||||
syncRetryCount?: number;
|
||||
customChannelId?: string | null;
|
||||
customResourceId?: string | null;
|
||||
channelExpiration?: Date | null;
|
||||
@@ -134,6 +138,20 @@ const createSubscription = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
overrides.nextSyncAt !== undefined ||
|
||||
overrides.syncRetryCount !== undefined
|
||||
) {
|
||||
await models.calendarSubscription.updateSync(subscription.id, {
|
||||
...(overrides.nextSyncAt !== undefined
|
||||
? { nextSyncAt: overrides.nextSyncAt }
|
||||
: {}),
|
||||
...(overrides.syncRetryCount !== undefined
|
||||
? { syncRetryCount: overrides.syncRetryCount }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
overrides.customChannelId !== undefined ||
|
||||
overrides.customResourceId !== undefined ||
|
||||
@@ -151,6 +169,8 @@ const createSubscription = async (
|
||||
|
||||
test.afterEach.always(() => {
|
||||
mock.reset();
|
||||
module.queue.add.resetHistory();
|
||||
module.queue.remove.resetHistory();
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
@@ -252,6 +272,9 @@ test('syncSubscription resets invalid sync token and maps events', async t => {
|
||||
const updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(updated?.syncToken, 'next-token');
|
||||
t.truthy(updated?.lastSyncAt);
|
||||
t.is(updated?.syncRetryCount, 0);
|
||||
t.truthy(updated?.nextSyncAt);
|
||||
t.true(updated!.nextSyncAt.getTime() > updated!.lastSyncAt!.getTime());
|
||||
|
||||
const events = await models.calendarEvent.listBySubscriptionsInRange(
|
||||
[subscription.id],
|
||||
@@ -493,51 +516,22 @@ test('syncSubscription applies exponential backoff for repeated failures', async
|
||||
mock.method(Date, 'now', () => now);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
let updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(listEventsMock.mock.callCount(), 1);
|
||||
t.is(updated?.syncRetryCount, 1);
|
||||
t.is(
|
||||
updated?.nextSyncAt.toISOString(),
|
||||
new Date(now + baseDelayMs).toISOString()
|
||||
);
|
||||
|
||||
now += baseDelayMs + 1000;
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(listEventsMock.mock.callCount(), 2);
|
||||
|
||||
now += baseDelayMs + 1000;
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
t.is(listEventsMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('syncSubscription skips token refresh while in backoff window', async t => {
|
||||
let now = new Date('2026-01-01T00:00:00.000Z').getTime();
|
||||
mock.method(Date, 'now', () => now);
|
||||
|
||||
const user = await module.create(Mockers.User);
|
||||
const account = await createAccount(user.id, {
|
||||
accessToken: 'expired-access-token',
|
||||
expiresAt: new Date(now - 5 * 60 * 1000),
|
||||
});
|
||||
const subscription = await createSubscription(account.id, {
|
||||
syncToken: 'sync-token',
|
||||
});
|
||||
|
||||
const provider = new MockCalendarProvider();
|
||||
const refreshMock = mock.method(provider, 'refreshTokens', async () => ({
|
||||
accessToken: `refreshed-${randomUUID()}`,
|
||||
}));
|
||||
const listEventsMock = mock.method(provider, 'listEvents', async () => {
|
||||
throw new Error('upstream timeout');
|
||||
});
|
||||
mock.method(providerFactory, 'get', () => provider);
|
||||
|
||||
const baseDelayMs = 5 * 60 * 1000;
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
t.is(refreshMock.mock.callCount(), 1);
|
||||
t.is(listEventsMock.mock.callCount(), 1);
|
||||
|
||||
now += baseDelayMs + 1000;
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
t.is(refreshMock.mock.callCount(), 2);
|
||||
t.is(listEventsMock.mock.callCount(), 2);
|
||||
t.is(updated?.syncRetryCount, 2);
|
||||
t.is(
|
||||
updated?.nextSyncAt.toISOString(),
|
||||
new Date(now + baseDelayMs * 2).toISOString()
|
||||
);
|
||||
});
|
||||
|
||||
test('syncSubscription renews webhook channel when expiring', async t => {
|
||||
@@ -599,3 +593,73 @@ test('syncSubscription renews webhook channel when expiring', async t => {
|
||||
t.is(updated?.customResourceId, 'new-resource');
|
||||
t.truthy(updated?.channelExpiration);
|
||||
});
|
||||
|
||||
test('syncSubscription keeps schedule moving when webhook renewal fails', async t => {
|
||||
const now = new Date('2026-01-01T00:00:00.000Z').getTime();
|
||||
mock.method(Date, 'now', () => now);
|
||||
|
||||
const user = await module.create(Mockers.User);
|
||||
const account = await createAccount(user.id, {
|
||||
refreshIntervalMinutes: 60,
|
||||
});
|
||||
const subscription = await createSubscription(account.id, {
|
||||
syncToken: 'sync-token',
|
||||
channelExpiration: new Date(Date.now() + 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const provider = new MockCalendarProvider();
|
||||
mock.method(provider, 'listEvents', async () => ({
|
||||
events: [],
|
||||
nextSyncToken: 'next-sync',
|
||||
}));
|
||||
mock.method(provider, 'watchCalendar', async () => {
|
||||
throw new Error('watch failed');
|
||||
});
|
||||
mock.method(providerFactory, 'get', () => provider);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
|
||||
const updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.truthy(updated?.lastSyncAt);
|
||||
t.is(updated?.syncRetryCount, 0);
|
||||
t.is(
|
||||
updated?.nextSyncAt.toISOString(),
|
||||
new Date(now + 15 * 60 * 1000).toISOString()
|
||||
);
|
||||
});
|
||||
|
||||
test('pollAccounts skips when nothing is due', async t => {
|
||||
mock.method(models.calendarSubscription, 'listDueForSync', async () => []);
|
||||
|
||||
await calendarCronJobs.pollAccounts();
|
||||
|
||||
t.is(module.queue.count('calendar.syncSubscription'), 0);
|
||||
});
|
||||
|
||||
test('pollAccounts enqueues due subscriptions only', async t => {
|
||||
mock.method(models.calendarSubscription, 'listDueForSync', async () => [
|
||||
{ id: 'due-subscription-a' },
|
||||
{ id: 'due-subscription-b' },
|
||||
]);
|
||||
|
||||
await calendarCronJobs.pollAccounts();
|
||||
|
||||
t.is(module.queue.count('calendar.syncSubscription'), 2);
|
||||
t.deepEqual(
|
||||
module.queue.add
|
||||
.getCalls()
|
||||
.map(call => [call.args[0], call.args[1], call.args[2]]),
|
||||
[
|
||||
[
|
||||
'calendar.syncSubscription',
|
||||
{ subscriptionId: 'due-subscription-a', reason: 'polling' },
|
||||
{ jobId: 'due-subscription-a' },
|
||||
],
|
||||
[
|
||||
'calendar.syncSubscription',
|
||||
{ subscriptionId: 'due-subscription-b', reason: 'polling' },
|
||||
{ jobId: 'due-subscription-b' },
|
||||
],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface CalendarGoogleConfig {
|
||||
clientSecret: string;
|
||||
externalWebhookUrl?: string;
|
||||
webhookVerificationToken?: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export type CalendarCalDAVAuthType = 'auto' | 'basic' | 'digest';
|
||||
@@ -49,6 +50,7 @@ const schema: JSONSchema = {
|
||||
clientSecret: { type: 'string' },
|
||||
externalWebhookUrl: { type: 'string' },
|
||||
webhookVerificationToken: { type: 'string' },
|
||||
requestTimeoutMs: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -88,6 +90,7 @@ defineModuleConfig('calendar', {
|
||||
clientSecret: '',
|
||||
externalWebhookUrl: '',
|
||||
webhookVerificationToken: '',
|
||||
requestTimeoutMs: 10_000,
|
||||
},
|
||||
schema,
|
||||
shape: z.object({
|
||||
@@ -101,6 +104,7 @@ defineModuleConfig('calendar', {
|
||||
.or(z.string().length(0))
|
||||
.optional(),
|
||||
webhookVerificationToken: z.string().optional(),
|
||||
requestTimeoutMs: z.number().int().positive().optional(),
|
||||
}),
|
||||
link: 'https://developers.google.com/calendar/api/guides/push',
|
||||
},
|
||||
|
||||
@@ -1,61 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { JobQueue } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CalendarService } from './service';
|
||||
|
||||
const CALENDAR_POLL_BATCH_SIZE = 200;
|
||||
|
||||
@Injectable()
|
||||
export class CalendarCronJobs {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly calendar: CalendarService
|
||||
private readonly queue: JobQueue
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async pollAccounts() {
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listAllWithAccountForSync();
|
||||
const subscriptions = await this.models.calendarSubscription.listDueForSync(
|
||||
new Date(),
|
||||
CALENDAR_POLL_BATCH_SIZE
|
||||
);
|
||||
|
||||
const accountDueAt = new Map<
|
||||
string,
|
||||
{ refreshInterval: number; lastSyncAt: Date | null }
|
||||
>();
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const interval = subscription.account.refreshIntervalMinutes ?? 60;
|
||||
const lastSyncAt = subscription.lastSyncAt ?? null;
|
||||
const existing = accountDueAt.get(subscription.accountId);
|
||||
if (!existing) {
|
||||
accountDueAt.set(subscription.accountId, {
|
||||
refreshInterval: interval,
|
||||
lastSyncAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const earliest =
|
||||
existing.lastSyncAt && lastSyncAt
|
||||
? existing.lastSyncAt < lastSyncAt
|
||||
? existing.lastSyncAt
|
||||
: lastSyncAt
|
||||
: (existing.lastSyncAt ?? lastSyncAt);
|
||||
accountDueAt.set(subscription.accountId, {
|
||||
refreshInterval: interval,
|
||||
lastSyncAt: earliest,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await Promise.allSettled(
|
||||
Array.from(accountDueAt.entries()).map(([accountId, info]) => {
|
||||
if (
|
||||
!info.lastSyncAt ||
|
||||
now - info.lastSyncAt.getTime() >= info.refreshInterval * 60 * 1000
|
||||
) {
|
||||
return this.calendar.syncAccount(accountId);
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
subscriptions.map(({ id }) =>
|
||||
this.queue.add(
|
||||
'calendar.syncSubscription',
|
||||
{ subscriptionId: id, reason: 'polling' },
|
||||
{ jobId: id }
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PermissionModule } from '../../core/permission';
|
||||
import { WorkspaceModule } from '../../core/workspaces';
|
||||
import { CalendarController } from './controller';
|
||||
import { CalendarCronJobs } from './cron';
|
||||
import { CalendarJob } from './job';
|
||||
import { CalendarOAuthService } from './oauth';
|
||||
import { CalendarProviderFactory, CalendarProviders } from './providers';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import { CalendarService } from './service';
|
||||
...CalendarProviders,
|
||||
CalendarProviderFactory,
|
||||
CalendarService,
|
||||
CalendarJob,
|
||||
CalendarOAuthService,
|
||||
CalendarCronJobs,
|
||||
CalendarServerConfigResolver,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { OnJob } from '../../base';
|
||||
import { CalendarService } from './service';
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'calendar.syncSubscription': {
|
||||
subscriptionId: string;
|
||||
reason?: 'polling' | 'webhook' | 'on-demand';
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarJob {
|
||||
constructor(private readonly calendar: CalendarService) {}
|
||||
|
||||
@OnJob('calendar.syncSubscription')
|
||||
async syncSubscription({
|
||||
subscriptionId,
|
||||
reason,
|
||||
}: Jobs['calendar.syncSubscription']) {
|
||||
await this.calendar.syncSubscription(subscriptionId, { reason });
|
||||
}
|
||||
}
|
||||
@@ -152,10 +152,28 @@ export abstract class CalendarProvider {
|
||||
}
|
||||
}
|
||||
|
||||
protected get requestTimeoutMs() {
|
||||
const timeout = (this.config as { requestTimeoutMs?: number } | undefined)
|
||||
?.requestTimeoutMs;
|
||||
return typeof timeout === 'number' && timeout > 0 ? timeout : undefined;
|
||||
}
|
||||
|
||||
protected withTimeout(signal?: AbortSignal | null) {
|
||||
const timeoutMs = this.requestTimeoutMs;
|
||||
if (!timeoutMs) return signal;
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
if (!signal) return timeoutSignal;
|
||||
|
||||
return AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
protected async fetchJson<T>(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json', ...init?.headers },
|
||||
...init,
|
||||
signal: this.withTimeout(init?.signal),
|
||||
headers: { ...init?.headers, Accept: 'application/json' },
|
||||
});
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -329,6 +329,7 @@ export class GoogleCalendarProvider extends CalendarProvider {
|
||||
|
||||
private async fetchWithTokenHandling<T>(url: string, accessToken: string) {
|
||||
const response = await fetch(url, {
|
||||
signal: this.withTimeout(),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -8,11 +8,11 @@ import { addDays, subDays } from 'date-fns';
|
||||
import {
|
||||
CalendarProviderRequestError,
|
||||
Config,
|
||||
exponentialBackoffDelay,
|
||||
GraphqlBadRequest,
|
||||
Mutex,
|
||||
JobQueue,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { SessionRedis } from '../../base/redis';
|
||||
import { Models } from '../../models';
|
||||
import type { CalendarCalDAVProviderPreset } from './config';
|
||||
import {
|
||||
@@ -28,10 +28,10 @@ import type { LinkCalDAVAccountInput } from './types';
|
||||
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
|
||||
const DEFAULT_PAST_DAYS = 90;
|
||||
const DEFAULT_FUTURE_DAYS = 180;
|
||||
const SYNC_FAILURE_BACKOFF_KEY_PREFIX = 'calendar:sync:backoff:';
|
||||
const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
|
||||
const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
|
||||
const SYNC_FAILURE_BACKOFF_TTL_SECONDS = 24 * 60 * 60;
|
||||
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
|
||||
const CHANNEL_RENEW_RETRY_MS = 15 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
@@ -41,8 +41,7 @@ export class CalendarService {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly providerFactory: CalendarProviderFactory<CalendarProvider>,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly redis: SessionRedis,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper
|
||||
) {}
|
||||
@@ -85,10 +84,24 @@ export class CalendarService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.models.calendarAccount.updateRefreshInterval(
|
||||
accountId,
|
||||
refreshIntervalMinutes
|
||||
const updatedAccount =
|
||||
await this.models.calendarAccount.updateRefreshInterval(
|
||||
accountId,
|
||||
refreshIntervalMinutes
|
||||
);
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listByAccountForSync(accountId);
|
||||
await Promise.all(
|
||||
subscriptions.map(subscription =>
|
||||
this.models.calendarSubscription.updateSync(subscription.id, {
|
||||
nextSyncAt: this.calculateNextSyncAt(
|
||||
subscription.lastSyncAt ?? this.now(),
|
||||
refreshIntervalMinutes
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
return updatedAccount;
|
||||
}
|
||||
|
||||
async unlinkAccount(userId: string, accountId: string) {
|
||||
@@ -313,25 +326,6 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const backoff = await this.getSyncFailureBackoff(subscription.id);
|
||||
if (backoff && now < backoff.nextRetryAt.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await using lock = await this.mutex.acquire(
|
||||
`calendar:subscription:${subscriptionId}`
|
||||
);
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lockedNow = Date.now();
|
||||
const lockedBackoff = await this.getSyncFailureBackoff(subscription.id);
|
||||
if (lockedBackoff && lockedNow < lockedBackoff.nextRetryAt.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = this.providerFactory.get(
|
||||
account.provider as CalendarProviderName
|
||||
);
|
||||
@@ -415,29 +409,28 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
if (synced) {
|
||||
await this.clearSyncFailureBackoff(subscription.id);
|
||||
await this.ensureWebhookChannel(subscription, provider, accessToken);
|
||||
const syncedAt = this.now();
|
||||
let nextSyncAt = this.calculateNextSyncAt(
|
||||
syncedAt,
|
||||
account.refreshIntervalMinutes
|
||||
);
|
||||
|
||||
try {
|
||||
await this.ensureWebhookChannel(subscription, provider, accessToken);
|
||||
} catch (error) {
|
||||
nextSyncAt = this.calculateChannelRetryAt(nextSyncAt);
|
||||
this.logger.warn(
|
||||
`Failed to ensure webhook channel for subscription ${subscription.id}`,
|
||||
this.toError(error)
|
||||
);
|
||||
}
|
||||
|
||||
await this.models.calendarSubscription.updateSync(subscription.id, {
|
||||
lastSyncAt: syncedAt,
|
||||
nextSyncAt,
|
||||
syncRetryCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
await this.models.calendarSubscription.updateLastSyncAt(
|
||||
subscription.id,
|
||||
new Date()
|
||||
);
|
||||
}
|
||||
|
||||
async syncAccount(accountId: string) {
|
||||
const account = await this.models.calendarAccount.get(accountId);
|
||||
if (!account || account.status !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listByAccountForSync(accountId);
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(subscription =>
|
||||
this.syncSubscription(subscription.id, { reason: 'polling' })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async listWorkspaceEvents(params: {
|
||||
@@ -455,9 +448,18 @@ export class CalendarService {
|
||||
params.to
|
||||
);
|
||||
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listWithAccounts(subscriptionIds);
|
||||
const staleSubscriptions = subscriptions.filter(
|
||||
subscription =>
|
||||
subscription.enabled &&
|
||||
subscription.account.status === 'active' &&
|
||||
subscription.nextSyncAt.getTime() <= this.nowMs()
|
||||
);
|
||||
|
||||
Promise.allSettled(
|
||||
subscriptionIds.map(subscriptionId =>
|
||||
this.syncSubscription(subscriptionId, { reason: 'on-demand' })
|
||||
staleSubscriptions.map(subscription =>
|
||||
this.enqueueSyncSubscription(subscription.id, 'on-demand')
|
||||
)
|
||||
).catch(error => {
|
||||
this.logger.warn('Calendar on-demand sync failed', error as Error);
|
||||
@@ -513,7 +515,7 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.syncSubscription(subscription.id, { reason: 'webhook' });
|
||||
await this.enqueueSyncSubscription(subscription.id, 'webhook');
|
||||
}
|
||||
|
||||
getWebhookToken() {
|
||||
@@ -747,7 +749,7 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
private getSyncWindow() {
|
||||
const now = new Date();
|
||||
const now = this.now();
|
||||
return {
|
||||
timeMin: subDays(now, DEFAULT_PAST_DAYS).toISOString(),
|
||||
timeMax: addDays(now, DEFAULT_FUTURE_DAYS).toISOString(),
|
||||
@@ -767,7 +769,7 @@ export class CalendarService {
|
||||
if (
|
||||
accessToken &&
|
||||
account.expiresAt &&
|
||||
account.expiresAt.getTime() > Date.now() + TOKEN_REFRESH_SKEW_MS
|
||||
account.expiresAt.getTime() > this.nowMs() + TOKEN_REFRESH_SKEW_MS
|
||||
) {
|
||||
return { accessToken };
|
||||
}
|
||||
@@ -831,7 +833,7 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
const renewThreshold = Date.now() + 24 * 60 * 60 * 1000;
|
||||
const renewThreshold = this.nowMs() + 24 * 60 * 60 * 1000;
|
||||
if (
|
||||
subscription.channelExpiration &&
|
||||
subscription.channelExpiration.getTime() > renewThreshold
|
||||
@@ -873,6 +875,7 @@ export class CalendarService {
|
||||
subscription: {
|
||||
id: string;
|
||||
externalCalendarId: string;
|
||||
syncRetryCount: number;
|
||||
customChannelId: string | null;
|
||||
customResourceId: string | null;
|
||||
};
|
||||
@@ -895,7 +898,6 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
if (this.isTokenInvalidError(params.error)) {
|
||||
await this.clearSyncFailureBackoff(params.subscription.id);
|
||||
await this.models.calendarAccount.invalidateAndPurge(
|
||||
params.account.id,
|
||||
this.formatSyncError(params.error)
|
||||
@@ -903,18 +905,14 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
const backoff = await this.bumpSyncFailureBackoff(params.subscription.id);
|
||||
const interval = params.account.refreshIntervalMinutes ?? 60;
|
||||
const lastSyncAt = this.calculateLastSyncAtForRetry(
|
||||
backoff.nextRetryAt,
|
||||
interval
|
||||
);
|
||||
await this.models.calendarSubscription.updateLastSyncAt(
|
||||
params.subscription.id,
|
||||
lastSyncAt
|
||||
);
|
||||
const attempt = params.subscription.syncRetryCount + 1;
|
||||
const nextRetryAt = this.calculateFailureRetryAt(attempt);
|
||||
await this.models.calendarSubscription.updateSync(params.subscription.id, {
|
||||
nextSyncAt: nextRetryAt,
|
||||
syncRetryCount: attempt,
|
||||
});
|
||||
this.logger.warn(
|
||||
`Calendar sync failed for subscription ${params.subscription.id}, attempt ${backoff.attempt}, next retry at ${backoff.nextRetryAt.toISOString()}`,
|
||||
`Calendar sync failed for subscription ${params.subscription.id}, attempt ${attempt}, next retry at ${nextRetryAt.toISOString()}`,
|
||||
this.toError(params.error)
|
||||
);
|
||||
}
|
||||
@@ -927,15 +925,6 @@ export class CalendarService {
|
||||
return status === 404;
|
||||
}
|
||||
|
||||
private calculateLastSyncAtForRetry(
|
||||
nextRetryAt: Date,
|
||||
refreshIntervalMinutes: number
|
||||
) {
|
||||
// Cron schedules by `now - lastSyncAt >= refreshInterval`, so back-calculate
|
||||
// a synthetic lastSyncAt to defer the next attempt to `nextRetryAt`.
|
||||
return new Date(nextRetryAt.getTime() - refreshIntervalMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
private async disableSubscription(params: {
|
||||
subscriptionId: string;
|
||||
provider: CalendarProvider;
|
||||
@@ -966,68 +955,52 @@ export class CalendarService {
|
||||
await this.models.calendarSubscription.disableAndPurge(
|
||||
params.subscriptionId
|
||||
);
|
||||
await this.clearSyncFailureBackoff(params.subscriptionId);
|
||||
}
|
||||
|
||||
private getSyncFailureBackoffKey(subscriptionId: string) {
|
||||
return `${SYNC_FAILURE_BACKOFF_KEY_PREFIX}${subscriptionId}`;
|
||||
}
|
||||
|
||||
private async getSyncFailureBackoff(subscriptionId: string) {
|
||||
const key = this.getSyncFailureBackoffKey(subscriptionId);
|
||||
const value = await this.redis.get(key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as {
|
||||
attempt?: number;
|
||||
nextRetryAt?: string;
|
||||
};
|
||||
if (!parsed.attempt || !parsed.nextRetryAt) {
|
||||
return null;
|
||||
async enqueueSyncSubscription(
|
||||
subscriptionId: string,
|
||||
reason: 'polling' | 'webhook' | 'on-demand'
|
||||
) {
|
||||
await this.queue.add(
|
||||
'calendar.syncSubscription',
|
||||
{
|
||||
subscriptionId,
|
||||
reason,
|
||||
},
|
||||
{
|
||||
jobId: subscriptionId,
|
||||
}
|
||||
const nextRetryAt = new Date(parsed.nextRetryAt);
|
||||
if (Number.isNaN(nextRetryAt.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
attempt: parsed.attempt,
|
||||
nextRetryAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async bumpSyncFailureBackoff(subscriptionId: string) {
|
||||
const state = await this.getSyncFailureBackoff(subscriptionId);
|
||||
const attempt = (state?.attempt ?? 0) + 1;
|
||||
const delay = Math.min(
|
||||
SYNC_FAILURE_BACKOFF_BASE_MS * 2 ** (attempt - 1),
|
||||
SYNC_FAILURE_BACKOFF_MAX_MS
|
||||
);
|
||||
const nextRetryAt = new Date(Date.now() + delay);
|
||||
const key = this.getSyncFailureBackoffKey(subscriptionId);
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({
|
||||
attempt,
|
||||
nextRetryAt: nextRetryAt.toISOString(),
|
||||
}),
|
||||
'EX',
|
||||
SYNC_FAILURE_BACKOFF_TTL_SECONDS
|
||||
);
|
||||
return {
|
||||
attempt,
|
||||
nextRetryAt,
|
||||
};
|
||||
private calculateNextSyncAt(base: Date, refreshIntervalMinutes?: number) {
|
||||
const intervalMinutes =
|
||||
refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
|
||||
return new Date(base.getTime() + intervalMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
private async clearSyncFailureBackoff(subscriptionId: string) {
|
||||
const key = this.getSyncFailureBackoffKey(subscriptionId);
|
||||
await this.redis.del(key);
|
||||
private calculateChannelRetryAt(nextSyncAt: Date) {
|
||||
return new Date(
|
||||
Math.min(nextSyncAt.getTime(), this.nowMs() + CHANNEL_RENEW_RETRY_MS)
|
||||
);
|
||||
}
|
||||
|
||||
private calculateFailureRetryAt(attempt: number) {
|
||||
return new Date(
|
||||
this.nowMs() +
|
||||
exponentialBackoffDelay(attempt - 1, {
|
||||
baseDelayMs: SYNC_FAILURE_BACKOFF_BASE_MS,
|
||||
maxDelayMs: SYNC_FAILURE_BACKOFF_MAX_MS,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private now() {
|
||||
return new Date(this.nowMs());
|
||||
}
|
||||
|
||||
private nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
private formatSyncError(error: unknown) {
|
||||
|
||||
@@ -33,19 +33,37 @@ test('should not index workspace if indexer is disabled', async t => {
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.indexWorkspace({ id: 'test-workspace' });
|
||||
await indexerEvent.indexWorkspace({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'test-workspace',
|
||||
});
|
||||
|
||||
t.is(module.queue.count('indexer.indexWorkspace'), count);
|
||||
});
|
||||
|
||||
test('should index workspace if indexer is enabled', async t => {
|
||||
test('should index workspace when root snapshot is updated', async t => {
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.indexWorkspace({ id: 'test-workspace' });
|
||||
await indexerEvent.indexWorkspace({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'test-workspace',
|
||||
});
|
||||
|
||||
const { payload } = await module.queue.waitFor('indexer.indexWorkspace');
|
||||
t.is(payload.workspaceId, 'test-workspace');
|
||||
});
|
||||
|
||||
test('should not index workspace when non-root snapshot is updated', async t => {
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.indexWorkspace({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'child-doc',
|
||||
});
|
||||
|
||||
t.is(module.queue.count('indexer.indexWorkspace'), count);
|
||||
});
|
||||
|
||||
test('should not delete workspace if indexer is disabled', async t => {
|
||||
Sinon.stub(config.indexer, 'enabled').value(false);
|
||||
const count = module.queue.count('indexer.deleteWorkspace');
|
||||
|
||||
@@ -29,21 +29,20 @@ export class IndexerEvent {
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.updated')
|
||||
async indexWorkspace({ id }: Events['workspace.updated']) {
|
||||
@OnEvent('doc.snapshot.updated')
|
||||
async indexWorkspace({ workspaceId, docId }: Events['doc.snapshot.updated']) {
|
||||
if (!this.config.indexer.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceId !== docId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.queue.add(
|
||||
'indexer.indexWorkspace',
|
||||
{
|
||||
workspaceId: id,
|
||||
},
|
||||
{
|
||||
jobId: `indexWorkspace/${id}`,
|
||||
priority: 100,
|
||||
}
|
||||
{ workspaceId },
|
||||
{ jobId: `indexWorkspace/${workspaceId}`, priority: 100 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,22 +114,17 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
const subscription = await this.getSubscription({
|
||||
plan: lookupKey.plan,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (
|
||||
subscription &&
|
||||
active &&
|
||||
// do not allow to re-subscribe unless
|
||||
!(
|
||||
/* current subscription is a onetime subscription and so as the one that's checking out */
|
||||
(
|
||||
(subscription.variant === SubscriptionVariant.Onetime &&
|
||||
(active.variant === SubscriptionVariant.Onetime &&
|
||||
lookupKey.variant === SubscriptionVariant.Onetime) ||
|
||||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
|
||||
(subscription.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
subscription.variant !== SubscriptionVariant.Onetime &&
|
||||
(active.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
active.variant !== SubscriptionVariant.Onetime &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
|
||||
sendTestEmail(config: {
|
||||
host: $host,
|
||||
port: $port,
|
||||
sender: $sender,
|
||||
username: $username,
|
||||
password: $password,
|
||||
ignoreTLS: $ignoreTLS,
|
||||
})
|
||||
}
|
||||
mutation sendTestEmail(
|
||||
$name: String!
|
||||
$host: String!
|
||||
$port: Int!
|
||||
$sender: String!
|
||||
$username: String!
|
||||
$password: String!
|
||||
$ignoreTLS: Boolean!
|
||||
) {
|
||||
sendTestEmail(
|
||||
config: {
|
||||
name: $name
|
||||
host: $host
|
||||
port: $port
|
||||
sender: $sender
|
||||
username: $username
|
||||
password: $password
|
||||
ignoreTLS: $ignoreTLS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -549,9 +549,9 @@ export const listUsersQuery = {
|
||||
export const sendTestEmailMutation = {
|
||||
id: 'sendTestEmailMutation' as const,
|
||||
op: 'sendTestEmail',
|
||||
query: `mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
|
||||
query: `mutation sendTestEmail($name: String!, $host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
|
||||
sendTestEmail(
|
||||
config: {host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS}
|
||||
config: {name: $name, host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS}
|
||||
)
|
||||
}`,
|
||||
};
|
||||
|
||||
@@ -4027,6 +4027,7 @@ export type ListUsersQuery = {
|
||||
};
|
||||
|
||||
export type SendTestEmailMutationVariables = Exact<{
|
||||
name: Scalars['String']['input'];
|
||||
host: Scalars['String']['input'];
|
||||
port: Scalars['Int']['input'];
|
||||
sender: Scalars['String']['input'];
|
||||
|
||||
@@ -430,9 +430,7 @@ fn parse_markdown_inner(markdown: &str) -> Result<MarkdownDocument, ParseError>
|
||||
table_handled = true;
|
||||
}
|
||||
Event::Html(html) | Event::InlineHtml(html) => {
|
||||
if is_html_comment(html) || is_iframe_end_tag(html) {
|
||||
// Ignore HTML comments and iframe end tags inside table cells.
|
||||
} else if let Some(text) = extract_wrapped_html_text(html) {
|
||||
if let Some(text) = extract_wrapped_html_text(html) {
|
||||
state.push_text(&text);
|
||||
} else if is_html_line_break(html) {
|
||||
state.push_text("\n");
|
||||
@@ -623,9 +621,6 @@ fn parse_markdown_inner(markdown: &str) -> Result<MarkdownDocument, ParseError>
|
||||
}
|
||||
}
|
||||
Event::Html(html) | Event::InlineHtml(html) => {
|
||||
if is_html_comment(&html) || is_iframe_end_tag(&html) {
|
||||
continue;
|
||||
}
|
||||
if is_ai_editable_comment(&html) {
|
||||
continue;
|
||||
}
|
||||
@@ -778,9 +773,6 @@ fn validate_markdown_inner(markdown: &str) -> Result<(), ParseError> {
|
||||
match event {
|
||||
Event::Start(tag) => ensure_supported_tag(&tag)?,
|
||||
Event::Html(html) | Event::InlineHtml(html) => {
|
||||
if is_html_comment(&html) || is_iframe_end_tag(&html) {
|
||||
continue;
|
||||
}
|
||||
if is_ai_editable_comment(&html) {
|
||||
continue;
|
||||
}
|
||||
@@ -944,15 +936,6 @@ fn is_ai_editable_comment(html: &str) -> bool {
|
||||
body.contains("block_id=") && body.contains("flavour=")
|
||||
}
|
||||
|
||||
fn is_html_comment(html: &str) -> bool {
|
||||
let trimmed = html.trim();
|
||||
trimmed.starts_with("<!--") && trimmed.ends_with("-->")
|
||||
}
|
||||
|
||||
fn is_iframe_end_tag(html: &str) -> bool {
|
||||
parse_html_tag(html).is_some_and(|tag| tag.closing && tag.name == "iframe")
|
||||
}
|
||||
|
||||
fn is_html_line_break(html: &str) -> bool {
|
||||
let trimmed = html.trim();
|
||||
if !trimmed.starts_with('<') || !trimmed.ends_with('>') {
|
||||
@@ -1733,13 +1716,6 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_markdown_allows_html_comment() {
|
||||
let markdown = "# Title\n\n<!-- omit from toc -->\n\nContent.";
|
||||
let result = validate_markdown(markdown);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_markdown_rejects_html() {
|
||||
let markdown = "# Title\n\n<div>HTML</div>";
|
||||
|
||||
@@ -282,9 +282,6 @@ pub fn parse_doc_to_markdown(
|
||||
0
|
||||
};
|
||||
let ai_block = ai_editable && block_level == 2;
|
||||
let ai_preserve_block = ai_block
|
||||
&& (matches!(flavour.as_str(), "affine:database" | "affine:callout")
|
||||
|| BlockFlavour::from_str(flavour.as_str()).is_none());
|
||||
|
||||
let mut block_markdown = String::new();
|
||||
|
||||
@@ -311,9 +308,7 @@ pub fn parse_doc_to_markdown(
|
||||
};
|
||||
renderer.write_block(&mut block_markdown, &spec, list_depth);
|
||||
} else {
|
||||
block_markdown.push_str(&format!(
|
||||
"<!-- unsupported_block_flavour:{flavour} block_id={block_id} -->\n\n"
|
||||
));
|
||||
return Err(ParseError::ParserError(format!("unsupported_block_flavour:{flavour}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,9 +317,6 @@ pub fn parse_doc_to_markdown(
|
||||
markdown.push_str(&format!("<!-- block_id={block_id} flavour={flavour} -->\n"));
|
||||
}
|
||||
markdown.push_str(&block_markdown);
|
||||
if ai_preserve_block {
|
||||
markdown.push_str(&format!("<!-- block_id={block_id} flavour={flavour} end -->\n"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MarkdownResult {
|
||||
@@ -800,59 +792,4 @@ mod tests {
|
||||
assert!(md.contains("|A|B|"));
|
||||
assert!(md.contains("|---|---|"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_doc_to_markdown_skips_unsupported_block_flavour() {
|
||||
let doc_id = "unsupported-doc".to_string();
|
||||
let doc = DocOptions::new().with_guid(doc_id.clone()).build();
|
||||
let mut blocks = doc.get_or_create_map("blocks").unwrap();
|
||||
|
||||
let mut page = doc.create_map().unwrap();
|
||||
page.insert("sys:id".into(), "page").unwrap();
|
||||
page.insert("sys:flavour".into(), "affine:page").unwrap();
|
||||
let mut page_children = doc.create_array().unwrap();
|
||||
page_children.push("note").unwrap();
|
||||
page.insert("sys:children".into(), Value::Array(page_children)).unwrap();
|
||||
let mut page_title = doc.create_text().unwrap();
|
||||
page_title.insert(0, "Page").unwrap();
|
||||
page.insert("prop:title".into(), Value::Text(page_title)).unwrap();
|
||||
blocks.insert("page".into(), Value::Map(page)).unwrap();
|
||||
|
||||
let mut note = doc.create_map().unwrap();
|
||||
note.insert("sys:id".into(), "note").unwrap();
|
||||
note.insert("sys:flavour".into(), "affine:note").unwrap();
|
||||
let mut note_children = doc.create_array().unwrap();
|
||||
note_children.push("latex").unwrap();
|
||||
note_children.push("paragraph").unwrap();
|
||||
note.insert("sys:children".into(), Value::Array(note_children)).unwrap();
|
||||
note.insert("prop:displayMode".into(), "page").unwrap();
|
||||
blocks.insert("note".into(), Value::Map(note)).unwrap();
|
||||
|
||||
let mut unsupported = doc.create_map().unwrap();
|
||||
unsupported.insert("sys:id".into(), "latex").unwrap();
|
||||
unsupported.insert("sys:flavour".into(), "affine:latex").unwrap();
|
||||
unsupported
|
||||
.insert("sys:children".into(), Value::Array(doc.create_array().unwrap()))
|
||||
.unwrap();
|
||||
blocks.insert("latex".into(), Value::Map(unsupported)).unwrap();
|
||||
|
||||
let mut paragraph = doc.create_map().unwrap();
|
||||
paragraph.insert("sys:id".into(), "paragraph").unwrap();
|
||||
paragraph.insert("sys:flavour".into(), "affine:paragraph").unwrap();
|
||||
paragraph
|
||||
.insert("sys:children".into(), Value::Array(doc.create_array().unwrap()))
|
||||
.unwrap();
|
||||
let mut paragraph_text = doc.create_text().unwrap();
|
||||
paragraph_text.insert(0, "After unsupported block").unwrap();
|
||||
paragraph
|
||||
.insert("prop:text".into(), Value::Text(paragraph_text))
|
||||
.unwrap();
|
||||
blocks.insert("paragraph".into(), Value::Map(paragraph)).unwrap();
|
||||
|
||||
let doc_bin = doc.encode_update_v1().unwrap();
|
||||
let result = parse_doc_to_markdown(doc_bin, doc_id, false, None).expect("parse doc");
|
||||
|
||||
assert!(result.markdown.contains("unsupported_block_flavour:affine:latex"));
|
||||
assert!(result.markdown.contains("After unsupported block"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Converts markdown content into AFFiNE-compatible y-octo document binary
|
||||
//! format.
|
||||
|
||||
use y_octo::{DocOptions, StateVector};
|
||||
use y_octo::DocOptions;
|
||||
|
||||
use super::{
|
||||
super::{
|
||||
@@ -73,7 +73,7 @@ fn build_doc_update(doc_id: &str, title: &str, blocks: &[BlockNode]) -> Result<V
|
||||
note_map.insert(PROP_HIDDEN.to_string(), Any::False)?;
|
||||
note_map.insert(PROP_DISPLAY_MODE.to_string(), Any::String("both".to_string()))?;
|
||||
|
||||
Ok(doc.encode_state_as_update_v1(&StateVector::default())?)
|
||||
Ok(doc.encode_update_v1()?)
|
||||
}
|
||||
|
||||
fn insert_block_trees(doc: &Doc, blocks_map: &mut Map, blocks: &[BlockNode]) -> Result<Vec<String>, ParseError> {
|
||||
|
||||
@@ -8,37 +8,19 @@ use std::collections::HashMap;
|
||||
use super::{
|
||||
super::{
|
||||
block_spec::{TreeNode, count_tree_nodes, text_delta_eq},
|
||||
blocksuite::{collect_child_ids, find_child_id_by_flavour, get_string},
|
||||
blocksuite::{collect_child_ids, find_child_id_by_flavour},
|
||||
markdown::{MAX_BLOCKS, parse_markdown_blocks},
|
||||
schema::{PROP_BACKGROUND, PROP_DISPLAY_MODE, PROP_ELEMENTS, PROP_HIDDEN, PROP_INDEX, PROP_XYWH, SURFACE_FLAVOUR},
|
||||
},
|
||||
builder::{
|
||||
ApplyBlockOptions, BOXED_NATIVE_TYPE, NOTE_BG_DARK, NOTE_BG_LIGHT, apply_block_spec, boxed_empty_map,
|
||||
insert_block_map, insert_block_tree, insert_children, insert_sys_fields, insert_text, note_background_map,
|
||||
text_ops_from_plain,
|
||||
},
|
||||
builder::{ApplyBlockOptions, apply_block_spec, insert_block_tree, insert_children},
|
||||
*,
|
||||
};
|
||||
|
||||
const MAX_LCS_CELLS: usize = 2_000_000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum NodeSpec {
|
||||
Supported(BlockSpec),
|
||||
/// A block flavour we don't support for markdown diffing/updating (e.g.
|
||||
/// `affine:database`).
|
||||
///
|
||||
/// These nodes are treated as opaque: we preserve them and never modify their
|
||||
/// properties/children.
|
||||
Opaque {
|
||||
flavour: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StoredNode {
|
||||
id: String,
|
||||
spec: NodeSpec,
|
||||
spec: BlockSpec,
|
||||
children: Vec<StoredNode>,
|
||||
}
|
||||
|
||||
@@ -48,20 +30,6 @@ impl TreeNode for StoredNode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TargetNode {
|
||||
/// Optional block id marker from exported markdown (AI-editable markers).
|
||||
id_hint: Option<String>,
|
||||
spec: NodeSpec,
|
||||
children: Vec<TargetNode>,
|
||||
}
|
||||
|
||||
impl TreeNode for TargetNode {
|
||||
fn children(&self) -> &[TargetNode] {
|
||||
&self.children
|
||||
}
|
||||
}
|
||||
|
||||
struct DocState {
|
||||
doc: Doc,
|
||||
note_id: String,
|
||||
@@ -91,24 +59,8 @@ enum PatchOp {
|
||||
/// # Returns
|
||||
/// A binary vector representing only the delta (changes) to apply
|
||||
pub fn update_doc(existing_binary: &[u8], new_markdown: &str, doc_id: &str) -> Result<Vec<u8>, ParseError> {
|
||||
let state = match load_doc_state(existing_binary, doc_id) {
|
||||
Ok(state) => state,
|
||||
Err(ParseError::ParserError(msg))
|
||||
if matches!(
|
||||
msg.as_str(),
|
||||
"blocks map is empty" | "page block not found" | "note block not found"
|
||||
) =>
|
||||
{
|
||||
// The existing doc may be a stub/partial document (e.g. created by references)
|
||||
// and doesn't contain the canonical page/note structure yet. In that
|
||||
// case, initialize the doc from the markdown instead of failing hard.
|
||||
let new_nodes = parse_markdown_blocks(new_markdown)?;
|
||||
return init_doc_from_markdown(existing_binary, new_markdown, doc_id, &new_nodes);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let mut new_nodes = parse_markdown_targets(new_markdown)?;
|
||||
let mut new_nodes = parse_markdown_blocks(new_markdown)?;
|
||||
let state = load_doc_state(existing_binary, doc_id)?;
|
||||
|
||||
check_limits(&state.blocks, &new_nodes)?;
|
||||
|
||||
@@ -122,315 +74,6 @@ pub fn update_doc(existing_binary: &[u8], new_markdown: &str, doc_id: &str) -> R
|
||||
Ok(state.doc.encode_state_as_update_v1(&state_before)?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BlockMarker {
|
||||
id: String,
|
||||
flavour: String,
|
||||
end: bool,
|
||||
}
|
||||
|
||||
fn parse_block_marker_line(line: &str) -> Option<BlockMarker> {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("<!--") || !trimmed.ends_with("-->") {
|
||||
return None;
|
||||
}
|
||||
let body = trimmed.trim_start_matches("<!--").trim_end_matches("-->").trim();
|
||||
if !body.contains("block_id=") || !body.contains("flavour=") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut id: Option<String> = None;
|
||||
let mut flavour: Option<String> = None;
|
||||
let mut end = false;
|
||||
|
||||
for token in body.split_whitespace() {
|
||||
if token == "end" || token == "type=end" || token == "end=true" {
|
||||
end = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = token.strip_prefix("block_id=") {
|
||||
if !value.is_empty() {
|
||||
id = Some(value.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = token.strip_prefix("flavour=") {
|
||||
if !value.is_empty() {
|
||||
flavour = Some(value.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Some(BlockMarker {
|
||||
id: id?,
|
||||
flavour: flavour?,
|
||||
end,
|
||||
})
|
||||
}
|
||||
|
||||
fn should_preserve_marker_flavour(flavour: &str) -> bool {
|
||||
matches!(flavour, "affine:database" | "affine:callout")
|
||||
}
|
||||
|
||||
fn parse_markdown_targets(markdown: &str) -> Result<Vec<TargetNode>, ParseError> {
|
||||
// Fast path: no markers, behave like the original implementation.
|
||||
if !markdown.contains("block_id=") || !markdown.contains("flavour=") {
|
||||
let blocks = parse_markdown_blocks(markdown)?;
|
||||
return Ok(blocks.into_iter().map(|b| target_from_block_node(b, None)).collect());
|
||||
}
|
||||
|
||||
// Split the markdown by marker comments. For most blocks, a marker indicates
|
||||
// the start of a block. For preserved blocks (e.g. database), an optional end
|
||||
// marker can be emitted so users can append new content after the preserved
|
||||
// section without needing to add markers manually.
|
||||
let mut segments: Vec<(Option<BlockMarker>, String)> = Vec::new();
|
||||
let mut current_marker: Option<BlockMarker> = None;
|
||||
let mut current_body = String::new();
|
||||
let mut saw_marker = false;
|
||||
|
||||
for line in markdown.lines() {
|
||||
if let Some(marker) = parse_block_marker_line(line) {
|
||||
saw_marker = true;
|
||||
if marker.end {
|
||||
if current_marker.is_some() || !current_body.is_empty() {
|
||||
segments.push((current_marker.take(), std::mem::take(&mut current_body)));
|
||||
}
|
||||
// Close the marker scope; subsequent lines belong to an unmarked segment.
|
||||
current_marker = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if current_marker.is_some() || !current_body.is_empty() {
|
||||
segments.push((current_marker.take(), std::mem::take(&mut current_body)));
|
||||
}
|
||||
current_marker = Some(marker);
|
||||
continue;
|
||||
}
|
||||
|
||||
current_body.push_str(line);
|
||||
current_body.push('\n');
|
||||
}
|
||||
|
||||
if current_marker.is_some() || !current_body.is_empty() {
|
||||
segments.push((current_marker.take(), current_body));
|
||||
}
|
||||
|
||||
if !saw_marker {
|
||||
let blocks = parse_markdown_blocks(markdown)?;
|
||||
return Ok(blocks.into_iter().map(|b| target_from_block_node(b, None)).collect());
|
||||
}
|
||||
|
||||
let mut out: Vec<TargetNode> = Vec::new();
|
||||
for (marker, body) in segments {
|
||||
if let Some(marker) = marker {
|
||||
let preserve =
|
||||
should_preserve_marker_flavour(&marker.flavour) || BlockFlavour::from_str(&marker.flavour).is_none();
|
||||
if preserve {
|
||||
out.push(TargetNode {
|
||||
id_hint: Some(marker.id),
|
||||
spec: NodeSpec::Opaque {
|
||||
flavour: marker.flavour,
|
||||
},
|
||||
children: Vec::new(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let blocks = parse_markdown_blocks(&body)?;
|
||||
for (idx, block) in blocks.into_iter().enumerate() {
|
||||
let id_hint = if idx == 0 { Some(marker.id.clone()) } else { None };
|
||||
out.push(target_from_block_node(block, id_hint));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let blocks = parse_markdown_blocks(&body)?;
|
||||
for block in blocks {
|
||||
out.push(target_from_block_node(block, None));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn target_from_block_node(node: BlockNode, id_hint: Option<String>) -> TargetNode {
|
||||
TargetNode {
|
||||
id_hint,
|
||||
spec: NodeSpec::Supported(node.spec),
|
||||
children: node
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|child| target_from_block_node(child, None))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn target_node_to_block_node(node: &TargetNode) -> Result<BlockNode, ParseError> {
|
||||
let NodeSpec::Supported(spec) = &node.spec else {
|
||||
return Err(ParseError::ParserError("cannot_insert_opaque_block".into()));
|
||||
};
|
||||
Ok(BlockNode {
|
||||
spec: spec.clone(),
|
||||
children: node
|
||||
.children
|
||||
.iter()
|
||||
.map(target_node_to_block_node)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn init_doc_from_markdown(
|
||||
existing_binary: &[u8],
|
||||
new_markdown: &str,
|
||||
doc_id: &str,
|
||||
blocks: &[BlockNode],
|
||||
) -> Result<Vec<u8>, ParseError> {
|
||||
let doc = load_doc(existing_binary, Some(doc_id))?;
|
||||
let state_before = doc.get_state_vector();
|
||||
let mut blocks_map = doc.get_or_create_map("blocks")?;
|
||||
|
||||
let title = derive_title_from_markdown(new_markdown).unwrap_or_else(|| "Untitled".to_string());
|
||||
// Prefer reusing an existing page block if the doc already has one (but is
|
||||
// missing surface/note). This avoids creating multiple page roots when
|
||||
// recovering from partial documents.
|
||||
if !blocks_map.is_empty() {
|
||||
let index = build_block_index(&blocks_map);
|
||||
if let Some(page_id) = find_block_id_by_flavour(&index.block_pool, PAGE_FLAVOUR) {
|
||||
insert_page_children(&doc, &mut blocks_map, &page_id, &title, blocks)?;
|
||||
return Ok(doc.encode_state_as_update_v1(&state_before)?);
|
||||
}
|
||||
}
|
||||
|
||||
insert_page_doc(&doc, &mut blocks_map, &title, blocks)?;
|
||||
|
||||
Ok(doc.encode_state_as_update_v1(&state_before)?)
|
||||
}
|
||||
|
||||
fn derive_title_from_markdown(markdown: &str) -> Option<String> {
|
||||
for line in markdown.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("# ") {
|
||||
let title = rest.trim();
|
||||
if !title.is_empty() {
|
||||
return Some(title.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn insert_page_doc(doc: &Doc, blocks_map: &mut Map, title: &str, blocks: &[BlockNode]) -> Result<(), ParseError> {
|
||||
let page_id = nanoid::nanoid!();
|
||||
let surface_id = nanoid::nanoid!();
|
||||
let note_id = nanoid::nanoid!();
|
||||
|
||||
// Insert root blocks first to establish stable IDs.
|
||||
let mut page_map = insert_block_map(doc, blocks_map, &page_id)?;
|
||||
let mut surface_map = insert_block_map(doc, blocks_map, &surface_id)?;
|
||||
let mut note_map = insert_block_map(doc, blocks_map, ¬e_id)?;
|
||||
|
||||
// Create content blocks under note.
|
||||
let content_ids = insert_block_trees(doc, blocks_map, blocks)?;
|
||||
|
||||
// Page block.
|
||||
insert_sys_fields(&mut page_map, &page_id, PAGE_FLAVOUR)?;
|
||||
insert_children(doc, &mut page_map, &[surface_id.clone(), note_id.clone()])?;
|
||||
insert_text(doc, &mut page_map, PROP_TITLE, &text_ops_from_plain(title))?;
|
||||
|
||||
// Surface block.
|
||||
insert_sys_fields(&mut surface_map, &surface_id, SURFACE_FLAVOUR)?;
|
||||
insert_children(doc, &mut surface_map, &[])?;
|
||||
let mut boxed = boxed_empty_map(doc)?;
|
||||
surface_map.insert(PROP_ELEMENTS.to_string(), Value::Map(boxed.clone()))?;
|
||||
boxed.insert("type".to_string(), Any::String(BOXED_NATIVE_TYPE.to_string()))?;
|
||||
let value = doc.create_map()?;
|
||||
boxed.insert("value".to_string(), Value::Map(value))?;
|
||||
|
||||
// Note block.
|
||||
insert_sys_fields(&mut note_map, ¬e_id, NOTE_FLAVOUR)?;
|
||||
insert_children(doc, &mut note_map, &content_ids)?;
|
||||
let mut background = note_background_map(doc)?;
|
||||
note_map.insert(PROP_BACKGROUND.to_string(), Value::Map(background.clone()))?;
|
||||
background.insert("light".to_string(), Any::String(NOTE_BG_LIGHT.to_string()))?;
|
||||
background.insert("dark".to_string(), Any::String(NOTE_BG_DARK.to_string()))?;
|
||||
note_map.insert(PROP_XYWH.to_string(), Any::String("[0,0,800,95]".to_string()))?;
|
||||
note_map.insert(PROP_INDEX.to_string(), Any::String("a0".to_string()))?;
|
||||
note_map.insert(PROP_HIDDEN.to_string(), Any::False)?;
|
||||
note_map.insert(PROP_DISPLAY_MODE.to_string(), Any::String("both".to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_page_children(
|
||||
doc: &Doc,
|
||||
blocks_map: &mut Map,
|
||||
page_id: &str,
|
||||
title: &str,
|
||||
blocks: &[BlockNode],
|
||||
) -> Result<(), ParseError> {
|
||||
let surface_id = nanoid::nanoid!();
|
||||
let note_id = nanoid::nanoid!();
|
||||
|
||||
// Insert root blocks first to establish stable IDs.
|
||||
let mut surface_map = insert_block_map(doc, blocks_map, &surface_id)?;
|
||||
let mut note_map = insert_block_map(doc, blocks_map, ¬e_id)?;
|
||||
|
||||
// Create content blocks under note.
|
||||
let content_ids = insert_block_trees(doc, blocks_map, blocks)?;
|
||||
|
||||
let Some(mut page_map) = blocks_map.get(page_id).and_then(|v| v.to_map()) else {
|
||||
return Err(ParseError::ParserError("page block not found".into()));
|
||||
};
|
||||
|
||||
// Page block.
|
||||
insert_sys_fields(&mut page_map, page_id, PAGE_FLAVOUR)?;
|
||||
insert_children(doc, &mut page_map, &[surface_id.clone(), note_id.clone()])?;
|
||||
if page_map.get(PROP_TITLE).is_none() {
|
||||
insert_text(doc, &mut page_map, PROP_TITLE, &text_ops_from_plain(title))?;
|
||||
}
|
||||
|
||||
// Surface block.
|
||||
insert_sys_fields(&mut surface_map, &surface_id, SURFACE_FLAVOUR)?;
|
||||
insert_children(doc, &mut surface_map, &[])?;
|
||||
let mut boxed = boxed_empty_map(doc)?;
|
||||
surface_map.insert(PROP_ELEMENTS.to_string(), Value::Map(boxed.clone()))?;
|
||||
boxed.insert("type".to_string(), Any::String(BOXED_NATIVE_TYPE.to_string()))?;
|
||||
let value = doc.create_map()?;
|
||||
boxed.insert("value".to_string(), Value::Map(value))?;
|
||||
|
||||
// Note block.
|
||||
insert_sys_fields(&mut note_map, ¬e_id, NOTE_FLAVOUR)?;
|
||||
insert_children(doc, &mut note_map, &content_ids)?;
|
||||
let mut background = note_background_map(doc)?;
|
||||
note_map.insert(PROP_BACKGROUND.to_string(), Value::Map(background.clone()))?;
|
||||
background.insert("light".to_string(), Any::String(NOTE_BG_LIGHT.to_string()))?;
|
||||
background.insert("dark".to_string(), Any::String(NOTE_BG_DARK.to_string()))?;
|
||||
note_map.insert(PROP_XYWH.to_string(), Any::String("[0,0,800,95]".to_string()))?;
|
||||
note_map.insert(PROP_INDEX.to_string(), Any::String("a0".to_string()))?;
|
||||
note_map.insert(PROP_HIDDEN.to_string(), Any::False)?;
|
||||
note_map.insert(PROP_DISPLAY_MODE.to_string(), Any::String("both".to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_block_trees(doc: &Doc, blocks_map: &mut Map, blocks: &[BlockNode]) -> Result<Vec<String>, ParseError> {
|
||||
let mut ids = Vec::with_capacity(blocks.len());
|
||||
for block in blocks {
|
||||
let id = insert_block_tree(doc, blocks_map, block)?;
|
||||
ids.push(id);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn load_doc_state(binary: &[u8], doc_id: &str) -> Result<DocState, ParseError> {
|
||||
let doc = load_doc(binary, Some(doc_id))?;
|
||||
|
||||
@@ -467,31 +110,14 @@ fn load_doc_state(binary: &[u8], doc_id: &str) -> Result<DocState, ParseError> {
|
||||
}
|
||||
|
||||
fn build_stored_tree(block_id: &str, block: &Map, pool: &HashMap<String, Map>) -> Result<StoredNode, ParseError> {
|
||||
let spec = BlockSpec::from_block_map(block)?;
|
||||
|
||||
let child_ids = collect_child_ids(block);
|
||||
let flavour = get_string(block, "sys:flavour").unwrap_or_default();
|
||||
|
||||
let spec = match BlockSpec::from_block_map(block) {
|
||||
Ok(spec) => spec,
|
||||
Err(ParseError::ParserError(msg)) if msg.starts_with("unsupported block flavour:") => {
|
||||
return Ok(StoredNode {
|
||||
id: block_id.to_string(),
|
||||
spec: NodeSpec::Opaque { flavour },
|
||||
children: Vec::new(),
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
// Only list/callout are supported as containers for markdown diffing.
|
||||
// For any other block with children, treat as opaque so we never corrupt it.
|
||||
if !child_ids.is_empty() && !matches!(spec.flavour, BlockFlavour::List | BlockFlavour::Callout) {
|
||||
return Ok(StoredNode {
|
||||
id: block_id.to_string(),
|
||||
spec: NodeSpec::Opaque { flavour },
|
||||
children: Vec::new(),
|
||||
});
|
||||
return Err(ParseError::ParserError(format!(
|
||||
"unsupported children on block: {block_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut children = Vec::new();
|
||||
for child_id in child_ids {
|
||||
let child_block = pool
|
||||
@@ -502,7 +128,7 @@ fn build_stored_tree(block_id: &str, block: &Map, pool: &HashMap<String, Map>) -
|
||||
|
||||
Ok(StoredNode {
|
||||
id: block_id.to_string(),
|
||||
spec: NodeSpec::Supported(spec),
|
||||
spec,
|
||||
children,
|
||||
})
|
||||
}
|
||||
@@ -511,7 +137,7 @@ fn sync_nodes(
|
||||
doc: &Doc,
|
||||
blocks_map: &mut Map,
|
||||
current: &[StoredNode],
|
||||
target: &mut [TargetNode],
|
||||
target: &mut [BlockNode],
|
||||
) -> Result<Vec<String>, ParseError> {
|
||||
let ops = diff_blocks(current, target);
|
||||
let mut new_children = Vec::new();
|
||||
@@ -522,47 +148,29 @@ fn sync_nodes(
|
||||
PatchOp::Keep(old_idx, new_idx) => {
|
||||
let old_node = ¤t[old_idx];
|
||||
let new_node = &target[new_idx];
|
||||
if let (NodeSpec::Supported(old_spec), NodeSpec::Supported(new_spec)) = (&old_node.spec, &new_node.spec) {
|
||||
update_block_props(doc, blocks_map, &old_node.id, old_spec, new_spec, true)?;
|
||||
let child_ids = sync_nodes(doc, blocks_map, &old_node.children, &mut new_node.children.clone())?;
|
||||
sync_children(doc, blocks_map, &old_node.id, &child_ids)?;
|
||||
} else {
|
||||
// Preserve opaque blocks (and any mismatched marker blocks) as-is.
|
||||
// Don't touch their properties or children ordering.
|
||||
}
|
||||
update_block_props(doc, blocks_map, old_node, &new_node.spec, true)?;
|
||||
let child_ids = sync_nodes(doc, blocks_map, &old_node.children, &mut new_node.children.clone())?;
|
||||
sync_children(doc, blocks_map, &old_node.id, &child_ids)?;
|
||||
new_children.push(old_node.id.clone());
|
||||
}
|
||||
PatchOp::Update(old_idx, new_idx) => {
|
||||
let old_node = ¤t[old_idx];
|
||||
let new_node = &target[new_idx];
|
||||
if let (NodeSpec::Supported(old_spec), NodeSpec::Supported(new_spec)) = (&old_node.spec, &new_node.spec) {
|
||||
update_block_props(doc, blocks_map, &old_node.id, old_spec, new_spec, false)?;
|
||||
let child_ids = sync_nodes(doc, blocks_map, &old_node.children, &mut new_node.children.clone())?;
|
||||
sync_children(doc, blocks_map, &old_node.id, &child_ids)?;
|
||||
} else {
|
||||
// Opaque blocks are never updated from markdown.
|
||||
}
|
||||
update_block_props(doc, blocks_map, old_node, &new_node.spec, false)?;
|
||||
let child_ids = sync_nodes(doc, blocks_map, &old_node.children, &mut new_node.children.clone())?;
|
||||
sync_children(doc, blocks_map, &old_node.id, &child_ids)?;
|
||||
new_children.push(old_node.id.clone());
|
||||
}
|
||||
PatchOp::Insert(new_idx) => {
|
||||
if let Ok(node) = target_node_to_block_node(&target[new_idx]) {
|
||||
let new_id = insert_block_tree(doc, blocks_map, &node)?;
|
||||
new_children.push(new_id);
|
||||
}
|
||||
let new_id = insert_block_tree(doc, blocks_map, &target[new_idx])?;
|
||||
new_children.push(new_id);
|
||||
}
|
||||
PatchOp::Delete(old_idx) => {
|
||||
let node = ¤t[old_idx];
|
||||
match &node.spec {
|
||||
NodeSpec::Opaque { .. } => {
|
||||
// Never delete opaque blocks when syncing from markdown. They might contain
|
||||
// rich data that can't be represented in markdown, so keeping them
|
||||
// avoids data loss.
|
||||
new_children.push(node.id.clone());
|
||||
}
|
||||
NodeSpec::Supported(spec) if spec.flavour == BlockFlavour::Callout => {
|
||||
new_children.push(node.id.clone());
|
||||
}
|
||||
NodeSpec::Supported(_) => collect_tree_ids(node, &mut to_remove),
|
||||
if node.spec.flavour == BlockFlavour::Callout {
|
||||
new_children.push(node.id.clone());
|
||||
} else {
|
||||
collect_tree_ids(node, &mut to_remove);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,7 +183,7 @@ fn sync_nodes(
|
||||
Ok(new_children)
|
||||
}
|
||||
|
||||
fn diff_blocks(current: &[StoredNode], target: &[TargetNode]) -> Vec<PatchOp> {
|
||||
fn diff_blocks(current: &[StoredNode], target: &[BlockNode]) -> Vec<PatchOp> {
|
||||
let old_len = current.len();
|
||||
let new_len = target.len();
|
||||
|
||||
@@ -590,10 +198,10 @@ fn diff_blocks(current: &[StoredNode], target: &[TargetNode]) -> Vec<PatchOp> {
|
||||
|
||||
for i in 1..=old_len {
|
||||
for j in 1..=new_len {
|
||||
let old_node = ¤t[i - 1];
|
||||
let new_node = &target[j - 1];
|
||||
let old_spec = ¤t[i - 1].spec;
|
||||
let new_spec = &target[j - 1].spec;
|
||||
|
||||
if nodes_align(old_node, new_node) {
|
||||
if old_spec.is_exact(new_spec) {
|
||||
lcs[i][j] = lcs[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
lcs[i][j] = std::cmp::max(lcs[i - 1][j], lcs[i][j - 1]);
|
||||
@@ -607,18 +215,14 @@ fn diff_blocks(current: &[StoredNode], target: &[TargetNode]) -> Vec<PatchOp> {
|
||||
|
||||
while i > 0 || j > 0 {
|
||||
if i > 0 && j > 0 {
|
||||
let old_node = ¤t[i - 1];
|
||||
let new_node = &target[j - 1];
|
||||
let old_spec = ¤t[i - 1].spec;
|
||||
let new_spec = &target[j - 1].spec;
|
||||
|
||||
if nodes_align(old_node, new_node) {
|
||||
if nodes_should_update(old_node, new_node) {
|
||||
ops.push(PatchOp::Update(i - 1, j - 1));
|
||||
} else {
|
||||
ops.push(PatchOp::Keep(i - 1, j - 1));
|
||||
}
|
||||
if old_spec.is_exact(new_spec) {
|
||||
ops.push(PatchOp::Keep(i - 1, j - 1));
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if nodes_similar(old_node, new_node)
|
||||
} else if old_spec.is_similar(new_spec)
|
||||
&& lcs[i - 1][j - 1] >= lcs[i - 1][j]
|
||||
&& lcs[i - 1][j - 1] >= lcs[i][j - 1]
|
||||
{
|
||||
@@ -645,60 +249,15 @@ fn diff_blocks(current: &[StoredNode], target: &[TargetNode]) -> Vec<PatchOp> {
|
||||
ops
|
||||
}
|
||||
|
||||
fn nodes_align(old_node: &StoredNode, new_node: &TargetNode) -> bool {
|
||||
if marker_matches(old_node, new_node) {
|
||||
return true;
|
||||
}
|
||||
match (&old_node.spec, &new_node.spec) {
|
||||
(NodeSpec::Supported(old_spec), NodeSpec::Supported(new_spec)) => old_spec.is_exact(new_spec),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn nodes_should_update(old_node: &StoredNode, new_node: &TargetNode) -> bool {
|
||||
if marker_matches(old_node, new_node) {
|
||||
return match (&old_node.spec, &new_node.spec) {
|
||||
(NodeSpec::Supported(old_spec), NodeSpec::Supported(new_spec)) => !old_spec.is_exact(new_spec),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn nodes_similar(old_node: &StoredNode, new_node: &TargetNode) -> bool {
|
||||
match (&old_node.spec, &new_node.spec) {
|
||||
(NodeSpec::Supported(old_spec), NodeSpec::Supported(new_spec)) => old_spec.is_similar(new_spec),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn marker_matches(old_node: &StoredNode, new_node: &TargetNode) -> bool {
|
||||
let Some(id) = new_node.id_hint.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
if id != old_node.id.as_str() {
|
||||
return false;
|
||||
}
|
||||
node_flavour_str(&old_node.spec) == node_flavour_str(&new_node.spec)
|
||||
}
|
||||
|
||||
fn node_flavour_str(spec: &NodeSpec) -> &str {
|
||||
match spec {
|
||||
NodeSpec::Supported(spec) => spec.flavour.as_str(),
|
||||
NodeSpec::Opaque { flavour } => flavour.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_block_props(
|
||||
doc: &Doc,
|
||||
blocks_map: &mut Map,
|
||||
node_id: &str,
|
||||
current: &BlockSpec,
|
||||
node: &StoredNode,
|
||||
target: &BlockSpec,
|
||||
preserve_text: bool,
|
||||
) -> Result<(), ParseError> {
|
||||
let Some(mut block) = blocks_map.get(node_id).and_then(|v| v.to_map()) else {
|
||||
return Err(ParseError::ParserError(format!("Block {} not found", node_id)));
|
||||
let Some(mut block) = blocks_map.get(&node.id).and_then(|v| v.to_map()) else {
|
||||
return Err(ParseError::ParserError(format!("Block {} not found", node.id)));
|
||||
};
|
||||
|
||||
let preserve = match target.flavour {
|
||||
@@ -707,7 +266,7 @@ fn update_block_props(
|
||||
| BlockFlavour::Bookmark
|
||||
| BlockFlavour::EmbedYoutube
|
||||
| BlockFlavour::EmbedIframe => preserve_text,
|
||||
_ => preserve_text || text_delta_eq(¤t.text, &target.text),
|
||||
_ => preserve_text || text_delta_eq(&node.spec.text, &target.text),
|
||||
};
|
||||
|
||||
apply_block_spec(
|
||||
@@ -743,7 +302,7 @@ fn collect_tree_ids(node: &StoredNode, output: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_limits(current: &[StoredNode], target: &[TargetNode]) -> Result<(), ParseError> {
|
||||
fn check_limits(current: &[StoredNode], target: &[BlockNode]) -> Result<(), ParseError> {
|
||||
let current_count = count_tree_nodes(current);
|
||||
let target_count = count_tree_nodes(target);
|
||||
|
||||
@@ -760,7 +319,7 @@ fn check_limits(current: &[StoredNode], target: &[TargetNode]) -> Result<(), Par
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use y_octo::{Any, DocOptions, StateVector, TextDeltaOp, TextInsert};
|
||||
use y_octo::{Any, DocOptions, TextDeltaOp, TextInsert};
|
||||
|
||||
use super::{super::builder::text_ops_from_plain, *};
|
||||
use crate::doc_parser::{
|
||||
@@ -1088,233 +647,6 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_ydoc_fallback_when_blocks_empty() {
|
||||
let doc_id = "stub-empty-blocks";
|
||||
let markdown = "# From Markdown\n\nHello from markdown.";
|
||||
|
||||
// Build a valid ydoc update that results in an empty `blocks` map.
|
||||
// NOTE: yjs/y-octo may encode a completely empty doc as `[0,0]`, which we treat
|
||||
// as empty/invalid. We intentionally insert + remove a temp key so the
|
||||
// update is non-empty while the final map is empty.
|
||||
let doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
let mut blocks = doc.get_or_create_map("blocks").expect("create blocks map");
|
||||
blocks
|
||||
.insert("tmp".to_string(), Any::String("1".to_string()))
|
||||
.expect("insert temp");
|
||||
blocks.remove("tmp");
|
||||
let stub_bin = doc
|
||||
.encode_state_as_update_v1(&StateVector::default())
|
||||
.expect("encode stub update");
|
||||
assert!(
|
||||
!stub_bin.is_empty() && stub_bin.as_slice() != [0, 0],
|
||||
"stub update should not be empty update"
|
||||
);
|
||||
|
||||
let delta = update_doc(&stub_bin, markdown, doc_id).expect("fallback delta");
|
||||
assert!(!delta.is_empty(), "delta should contain changes");
|
||||
|
||||
let mut updated = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
updated
|
||||
.apply_update_from_binary_v1(&stub_bin)
|
||||
.expect("apply stub update");
|
||||
updated
|
||||
.apply_update_from_binary_v1(&delta)
|
||||
.expect("apply fallback delta");
|
||||
|
||||
let blocks_map = updated.get_map("blocks").expect("blocks map exists");
|
||||
|
||||
let mut page: Option<Map> = None;
|
||||
for (_, value) in blocks_map.iter() {
|
||||
if let Some(block_map) = value.to_map()
|
||||
&& get_string(&block_map, "sys:flavour").as_deref() == Some(PAGE_FLAVOUR)
|
||||
{
|
||||
page = Some(block_map);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let page = page.expect("page block created");
|
||||
assert_eq!(
|
||||
get_string(&page, "prop:title").as_deref(),
|
||||
Some("From Markdown"),
|
||||
"page title should be derived from markdown H1"
|
||||
);
|
||||
|
||||
let index = build_block_index(&blocks_map);
|
||||
let note_id = find_child_id_by_flavour(&page, &index.block_pool, NOTE_FLAVOUR).expect("note child exists");
|
||||
|
||||
let note = index.block_pool.get(¬e_id).expect("note block exists").clone();
|
||||
assert!(
|
||||
!collect_child_ids(¬e).is_empty(),
|
||||
"note should contain imported content blocks"
|
||||
);
|
||||
|
||||
let full_bin = updated
|
||||
.encode_state_as_update_v1(&StateVector::default())
|
||||
.expect("encode full doc");
|
||||
let md = parse_doc_to_markdown(full_bin, doc_id.to_string(), false, None).expect("render markdown");
|
||||
assert!(md.markdown.contains("Hello from markdown."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_ydoc_fallback_when_page_missing() {
|
||||
let doc_id = "stub-page-missing";
|
||||
let markdown = "# Title\n\nUpdated content.";
|
||||
|
||||
// Build a stub doc that has some blocks, but no `affine:page` root.
|
||||
let doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
let mut blocks_map = doc.get_or_create_map("blocks").expect("create blocks map");
|
||||
let para_id = "para-1";
|
||||
let mut para = insert_block_map(&doc, &mut blocks_map, para_id).expect("insert para");
|
||||
insert_sys_fields(&mut para, para_id, "affine:paragraph").expect("sys fields");
|
||||
insert_children(&doc, &mut para, &[]).expect("children");
|
||||
|
||||
let stub_bin = doc
|
||||
.encode_state_as_update_v1(&StateVector::default())
|
||||
.expect("encode stub update");
|
||||
assert!(!stub_bin.is_empty(), "stub update should not be empty");
|
||||
|
||||
let delta = update_doc(&stub_bin, markdown, doc_id).expect("fallback delta");
|
||||
assert!(!delta.is_empty(), "delta should contain changes");
|
||||
|
||||
let mut updated = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
updated
|
||||
.apply_update_from_binary_v1(&stub_bin)
|
||||
.expect("apply stub update");
|
||||
updated
|
||||
.apply_update_from_binary_v1(&delta)
|
||||
.expect("apply fallback delta");
|
||||
|
||||
let blocks_map = updated.get_map("blocks").expect("blocks map exists");
|
||||
let index = build_block_index(&blocks_map);
|
||||
let page_id = find_block_id_by_flavour(&index.block_pool, PAGE_FLAVOUR).expect("page block exists");
|
||||
let page = index.block_pool.get(&page_id).expect("page map exists").clone();
|
||||
|
||||
let note_id = find_child_id_by_flavour(&page, &index.block_pool, NOTE_FLAVOUR).expect("note child exists");
|
||||
let note = index.block_pool.get(¬e_id).expect("note block exists").clone();
|
||||
assert!(
|
||||
!collect_child_ids(¬e).is_empty(),
|
||||
"note should contain imported content blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_ydoc_fallback_when_note_missing() {
|
||||
let doc_id = "stub-note-missing";
|
||||
let markdown = "# Title\n\nUpdated content.";
|
||||
|
||||
// Build a stub doc that has an `affine:page` block but doesn't contain a note
|
||||
// child.
|
||||
let doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
let mut blocks_map = doc.get_or_create_map("blocks").expect("create blocks map");
|
||||
let page_id = "page-1";
|
||||
let mut page = insert_block_map(&doc, &mut blocks_map, page_id).expect("insert page");
|
||||
insert_sys_fields(&mut page, page_id, PAGE_FLAVOUR).expect("sys fields");
|
||||
insert_children(&doc, &mut page, &[]).expect("children");
|
||||
|
||||
let stub_bin = doc
|
||||
.encode_state_as_update_v1(&StateVector::default())
|
||||
.expect("encode stub update");
|
||||
assert!(!stub_bin.is_empty(), "stub update should not be empty");
|
||||
|
||||
let delta = update_doc(&stub_bin, markdown, doc_id).expect("fallback delta");
|
||||
assert!(!delta.is_empty(), "delta should contain changes");
|
||||
|
||||
let mut updated = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
updated
|
||||
.apply_update_from_binary_v1(&stub_bin)
|
||||
.expect("apply stub update");
|
||||
updated
|
||||
.apply_update_from_binary_v1(&delta)
|
||||
.expect("apply fallback delta");
|
||||
|
||||
let blocks_map = updated.get_map("blocks").expect("blocks map exists");
|
||||
let index = build_block_index(&blocks_map);
|
||||
let page_id = find_block_id_by_flavour(&index.block_pool, PAGE_FLAVOUR).expect("page block exists");
|
||||
let page = index.block_pool.get(&page_id).expect("page map exists").clone();
|
||||
|
||||
let note_id = find_child_id_by_flavour(&page, &index.block_pool, NOTE_FLAVOUR).expect("note child exists");
|
||||
let note = index.block_pool.get(¬e_id).expect("note block exists").clone();
|
||||
assert!(
|
||||
!collect_child_ids(¬e).is_empty(),
|
||||
"note should contain imported content blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_ydoc_preserves_opaque_blocks_when_unsupported_block_flavour() {
|
||||
let doc_id = "unsupported-flavour-replace";
|
||||
|
||||
// Build a doc with canonical page/note structure, but add an unsupported block
|
||||
// flavour under note. This simulates real-world docs that contain blocks we
|
||||
// don't support for structural diffing.
|
||||
let doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
let mut blocks_map = doc.get_or_create_map("blocks").expect("create blocks map");
|
||||
|
||||
let page_id = "page-1";
|
||||
let surface_id = "surface-1";
|
||||
let note_id = "note-1";
|
||||
let db_id = "db-1";
|
||||
|
||||
let mut page = insert_block_map(&doc, &mut blocks_map, page_id).expect("insert page");
|
||||
let mut surface = insert_block_map(&doc, &mut blocks_map, surface_id).expect("insert surface");
|
||||
let mut note = insert_block_map(&doc, &mut blocks_map, note_id).expect("insert note");
|
||||
let mut db = insert_block_map(&doc, &mut blocks_map, db_id).expect("insert db");
|
||||
|
||||
insert_sys_fields(&mut page, page_id, PAGE_FLAVOUR).expect("page sys fields");
|
||||
insert_children(&doc, &mut page, &[surface_id.to_string(), note_id.to_string()]).expect("page children");
|
||||
insert_text(&doc, &mut page, PROP_TITLE, &text_ops_from_plain("Title")).expect("page title");
|
||||
|
||||
insert_sys_fields(&mut surface, surface_id, SURFACE_FLAVOUR).expect("surface sys fields");
|
||||
insert_children(&doc, &mut surface, &[]).expect("surface children");
|
||||
let mut boxed = boxed_empty_map(&doc).expect("boxed map");
|
||||
surface
|
||||
.insert(PROP_ELEMENTS.to_string(), Value::Map(boxed.clone()))
|
||||
.expect("surface elements");
|
||||
boxed
|
||||
.insert("type".to_string(), Any::String(BOXED_NATIVE_TYPE.to_string()))
|
||||
.expect("boxed type");
|
||||
let value = doc.create_map().expect("boxed value map");
|
||||
boxed
|
||||
.insert("value".to_string(), Value::Map(value))
|
||||
.expect("boxed value");
|
||||
|
||||
insert_sys_fields(&mut note, note_id, NOTE_FLAVOUR).expect("note sys fields");
|
||||
insert_children(&doc, &mut note, &[db_id.to_string()]).expect("note children");
|
||||
|
||||
// Unsupported flavour.
|
||||
insert_sys_fields(&mut db, db_id, "affine:database").expect("db sys fields");
|
||||
insert_children(&doc, &mut db, &[]).expect("db children");
|
||||
|
||||
let initial_bin = doc
|
||||
.encode_state_as_update_v1(&StateVector::default())
|
||||
.expect("encode initial");
|
||||
|
||||
// Updating should succeed and preserve the opaque block rather than deleting
|
||||
// it.
|
||||
let updated_md = "# New Title\n\nHello.";
|
||||
let delta = update_doc(&initial_bin, updated_md, doc_id).expect("delta");
|
||||
assert!(!delta.is_empty(), "delta should contain changes");
|
||||
|
||||
let mut updated_doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
updated_doc
|
||||
.apply_update_from_binary_v1(&initial_bin)
|
||||
.expect("apply initial");
|
||||
updated_doc.apply_update_from_binary_v1(&delta).expect("apply delta");
|
||||
|
||||
let blocks_map = updated_doc.get_map("blocks").expect("blocks map");
|
||||
assert!(
|
||||
blocks_map.get(db_id).is_some(),
|
||||
"opaque block should be preserved when syncing from markdown"
|
||||
);
|
||||
|
||||
let md = parse_doc_to_markdown(updated_doc.encode_update_v1().unwrap(), doc_id.to_string(), false, None)
|
||||
.expect("render markdown")
|
||||
.markdown;
|
||||
assert!(md.contains("Hello."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_ydoc_markdown_too_large() {
|
||||
let initial_md = "# Title\n\nContent.";
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"./broadcast-channel": "./src/impls/broadcast-channel/index.ts",
|
||||
"./idb/v1": "./src/impls/idb/v1/index.ts",
|
||||
"./cloud": "./src/impls/cloud/index.ts",
|
||||
"./disk": "./src/impls/disk/index.ts",
|
||||
"./sqlite": "./src/impls/sqlite/index.ts",
|
||||
"./sqlite/v1": "./src/impls/sqlite/v1/index.ts",
|
||||
"./sync": "./src/sync/index.ts",
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { AutoReconnectConnection } from '../../connection';
|
||||
import type { DocClock, DocUpdate } from '../../storage';
|
||||
import { type SpaceType, universalId } from '../../utils/universal-id';
|
||||
|
||||
export interface DiskSessionOptions {
|
||||
workspaceId: string;
|
||||
syncFolder: string;
|
||||
}
|
||||
|
||||
export type DiskSyncEvent =
|
||||
| { type: 'ready' }
|
||||
| {
|
||||
type: 'doc-update';
|
||||
update: {
|
||||
docId: string;
|
||||
bin: Uint8Array;
|
||||
timestamp: Date;
|
||||
editor?: string;
|
||||
};
|
||||
origin?: string;
|
||||
}
|
||||
| { type: 'doc-delete'; docId: string; timestamp: Date }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export interface DiskSyncApis {
|
||||
startSession: (
|
||||
sessionId: string,
|
||||
options: DiskSessionOptions
|
||||
) => Promise<void>;
|
||||
stopSession: (sessionId: string) => Promise<void>;
|
||||
applyLocalUpdate: (
|
||||
sessionId: string,
|
||||
update: DocUpdate,
|
||||
origin?: string
|
||||
) => Promise<DocClock>;
|
||||
subscribeEvents: (
|
||||
sessionId: string,
|
||||
callback: (event: DiskSyncEvent) => void
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
interface DiskSyncOptions {
|
||||
readonly flavour: string;
|
||||
readonly type: SpaceType;
|
||||
readonly id: string;
|
||||
readonly syncFolder: string;
|
||||
}
|
||||
|
||||
interface DiskSyncApisWrapper {
|
||||
startSession: (options: DiskSessionOptions) => Promise<void>;
|
||||
stopSession: () => Promise<void>;
|
||||
applyLocalUpdate: (update: DocUpdate, origin?: string) => Promise<DocClock>;
|
||||
subscribeEvents: (callback: (event: DiskSyncEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
let apis: DiskSyncApis | null = null;
|
||||
|
||||
export function bindDiskSyncApis(a: DiskSyncApis) {
|
||||
apis = a;
|
||||
}
|
||||
|
||||
export class DiskSyncConnection extends AutoReconnectConnection<{
|
||||
unsubscribe: () => void;
|
||||
}> {
|
||||
readonly apis: DiskSyncApisWrapper;
|
||||
readonly sessionId: string;
|
||||
|
||||
readonly flavour = this.options.flavour;
|
||||
readonly type = this.options.type;
|
||||
readonly id = this.options.id;
|
||||
|
||||
constructor(
|
||||
private readonly options: DiskSyncOptions,
|
||||
private readonly onEvent: (event: DiskSyncEvent) => void
|
||||
) {
|
||||
super();
|
||||
if (!apis) {
|
||||
throw new Error('Not in native context.');
|
||||
}
|
||||
this.sessionId = universalId({
|
||||
peer: this.flavour,
|
||||
type: this.type,
|
||||
id: this.id,
|
||||
});
|
||||
this.apis = this.wrapApis(apis);
|
||||
}
|
||||
|
||||
override get shareId(): string {
|
||||
return `disk:${this.sessionId}:${this.options.syncFolder}`;
|
||||
}
|
||||
|
||||
private wrapApis(originalApis: DiskSyncApis): DiskSyncApisWrapper {
|
||||
const sessionId = this.sessionId;
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, key: keyof DiskSyncApisWrapper) => {
|
||||
const method = originalApis[key];
|
||||
return (...args: unknown[]) => {
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (method as any)(sessionId, ...args);
|
||||
};
|
||||
},
|
||||
}
|
||||
) as DiskSyncApisWrapper;
|
||||
}
|
||||
|
||||
override async doConnect() {
|
||||
await this.apis.startSession({
|
||||
workspaceId: this.id,
|
||||
syncFolder: this.options.syncFolder,
|
||||
});
|
||||
const unsubscribe = this.apis.subscribeEvents(this.onEvent);
|
||||
return { unsubscribe };
|
||||
}
|
||||
|
||||
override doDisconnect(conn: { unsubscribe: () => void }) {
|
||||
try {
|
||||
conn.unsubscribe();
|
||||
} catch (error) {
|
||||
console.error('DiskSyncConnection unsubscribe failed', error);
|
||||
}
|
||||
this.apis.stopSession().catch(error => {
|
||||
console.error('DiskSyncConnection stopSession failed', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '../../../../../../');
|
||||
|
||||
const JS_BOUNDARY_FILES = [
|
||||
path.join(PROJECT_ROOT, 'packages/common/nbstore/src/impls/disk/doc.ts'),
|
||||
path.join(
|
||||
PROJECT_ROOT,
|
||||
'packages/frontend/apps/electron/src/helper/disk-sync/handlers.ts'
|
||||
),
|
||||
];
|
||||
|
||||
const FORBIDDEN_PATTERNS = [
|
||||
/frontmatter/i,
|
||||
/gray-matter/i,
|
||||
/MarkdownAdapter/,
|
||||
/markdownToSnapshot/,
|
||||
/fromMarkdown/,
|
||||
/toMarkdown/,
|
||||
];
|
||||
|
||||
describe('disk boundary', () => {
|
||||
it('keeps markdown/frontmatter parsing out of JS adapter layer', () => {
|
||||
for (const file of JS_BOUNDARY_FILES) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
|
||||
for (const pattern of FORBIDDEN_PATTERNS) {
|
||||
expect(content).not.toMatch(pattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps JS layer focused on session orchestration APIs', () => {
|
||||
const adapter = fs.readFileSync(JS_BOUNDARY_FILES[0], 'utf-8');
|
||||
expect(adapter).toMatch(/applyLocalUpdate/);
|
||||
|
||||
const helper = fs.readFileSync(JS_BOUNDARY_FILES[1], 'utf-8');
|
||||
expect(helper).toMatch(/startSession/);
|
||||
expect(helper).toMatch(/stopSession/);
|
||||
expect(helper).toMatch(/applyLocalUpdate/);
|
||||
});
|
||||
});
|
||||
@@ -1,451 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
applyUpdate,
|
||||
Array as YArray,
|
||||
Doc as YDoc,
|
||||
encodeStateAsUpdate,
|
||||
Map as YMap,
|
||||
} from 'yjs';
|
||||
|
||||
import { universalId } from '../../utils/universal-id';
|
||||
import { bindDiskSyncApis, type DiskSyncApis, type DiskSyncEvent } from './api';
|
||||
import { DiskDocStorage } from './doc';
|
||||
|
||||
function createUpdate(text: string): Uint8Array {
|
||||
const doc = new YDoc();
|
||||
doc.getText('content').insert(0, text);
|
||||
return encodeStateAsUpdate(doc);
|
||||
}
|
||||
|
||||
function createMapUpdate(entries: Record<string, string>): Uint8Array {
|
||||
const doc = new YDoc();
|
||||
const map = doc.getMap('test');
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
map.set(key, value);
|
||||
}
|
||||
return encodeStateAsUpdate(doc);
|
||||
}
|
||||
|
||||
function createRootMetaUpdate(docIds: string[]): Uint8Array {
|
||||
const doc = new YDoc();
|
||||
const meta = doc.getMap('meta');
|
||||
const pages = new YArray<YMap<unknown>>();
|
||||
for (const docId of docIds) {
|
||||
const page = new YMap<unknown>();
|
||||
page.set('id', docId);
|
||||
pages.push([page]);
|
||||
}
|
||||
meta.set('pages', pages);
|
||||
return encodeStateAsUpdate(doc);
|
||||
}
|
||||
|
||||
describe('DiskDocStorage', () => {
|
||||
const sessionId = universalId({
|
||||
peer: 'local',
|
||||
type: 'workspace',
|
||||
id: 'workspace-test',
|
||||
});
|
||||
const listeners = new Map<string, Set<(event: DiskSyncEvent) => void>>();
|
||||
|
||||
const startSession = vi.fn(
|
||||
async (_sessionId: string, _options: { workspaceId: string }) => {}
|
||||
);
|
||||
const stopSession = vi.fn(async (_sessionId: string) => {});
|
||||
const applyLocalUpdate = vi.fn(
|
||||
async (_sessionId: string, update: { docId: string }) => {
|
||||
return {
|
||||
docId: update.docId,
|
||||
timestamp: new Date('2026-01-02T00:00:00.000Z'),
|
||||
};
|
||||
}
|
||||
);
|
||||
const subscribeEvents = vi.fn(
|
||||
(currentSessionId: string, callback: (event: DiskSyncEvent) => void) => {
|
||||
let set = listeners.get(currentSessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(currentSessionId, set);
|
||||
}
|
||||
set.add(callback);
|
||||
return () => {
|
||||
set?.delete(callback);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const apis: DiskSyncApis = {
|
||||
startSession,
|
||||
stopSession,
|
||||
applyLocalUpdate,
|
||||
subscribeEvents,
|
||||
};
|
||||
|
||||
function emit(event: DiskSyncEvent) {
|
||||
const callbacks = listeners.get(sessionId);
|
||||
for (const callback of callbacks ?? []) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
function createStorage() {
|
||||
return new DiskDocStorage({
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
id: 'workspace-test',
|
||||
syncFolder: '/tmp/sync',
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bindDiskSyncApis(apis);
|
||||
listeners.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listeners.clear();
|
||||
});
|
||||
|
||||
it('starts and stops disk session with connection lifecycle', async () => {
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
expect(startSession).toHaveBeenCalledWith(sessionId, {
|
||||
workspaceId: 'workspace-test',
|
||||
syncFolder: '/tmp/sync',
|
||||
});
|
||||
|
||||
storage.connection.disconnect();
|
||||
await vi.waitFor(() => {
|
||||
expect(stopSession).toHaveBeenCalledWith(sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards local updates and emits doc update events', async () => {
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
const seen: Array<{ docId: string; origin?: string }> = [];
|
||||
const unsubscribe = storage.subscribeDocUpdate((update, origin) => {
|
||||
seen.push({ docId: update.docId, origin });
|
||||
});
|
||||
|
||||
const bin = createUpdate('local');
|
||||
await storage.pushDocUpdate({ docId: 'doc-local', bin }, 'origin:local');
|
||||
|
||||
expect(applyLocalUpdate).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
expect.objectContaining({
|
||||
docId: 'doc-local',
|
||||
}),
|
||||
'origin:local'
|
||||
);
|
||||
expect(seen).toEqual([{ docId: 'doc-local', origin: 'origin:local' }]);
|
||||
|
||||
const snapshot = await storage.getDoc('doc-local');
|
||||
expect(snapshot?.docId).toBe('doc-local');
|
||||
expect(snapshot?.timestamp.toISOString()).toBe('2026-01-02T00:00:00.000Z');
|
||||
|
||||
unsubscribe();
|
||||
storage.connection.disconnect();
|
||||
});
|
||||
|
||||
it('applies remote events into local snapshots and handles delete events', async () => {
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
emit({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: 'doc-remote',
|
||||
bin: createUpdate('remote'),
|
||||
timestamp: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const snapshot = await storage.getDoc('doc-remote');
|
||||
expect(snapshot?.docId).toBe('doc-remote');
|
||||
});
|
||||
|
||||
const timestamps = await storage.getDocTimestamps();
|
||||
expect(timestamps['doc-remote']?.toISOString()).toBe(
|
||||
'2026-01-03T00:00:00.000Z'
|
||||
);
|
||||
|
||||
emit({
|
||||
type: 'doc-delete',
|
||||
docId: 'doc-remote',
|
||||
timestamp: new Date('2026-01-03T00:00:01.000Z'),
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await storage.getDoc('doc-remote')).toBeNull();
|
||||
});
|
||||
|
||||
storage.connection.disconnect();
|
||||
});
|
||||
|
||||
it('serializes concurrent remote doc-update merges for the same doc', async () => {
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
const originalMergeUpdates = (
|
||||
storage as unknown as {
|
||||
mergeUpdates: (updates: Uint8Array[]) => Promise<Uint8Array>;
|
||||
}
|
||||
).mergeUpdates.bind(storage);
|
||||
|
||||
let mergeCall = 0;
|
||||
vi.spyOn(
|
||||
storage as unknown as {
|
||||
mergeUpdates: (updates: Uint8Array[]) => Promise<Uint8Array>;
|
||||
},
|
||||
'mergeUpdates'
|
||||
).mockImplementation(async updates => {
|
||||
mergeCall += 1;
|
||||
// Force two in-flight merge operations to overlap and complete out-of-order.
|
||||
if (mergeCall === 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
return originalMergeUpdates(updates);
|
||||
});
|
||||
|
||||
emit({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: 'doc-race',
|
||||
bin: createMapUpdate({ first: '1' }),
|
||||
timestamp: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
emit({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: 'doc-race',
|
||||
bin: createMapUpdate({ second: '2' }),
|
||||
timestamp: new Date('2026-01-03T00:00:00.001Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const snapshot = await storage.getDoc('doc-race');
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot!.timestamp.toISOString()).toBe(
|
||||
'2026-01-03T00:00:00.001Z'
|
||||
);
|
||||
|
||||
const doc = new YDoc();
|
||||
applyUpdate(doc, snapshot!.bin);
|
||||
expect(doc.getMap('test').toJSON()).toEqual({
|
||||
first: '1',
|
||||
second: '2',
|
||||
});
|
||||
});
|
||||
|
||||
storage.connection.disconnect();
|
||||
});
|
||||
|
||||
it('does not block follow-up updates when snapshot merge fails once', async () => {
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
const originalMergeUpdates = (
|
||||
storage as unknown as {
|
||||
mergeUpdates: (updates: Uint8Array[]) => Promise<Uint8Array>;
|
||||
}
|
||||
).mergeUpdates.bind(storage);
|
||||
|
||||
let mergeCall = 0;
|
||||
vi.spyOn(
|
||||
storage as unknown as {
|
||||
mergeUpdates: (updates: Uint8Array[]) => Promise<Uint8Array>;
|
||||
},
|
||||
'mergeUpdates'
|
||||
).mockImplementation(async updates => {
|
||||
mergeCall += 1;
|
||||
if (mergeCall === 1) {
|
||||
throw new Error('merge failed once');
|
||||
}
|
||||
return originalMergeUpdates(updates);
|
||||
});
|
||||
|
||||
await expect(
|
||||
storage.pushDocUpdate({
|
||||
docId: 'doc-merge-fallback',
|
||||
bin: createMapUpdate({ a: '1' }),
|
||||
})
|
||||
).resolves.toEqual({
|
||||
docId: 'doc-merge-fallback',
|
||||
timestamp: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
// This update triggers the mocked merge failure, but should still resolve.
|
||||
await expect(
|
||||
storage.pushDocUpdate({
|
||||
docId: 'doc-merge-fallback',
|
||||
bin: createMapUpdate({ b: '2' }),
|
||||
})
|
||||
).resolves.toEqual({
|
||||
docId: 'doc-merge-fallback',
|
||||
timestamp: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
// Follow-up update should continue to work without requiring reconnect/reload.
|
||||
await expect(
|
||||
storage.pushDocUpdate({
|
||||
docId: 'doc-merge-fallback',
|
||||
bin: createMapUpdate({ c: '3' }),
|
||||
})
|
||||
).resolves.toEqual({
|
||||
docId: 'doc-merge-fallback',
|
||||
timestamp: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const snapshot = await storage.getDoc('doc-merge-fallback');
|
||||
expect(snapshot).not.toBeNull();
|
||||
const doc = new YDoc();
|
||||
applyUpdate(doc, snapshot!.bin);
|
||||
const data = doc.getMap('test').toJSON();
|
||||
expect(data).toMatchObject({
|
||||
b: '2',
|
||||
c: '3',
|
||||
});
|
||||
|
||||
storage.connection.disconnect();
|
||||
});
|
||||
|
||||
it('accepts remote doc-update bins as number[] (from native binding)', async () => {
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
const original = createUpdate('remote-array');
|
||||
const bin = Array.from(original) as unknown as Uint8Array;
|
||||
|
||||
emit({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: 'doc-remote-array',
|
||||
bin,
|
||||
timestamp: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const snapshot = await storage.getDoc('doc-remote-array');
|
||||
expect(snapshot).not.toBeNull();
|
||||
|
||||
const doc = new YDoc();
|
||||
applyUpdate(doc, snapshot!.bin);
|
||||
expect(doc.getText('content').toString()).toBe('remote-array');
|
||||
});
|
||||
|
||||
storage.connection.disconnect();
|
||||
});
|
||||
|
||||
it('throws when applyLocalUpdate returns invalid timestamp', async () => {
|
||||
applyLocalUpdate.mockResolvedValueOnce({
|
||||
docId: 'doc-invalid-clock',
|
||||
timestamp: new Date('invalid'),
|
||||
});
|
||||
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
await expect(
|
||||
storage.pushDocUpdate({
|
||||
docId: 'doc-invalid-clock',
|
||||
bin: createUpdate('invalid'),
|
||||
})
|
||||
).rejects.toThrow('[disk] invalid timestamp');
|
||||
|
||||
storage.connection.disconnect();
|
||||
});
|
||||
|
||||
it('skips remote doc-update with invalid timestamp', async () => {
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
emit({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: 'doc-invalid-remote-clock',
|
||||
bin: createUpdate('remote-invalid'),
|
||||
timestamp: new Date('invalid') as unknown as Date,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await storage.getDoc('doc-invalid-remote-clock')).toBeNull();
|
||||
});
|
||||
|
||||
storage.connection.disconnect();
|
||||
});
|
||||
|
||||
it('discovers doc ids from root meta and emits connect-driving updates once', async () => {
|
||||
const storage = createStorage();
|
||||
storage.connection.connect();
|
||||
await storage.connection.waitForConnected();
|
||||
|
||||
const seen: Array<{ docId: string; origin?: string; size: number }> = [];
|
||||
const unsubscribe = storage.subscribeDocUpdate((update, origin) => {
|
||||
seen.push({
|
||||
docId: update.docId,
|
||||
origin,
|
||||
size: update.bin.byteLength,
|
||||
});
|
||||
});
|
||||
|
||||
const rootUpdate = createRootMetaUpdate(['doc-a', 'doc-b']);
|
||||
|
||||
await storage.pushDocUpdate(
|
||||
{
|
||||
docId: 'workspace-test',
|
||||
bin: rootUpdate,
|
||||
},
|
||||
'origin:root'
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const discovered = seen.filter(
|
||||
item => item.origin === 'disk:root-meta-discovery'
|
||||
);
|
||||
expect(discovered).toHaveLength(2);
|
||||
});
|
||||
|
||||
const discoveredDocIds = seen
|
||||
.filter(item => item.origin === 'disk:root-meta-discovery')
|
||||
.map(item => item.docId)
|
||||
.sort();
|
||||
expect(discoveredDocIds).toEqual(['doc-a', 'doc-b']);
|
||||
expect(
|
||||
seen
|
||||
.filter(item => item.origin === 'disk:root-meta-discovery')
|
||||
.every(item => item.size === 0)
|
||||
).toBe(true);
|
||||
|
||||
await storage.pushDocUpdate(
|
||||
{
|
||||
docId: 'workspace-test',
|
||||
bin: rootUpdate,
|
||||
},
|
||||
'origin:root'
|
||||
);
|
||||
|
||||
const discoveryCountAfterSecondPush = seen.filter(
|
||||
item => item.origin === 'disk:root-meta-discovery'
|
||||
).length;
|
||||
expect(discoveryCountAfterSecondPush).toBe(2);
|
||||
|
||||
unsubscribe();
|
||||
storage.connection.disconnect();
|
||||
});
|
||||
});
|
||||
@@ -1,291 +0,0 @@
|
||||
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||
|
||||
import {
|
||||
type DocClock,
|
||||
type DocClocks,
|
||||
type DocRecord,
|
||||
DocStorageBase,
|
||||
type DocUpdate,
|
||||
} from '../../storage';
|
||||
import { type SpaceType } from '../../utils/universal-id';
|
||||
import { DiskSyncConnection, type DiskSyncEvent } from './api';
|
||||
|
||||
export interface DiskDocStorageOptions {
|
||||
readonly flavour: string;
|
||||
readonly type: SpaceType;
|
||||
readonly id: string;
|
||||
readonly syncFolder: string;
|
||||
}
|
||||
|
||||
export class DiskDocStorage extends DocStorageBase<DiskDocStorageOptions> {
|
||||
static readonly identifier = 'DiskDocStorage';
|
||||
|
||||
readonly connection: DiskSyncConnection;
|
||||
|
||||
private readonly snapshots = new Map<string, DocRecord>();
|
||||
private readonly pendingUpdates = new Map<string, DocRecord[]>();
|
||||
private readonly discoveredRootDocs = new Set<string>();
|
||||
|
||||
constructor(options: DiskDocStorageOptions) {
|
||||
super(options);
|
||||
this.connection = new DiskSyncConnection(options, this.handleDiskEvent);
|
||||
}
|
||||
|
||||
override async pushDocUpdate(update: DocUpdate, origin?: string) {
|
||||
const { timestamp } = await this.connection.apis.applyLocalUpdate(
|
||||
update,
|
||||
origin
|
||||
);
|
||||
const clock = normalizeDate(timestamp);
|
||||
const next: DocRecord = {
|
||||
docId: update.docId,
|
||||
bin: update.bin,
|
||||
timestamp: clock,
|
||||
editor: update.editor,
|
||||
};
|
||||
await this.applySnapshotUpdate(next, origin);
|
||||
return { docId: update.docId, timestamp: clock };
|
||||
}
|
||||
|
||||
override async getDocTimestamp(docId: string): Promise<DocClock | null> {
|
||||
const snapshot = this.snapshots.get(docId);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
docId,
|
||||
timestamp: snapshot.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
override async getDocTimestamps(after?: Date): Promise<DocClocks> {
|
||||
const timestamps: DocClocks = {};
|
||||
for (const [docId, snapshot] of this.snapshots.entries()) {
|
||||
if (after && snapshot.timestamp.getTime() <= after.getTime()) {
|
||||
continue;
|
||||
}
|
||||
timestamps[docId] = snapshot.timestamp;
|
||||
}
|
||||
return timestamps;
|
||||
}
|
||||
|
||||
override async deleteDoc(docId: string): Promise<void> {
|
||||
this.snapshots.delete(docId);
|
||||
this.pendingUpdates.delete(docId);
|
||||
}
|
||||
|
||||
protected override async getDocSnapshot(docId: string) {
|
||||
return this.snapshots.get(docId) ?? null;
|
||||
}
|
||||
|
||||
protected override async setDocSnapshot(
|
||||
snapshot: DocRecord
|
||||
): Promise<boolean> {
|
||||
const existing = this.snapshots.get(snapshot.docId);
|
||||
if (
|
||||
existing &&
|
||||
existing.timestamp.getTime() > snapshot.timestamp.getTime()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.snapshots.set(snapshot.docId, snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override async getDocUpdates(docId: string): Promise<DocRecord[]> {
|
||||
return this.pendingUpdates.get(docId) ?? [];
|
||||
}
|
||||
|
||||
protected override async markUpdatesMerged(
|
||||
docId: string,
|
||||
updates: DocRecord[]
|
||||
): Promise<number> {
|
||||
if (updates.length) {
|
||||
this.pendingUpdates.delete(docId);
|
||||
}
|
||||
return updates.length;
|
||||
}
|
||||
|
||||
private readonly handleDiskEvent = (event: DiskSyncEvent) => {
|
||||
switch (event.type) {
|
||||
case 'doc-update': {
|
||||
let timestamp: Date;
|
||||
try {
|
||||
timestamp = normalizeDate(event.update.timestamp);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[disk] invalid doc-update timestamp, skip event',
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let bin: Uint8Array;
|
||||
try {
|
||||
bin = normalizeBin(event.update.bin);
|
||||
} catch (error) {
|
||||
console.warn('[disk] invalid doc-update bin, skip event', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const update: DocRecord = {
|
||||
docId: event.update.docId,
|
||||
bin,
|
||||
timestamp,
|
||||
editor: event.update.editor,
|
||||
};
|
||||
void this.applySnapshotUpdate(update, event.origin).catch(error => {
|
||||
console.warn(
|
||||
'[disk] failed to apply remote doc-update, skip event',
|
||||
error
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'doc-delete': {
|
||||
this.snapshots.delete(event.docId);
|
||||
this.pendingUpdates.delete(event.docId);
|
||||
return;
|
||||
}
|
||||
case 'error': {
|
||||
console.warn('[disk] session error', event.message);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private async applySnapshotUpdate(update: DocRecord, origin?: string) {
|
||||
await using _lock = await this.lockDocForUpdate(update.docId);
|
||||
try {
|
||||
await this.mergeIntoSnapshot(update);
|
||||
} catch (error) {
|
||||
// Snapshot cache is best-effort. A merge failure must not block upstream sync
|
||||
// forever (it can otherwise require a full app reload to recover).
|
||||
console.warn(
|
||||
'[disk] snapshot merge failed, reset in-memory snapshot cache',
|
||||
error
|
||||
);
|
||||
this.snapshots.set(update.docId, update);
|
||||
}
|
||||
this.emit('update', update, origin);
|
||||
if (update.docId === this.spaceId) {
|
||||
this.emitRootMetaDiscoveryUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
private async mergeIntoSnapshot(update: DocRecord) {
|
||||
const current = this.snapshots.get(update.docId);
|
||||
if (!current) {
|
||||
this.snapshots.set(update.docId, update);
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = await this.mergeUpdates([current.bin, update.bin]);
|
||||
this.snapshots.set(update.docId, {
|
||||
...update,
|
||||
bin: merged,
|
||||
timestamp:
|
||||
current.timestamp.getTime() > update.timestamp.getTime()
|
||||
? current.timestamp
|
||||
: update.timestamp,
|
||||
editor: update.editor ?? current.editor,
|
||||
});
|
||||
}
|
||||
|
||||
private emitRootMetaDiscoveryUpdates() {
|
||||
const rootSnapshot = this.snapshots.get(this.spaceId);
|
||||
if (!rootSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const docIds = extractRootMetaDocIds(rootSnapshot.bin);
|
||||
// These discovery events are only meant to "introduce" doc ids to the sync
|
||||
// peer, so it can connect/pull/push them. They should NOT be treated as a
|
||||
// remote clock; otherwise switching sync folders (remote empty) can be
|
||||
// incorrectly seen as "remote newer than local" and skip the initial push.
|
||||
const discoveryTimestamp = new Date(0);
|
||||
for (const docId of docIds) {
|
||||
if (docId === this.spaceId || this.discoveredRootDocs.has(docId)) {
|
||||
continue;
|
||||
}
|
||||
this.discoveredRootDocs.add(docId);
|
||||
this.emit(
|
||||
'update',
|
||||
{
|
||||
docId,
|
||||
bin: new Uint8Array(),
|
||||
timestamp: discoveryTimestamp,
|
||||
},
|
||||
'disk:root-meta-discovery'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDate(date: Date | string | number): Date {
|
||||
const normalized = date instanceof Date ? date : new Date(date);
|
||||
if (Number.isNaN(normalized.getTime())) {
|
||||
throw new Error(`[disk] invalid timestamp: ${String(date)}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractRootMetaDocIds(rootBin: Uint8Array): string[] {
|
||||
const doc = new YDoc();
|
||||
try {
|
||||
applyUpdate(doc, rootBin);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const meta = doc.getMap<unknown>('meta');
|
||||
const pages = meta.get('pages');
|
||||
const pagesJson =
|
||||
typeof pages === 'object' &&
|
||||
pages !== null &&
|
||||
'toJSON' in pages &&
|
||||
typeof pages.toJSON === 'function'
|
||||
? pages.toJSON()
|
||||
: pages;
|
||||
|
||||
if (!Array.isArray(pagesJson)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docIds: string[] = [];
|
||||
for (const page of pagesJson) {
|
||||
if (!page || typeof page !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const id = (page as { id?: unknown }).id;
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
docIds.push(id);
|
||||
}
|
||||
}
|
||||
return docIds;
|
||||
}
|
||||
|
||||
function normalizeBin(bin: unknown): Uint8Array {
|
||||
// Native NAPI binding may send `number[]` for `Vec<u8>` fields.
|
||||
if (bin instanceof Uint8Array) {
|
||||
return bin;
|
||||
}
|
||||
if (Array.isArray(bin)) {
|
||||
return Uint8Array.from(bin);
|
||||
}
|
||||
// Some transports may serialize Buffer as `{ type: 'Buffer', data: number[] }`.
|
||||
if (
|
||||
bin &&
|
||||
typeof bin === 'object' &&
|
||||
'data' in bin &&
|
||||
Array.isArray((bin as { data?: unknown }).data)
|
||||
) {
|
||||
return Uint8Array.from((bin as { data: number[] }).data);
|
||||
}
|
||||
throw new Error(
|
||||
`[disk] invalid update bin type: ${Object.prototype.toString.call(bin)}`
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { StorageConstructor } from '..';
|
||||
import { DiskDocStorage } from './doc';
|
||||
|
||||
export * from './api';
|
||||
export * from './doc';
|
||||
|
||||
export const diskStorages = [DiskDocStorage] satisfies StorageConstructor[];
|
||||
@@ -1,378 +0,0 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { expectYjsEqual } from '../../__tests__/utils';
|
||||
import { SpaceStorage } from '../../storage';
|
||||
import { Sync } from '../../sync';
|
||||
import { universalId } from '../../utils/universal-id';
|
||||
import { IndexedDBDocStorage, IndexedDBDocSyncStorage } from '../idb';
|
||||
import { bindDiskSyncApis, type DiskSyncApis, type DiskSyncEvent } from './api';
|
||||
import { DiskDocStorage } from './doc';
|
||||
|
||||
test('sync local <-> disk remote updates through DocSyncPeer', async () => {
|
||||
const workspaceId = 'ws-disk-integration';
|
||||
const sessionId = universalId({
|
||||
peer: 'local',
|
||||
type: 'workspace',
|
||||
id: workspaceId,
|
||||
});
|
||||
|
||||
const listeners = new Map<string, Set<(event: DiskSyncEvent) => void>>();
|
||||
const remoteDocs = new Map<string, { timestamp: Date; bin: Uint8Array }>();
|
||||
|
||||
const apis: DiskSyncApis = {
|
||||
startSession: async currentSessionId => {
|
||||
if (!listeners.has(currentSessionId)) {
|
||||
listeners.set(currentSessionId, new Set());
|
||||
}
|
||||
},
|
||||
stopSession: async currentSessionId => {
|
||||
listeners.delete(currentSessionId);
|
||||
},
|
||||
applyLocalUpdate: async (currentSessionId, update) => {
|
||||
const timestamp = new Date();
|
||||
remoteDocs.set(update.docId, { timestamp, bin: update.bin });
|
||||
for (const callback of listeners.get(currentSessionId) ?? []) {
|
||||
callback({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: update.docId,
|
||||
bin: update.bin,
|
||||
timestamp,
|
||||
},
|
||||
origin: 'sync:disk-mock',
|
||||
});
|
||||
}
|
||||
return {
|
||||
docId: update.docId,
|
||||
timestamp,
|
||||
};
|
||||
},
|
||||
subscribeEvents: (currentSessionId, callback) => {
|
||||
let set = listeners.get(currentSessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(currentSessionId, set);
|
||||
}
|
||||
set.add(callback);
|
||||
return () => {
|
||||
set?.delete(callback);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
bindDiskSyncApis(apis);
|
||||
|
||||
const localDoc = new IndexedDBDocStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
});
|
||||
const localDocSync = new IndexedDBDocSyncStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remoteDoc = new DiskDocStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
syncFolder: '/tmp/disk-sync',
|
||||
});
|
||||
|
||||
const local = new SpaceStorage({
|
||||
doc: localDoc,
|
||||
docSync: localDocSync,
|
||||
});
|
||||
const remote = new SpaceStorage({
|
||||
doc: remoteDoc,
|
||||
});
|
||||
|
||||
local.connect();
|
||||
remote.connect();
|
||||
await local.waitForConnected();
|
||||
await remote.waitForConnected();
|
||||
|
||||
const sync = new Sync({
|
||||
local,
|
||||
remotes: {
|
||||
disk: remote,
|
||||
},
|
||||
});
|
||||
sync.start();
|
||||
|
||||
const localSource = new YDoc();
|
||||
localSource.getMap('test').set('origin', 'local');
|
||||
await localDoc.pushDocUpdate({
|
||||
docId: 'doc-local',
|
||||
bin: encodeStateAsUpdate(localSource),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(remoteDocs.has('doc-local')).toBe(true);
|
||||
});
|
||||
|
||||
const remoteSource = new YDoc();
|
||||
remoteSource.getMap('test').set('origin', 'remote');
|
||||
remoteSource.getMap('test').set('synced', 'yes');
|
||||
const remoteUpdate = encodeStateAsUpdate(remoteSource);
|
||||
const remoteTimestamp = new Date('2026-01-05T00:00:00.000Z');
|
||||
for (const callback of listeners.get(sessionId) ?? []) {
|
||||
callback({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: 'doc-remote',
|
||||
bin: remoteUpdate,
|
||||
timestamp: remoteTimestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const doc = await localDoc.getDoc('doc-remote');
|
||||
expect(doc).not.toBeNull();
|
||||
expectYjsEqual(doc!.bin, {
|
||||
test: {
|
||||
origin: 'remote',
|
||||
synced: 'yes',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
sync.stop();
|
||||
// Intentionally keep IndexedDB connections open in tests. Disconnecting can
|
||||
// abort in-flight IDB transactions in fake-indexeddb and surface as unhandled
|
||||
// rejections, which makes Vitest fail the run.
|
||||
remote.disconnect();
|
||||
});
|
||||
|
||||
test('forces initial push when disk has stale pushed clocks but remote is empty', async () => {
|
||||
const workspaceId = 'ws-disk-stale-push';
|
||||
|
||||
const listeners = new Map<string, Set<(event: DiskSyncEvent) => void>>();
|
||||
const remoteDocs = new Map<string, { timestamp: Date; bin: Uint8Array }>();
|
||||
|
||||
const apis: DiskSyncApis = {
|
||||
startSession: async currentSessionId => {
|
||||
if (!listeners.has(currentSessionId)) {
|
||||
listeners.set(currentSessionId, new Set());
|
||||
}
|
||||
},
|
||||
stopSession: async currentSessionId => {
|
||||
listeners.delete(currentSessionId);
|
||||
},
|
||||
applyLocalUpdate: async (currentSessionId, update) => {
|
||||
const timestamp = new Date();
|
||||
remoteDocs.set(update.docId, { timestamp, bin: update.bin });
|
||||
for (const callback of listeners.get(currentSessionId) ?? []) {
|
||||
callback({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: update.docId,
|
||||
bin: update.bin,
|
||||
timestamp,
|
||||
},
|
||||
origin: 'sync:disk-mock',
|
||||
});
|
||||
}
|
||||
return {
|
||||
docId: update.docId,
|
||||
timestamp,
|
||||
};
|
||||
},
|
||||
subscribeEvents: (currentSessionId, callback) => {
|
||||
let set = listeners.get(currentSessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(currentSessionId, set);
|
||||
}
|
||||
set.add(callback);
|
||||
return () => {
|
||||
set?.delete(callback);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
bindDiskSyncApis(apis);
|
||||
|
||||
const localDoc = new IndexedDBDocStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
});
|
||||
const localDocSync = new IndexedDBDocSyncStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remoteDoc = new DiskDocStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
syncFolder: '/tmp/disk-sync-stale',
|
||||
});
|
||||
|
||||
const local = new SpaceStorage({
|
||||
doc: localDoc,
|
||||
docSync: localDocSync,
|
||||
});
|
||||
const remote = new SpaceStorage({
|
||||
doc: remoteDoc,
|
||||
});
|
||||
|
||||
local.connect();
|
||||
remote.connect();
|
||||
await local.waitForConnected();
|
||||
await remote.waitForConnected();
|
||||
|
||||
const source = new YDoc();
|
||||
source.getMap('test').set('value', 'local');
|
||||
await localDoc.pushDocUpdate({
|
||||
docId: 'doc-local-stale',
|
||||
bin: encodeStateAsUpdate(source),
|
||||
});
|
||||
|
||||
await localDocSync.setPeerPushedClock('disk', {
|
||||
docId: 'doc-local-stale',
|
||||
timestamp: new Date('2099-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const sync = new Sync({
|
||||
local,
|
||||
remotes: {
|
||||
disk: remote,
|
||||
},
|
||||
});
|
||||
sync.start();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(remoteDocs.has('doc-local-stale')).toBe(true);
|
||||
});
|
||||
|
||||
sync.stop();
|
||||
remote.disconnect();
|
||||
});
|
||||
|
||||
test('root-meta discovery must not block pushing page docs when switching disk folders', async () => {
|
||||
const workspaceId = 'ws-disk-discovery-nonblocking';
|
||||
|
||||
const pageDocId = 'page-doc-1';
|
||||
|
||||
const listeners = new Map<string, Set<(event: DiskSyncEvent) => void>>();
|
||||
const remoteDocs = new Map<string, { timestamp: Date; bin: Uint8Array }>();
|
||||
|
||||
const apis: DiskSyncApis = {
|
||||
startSession: async currentSessionId => {
|
||||
if (!listeners.has(currentSessionId)) {
|
||||
listeners.set(currentSessionId, new Set());
|
||||
}
|
||||
},
|
||||
stopSession: async currentSessionId => {
|
||||
listeners.delete(currentSessionId);
|
||||
},
|
||||
applyLocalUpdate: async (currentSessionId, update) => {
|
||||
const timestamp = new Date();
|
||||
remoteDocs.set(update.docId, { timestamp, bin: update.bin });
|
||||
for (const callback of listeners.get(currentSessionId) ?? []) {
|
||||
callback({
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: update.docId,
|
||||
bin: update.bin,
|
||||
timestamp,
|
||||
},
|
||||
origin: 'sync:disk-mock',
|
||||
});
|
||||
}
|
||||
return {
|
||||
docId: update.docId,
|
||||
timestamp,
|
||||
};
|
||||
},
|
||||
subscribeEvents: (currentSessionId, callback) => {
|
||||
let set = listeners.get(currentSessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(currentSessionId, set);
|
||||
}
|
||||
set.add(callback);
|
||||
return () => {
|
||||
set?.delete(callback);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
bindDiskSyncApis(apis);
|
||||
|
||||
const localDoc = new IndexedDBDocStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
});
|
||||
const localDocSync = new IndexedDBDocSyncStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remoteDoc = new DiskDocStorage({
|
||||
id: workspaceId,
|
||||
flavour: 'local',
|
||||
type: 'workspace',
|
||||
syncFolder: '/tmp/disk-sync-discovery',
|
||||
});
|
||||
|
||||
const local = new SpaceStorage({
|
||||
doc: localDoc,
|
||||
docSync: localDocSync,
|
||||
});
|
||||
const remote = new SpaceStorage({
|
||||
doc: remoteDoc,
|
||||
});
|
||||
|
||||
local.connect();
|
||||
remote.connect();
|
||||
await local.waitForConnected();
|
||||
await remote.waitForConnected();
|
||||
|
||||
// Seed local root meta so disk can discover the page doc id from it.
|
||||
const root = new YDoc();
|
||||
const meta = root.getMap('meta');
|
||||
meta.set('pages', [{ id: pageDocId }]);
|
||||
await localDoc.pushDocUpdate({
|
||||
docId: workspaceId,
|
||||
bin: encodeStateAsUpdate(root),
|
||||
});
|
||||
|
||||
// Seed the page doc itself.
|
||||
const page = new YDoc();
|
||||
page.getMap('test').set('value', 'local');
|
||||
const { timestamp: pageClock } = await localDoc.pushDocUpdate({
|
||||
docId: pageDocId,
|
||||
bin: encodeStateAsUpdate(page),
|
||||
});
|
||||
|
||||
// Simulate "already pushed" clocks from a previous disk folder.
|
||||
await localDocSync.setPeerPushedClock('disk', {
|
||||
docId: pageDocId,
|
||||
timestamp: pageClock,
|
||||
});
|
||||
|
||||
const sync = new Sync({
|
||||
local,
|
||||
remotes: {
|
||||
disk: remote,
|
||||
},
|
||||
});
|
||||
// Match workspace engine behavior: sync root doc first.
|
||||
sync.doc.addPriority(workspaceId, 100);
|
||||
sync.start();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(remoteDocs.has(pageDocId)).toBe(true);
|
||||
});
|
||||
|
||||
sync.stop();
|
||||
remote.disconnect();
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Storage } from '../storage';
|
||||
import type { broadcastChannelStorages } from './broadcast-channel';
|
||||
import type { cloudStorages } from './cloud';
|
||||
import type { diskStorages } from './disk';
|
||||
import type { idbStorages } from './idb';
|
||||
import type { idbV1Storages } from './idb/v1';
|
||||
import type { sqliteStorages } from './sqlite';
|
||||
@@ -16,7 +15,6 @@ type Storages =
|
||||
| typeof cloudStorages
|
||||
| typeof idbV1Storages
|
||||
| typeof idbStorages
|
||||
| typeof diskStorages
|
||||
| typeof sqliteStorages
|
||||
| typeof sqliteV1Storages
|
||||
| typeof broadcastChannelStorages;
|
||||
|
||||
@@ -241,16 +241,13 @@ export class DocSyncPeer {
|
||||
(await this.syncMetadata.getPeerPushedClock(this.peerId, docId))
|
||||
?.timestamp ?? null;
|
||||
const clock = await this.local.getDocTimestamp(docId);
|
||||
const remoteClock = this.status.remoteClocks.get(docId) ?? null;
|
||||
|
||||
throwIfAborted(signal);
|
||||
if (
|
||||
!this.remote.isReadonly &&
|
||||
clock &&
|
||||
(pushedClock === null ||
|
||||
pushedClock.getTime() < clock.timestamp.getTime() ||
|
||||
remoteClock === null ||
|
||||
remoteClock.getTime() < clock.timestamp.getTime())
|
||||
pushedClock.getTime() < clock.timestamp.getTime())
|
||||
) {
|
||||
await this.jobs.pullAndPush(docId, signal);
|
||||
} else {
|
||||
@@ -258,6 +255,7 @@ export class DocSyncPeer {
|
||||
const pulled =
|
||||
(await this.syncMetadata.getPeerPulledRemoteClock(this.peerId, docId))
|
||||
?.timestamp ?? null;
|
||||
const remoteClock = this.status.remoteClocks.get(docId);
|
||||
if (
|
||||
remoteClock &&
|
||||
(pulled === null || pulled.getTime() < remoteClock.getTime())
|
||||
@@ -678,12 +676,10 @@ export class DocSyncPeer {
|
||||
this.actions.addDoc(docId);
|
||||
}
|
||||
|
||||
const forceFullRemoteClockRefresh = this.peerId === 'disk';
|
||||
|
||||
// get cached clocks from metadata
|
||||
const cachedClocks = forceFullRemoteClockRefresh
|
||||
? {}
|
||||
: await this.syncMetadata.getPeerRemoteClocks(this.peerId);
|
||||
const cachedClocks = await this.syncMetadata.getPeerRemoteClocks(
|
||||
this.peerId
|
||||
);
|
||||
this.status.remoteClocks.clear();
|
||||
throwIfAborted(signal);
|
||||
for (const [id, v] of Object.entries(cachedClocks)) {
|
||||
@@ -691,14 +687,9 @@ export class DocSyncPeer {
|
||||
}
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
|
||||
// get clocks from server
|
||||
const maxClockValue = forceFullRemoteClockRefresh
|
||||
? undefined
|
||||
: this.status.remoteClocks.max;
|
||||
// get new clocks from server
|
||||
const maxClockValue = this.status.remoteClocks.max;
|
||||
const newClocks = await this.remote.getDocTimestamps(maxClockValue);
|
||||
if (forceFullRemoteClockRefresh) {
|
||||
this.status.remoteClocks.clear();
|
||||
}
|
||||
for (const [id, v] of Object.entries(newClocks)) {
|
||||
this.status.remoteClocks.set(id, v);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { OpConsumer } from '@toeverything/infra/op';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { type StorageConstructor } from '../impls';
|
||||
@@ -14,9 +13,8 @@ import type { StoreInitOptions, WorkerManagerOps, WorkerOps } from './ops';
|
||||
export type { WorkerManagerOps };
|
||||
|
||||
class StoreConsumer {
|
||||
private storages: PeerStorageOptions<SpaceStorage> | null = null;
|
||||
private sync: Sync | null = null;
|
||||
private initOptions: StoreInitOptions;
|
||||
private readonly storages: PeerStorageOptions<SpaceStorage>;
|
||||
private readonly sync: Sync;
|
||||
|
||||
get ensureLocal() {
|
||||
if (!this.storages) {
|
||||
@@ -72,29 +70,20 @@ class StoreConsumer {
|
||||
private readonly availableStorageImplementations: StorageConstructor[],
|
||||
init: StoreInitOptions
|
||||
) {
|
||||
this.initOptions = init;
|
||||
this.initWithOptions(init);
|
||||
}
|
||||
|
||||
private createStorage(opt: any): any {
|
||||
if (opt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const Storage = this.availableStorageImplementations.find(
|
||||
impl => impl.identifier === opt.name
|
||||
);
|
||||
if (!Storage) {
|
||||
throw new Error(`Storage implementation ${opt.name} not found`);
|
||||
}
|
||||
return new Storage(opt.opts as any);
|
||||
}
|
||||
|
||||
private initWithOptions(init: StoreInitOptions) {
|
||||
this.storages = {
|
||||
local: new SpaceStorage(
|
||||
Object.fromEntries(
|
||||
Object.entries(init.local).map(([type, opt]) => {
|
||||
return [type, this.createStorage(opt)];
|
||||
if (opt === undefined) {
|
||||
return [type, undefined];
|
||||
}
|
||||
const Storage = this.availableStorageImplementations.find(
|
||||
impl => impl.identifier === opt.name
|
||||
);
|
||||
if (!Storage) {
|
||||
throw new Error(`Storage implementation ${opt.name} not found`);
|
||||
}
|
||||
return [type, new Storage(opt.opts as any)];
|
||||
})
|
||||
)
|
||||
),
|
||||
@@ -105,7 +94,18 @@ class StoreConsumer {
|
||||
new SpaceStorage(
|
||||
Object.fromEntries(
|
||||
Object.entries(opts).map(([type, opt]) => {
|
||||
return [type, this.createStorage(opt)];
|
||||
if (opt === undefined) {
|
||||
return [type, undefined];
|
||||
}
|
||||
const Storage = this.availableStorageImplementations.find(
|
||||
impl => impl.identifier === opt.name
|
||||
);
|
||||
if (!Storage) {
|
||||
throw new Error(
|
||||
`Storage implementation ${opt.name} not found`
|
||||
);
|
||||
}
|
||||
return [type, new Storage(opt.opts as any)];
|
||||
})
|
||||
)
|
||||
),
|
||||
@@ -125,69 +125,6 @@ class StoreConsumer {
|
||||
this.registerHandlers(consumer);
|
||||
}
|
||||
|
||||
async reconfigure(init: StoreInitOptions) {
|
||||
if (isEqual(this.initOptions, init)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If local storage config changes, fall back to full teardown/rebuild.
|
||||
// (Remote-only changes are expected, like enabling folder sync.)
|
||||
if (
|
||||
!this.storages ||
|
||||
!this.sync ||
|
||||
!isEqual(this.initOptions.local, init.local)
|
||||
) {
|
||||
await this.destroy();
|
||||
this.initOptions = init;
|
||||
this.initWithOptions(init);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote-only change: rebuild sync graph and remote storages in-place so
|
||||
// existing OpConsumers keep working.
|
||||
const prevInit = this.initOptions;
|
||||
const storages = this.storages;
|
||||
|
||||
this.sync.stop();
|
||||
|
||||
// Destroy removed or changed remote peers.
|
||||
for (const [peerId, prevPeerOpts] of Object.entries(prevInit.remotes)) {
|
||||
const nextPeerOpts = init.remotes[peerId];
|
||||
const changed = !nextPeerOpts || !isEqual(prevPeerOpts, nextPeerOpts);
|
||||
if (!changed) {
|
||||
continue;
|
||||
}
|
||||
const remote = storages.remotes[peerId];
|
||||
if (remote) {
|
||||
delete storages.remotes[peerId];
|
||||
remote.disconnect();
|
||||
await remote.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Create added or changed remote peers.
|
||||
for (const [peerId, nextPeerOpts] of Object.entries(init.remotes)) {
|
||||
const prevPeerOpts = prevInit.remotes[peerId];
|
||||
const changed = !prevPeerOpts || !isEqual(prevPeerOpts, nextPeerOpts);
|
||||
if (!changed) {
|
||||
continue;
|
||||
}
|
||||
const remote = new SpaceStorage(
|
||||
Object.fromEntries(
|
||||
Object.entries(nextPeerOpts).map(([type, opt]) => {
|
||||
return [type, this.createStorage(opt)];
|
||||
})
|
||||
)
|
||||
);
|
||||
storages.remotes[peerId] = remote;
|
||||
remote.connect();
|
||||
}
|
||||
|
||||
this.sync = new Sync(storages);
|
||||
this.sync.start();
|
||||
this.initOptions = init;
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.sync?.stop();
|
||||
this.storages?.local.disconnect();
|
||||
@@ -196,9 +133,6 @@ class StoreConsumer {
|
||||
remote.disconnect();
|
||||
await remote.destroy();
|
||||
}
|
||||
|
||||
this.sync = null;
|
||||
this.storages = null;
|
||||
}
|
||||
|
||||
private readonly ENABLE_BATTERY_SAVE_MODE_DELAY = 1000;
|
||||
@@ -403,12 +337,7 @@ export class StoreManagerConsumer {
|
||||
private readonly storeDisposers = new Map<string, () => void>();
|
||||
private readonly storePool = new Map<
|
||||
string,
|
||||
{
|
||||
store: StoreConsumer;
|
||||
refCount: number;
|
||||
options: StoreInitOptions;
|
||||
reconfiguring?: Promise<void>;
|
||||
}
|
||||
{ store: StoreConsumer; refCount: number }
|
||||
>();
|
||||
private readonly telemetry = new TelemetryManager();
|
||||
|
||||
@@ -431,22 +360,7 @@ export class StoreManagerConsumer {
|
||||
this.availableStorageImplementations,
|
||||
options
|
||||
);
|
||||
storeRef = { store, refCount: 0, options };
|
||||
} else if (!isEqual(storeRef.options, options)) {
|
||||
const currentStoreRef = storeRef;
|
||||
// Options can change across renderer reloads (or when features like
|
||||
// folder sync are enabled). Reconfigure the shared store in-place
|
||||
// so existing consumers keep working with the latest remotes.
|
||||
currentStoreRef.reconfiguring = (
|
||||
currentStoreRef.reconfiguring ?? Promise.resolve()
|
||||
)
|
||||
.then(async () => {
|
||||
await currentStoreRef.store.reconfigure(options);
|
||||
currentStoreRef.options = options;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('failed to reconfigure store', key, error);
|
||||
});
|
||||
storeRef = { store, refCount: 0 };
|
||||
}
|
||||
storeRef.refCount++;
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
"type": "Object",
|
||||
"desc": "The config for copilot job queue"
|
||||
},
|
||||
"queues.calendar": {
|
||||
"type": "Object",
|
||||
"desc": "The config for calendar job queue"
|
||||
},
|
||||
"queues.doc": {
|
||||
"type": "Object",
|
||||
"desc": "The config for doc job queue"
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,20 +60,6 @@ export function setupStoreManager(framework: Framework) {
|
||||
|
||||
framework.impl(NbstoreProvider, {
|
||||
openStore(key, options) {
|
||||
try {
|
||||
// E2E/debug only: capture init options passed to the nbstore worker.
|
||||
(globalThis as any).__e2eNbstoreOpenStoreLogs =
|
||||
(globalThis as any).__e2eNbstoreOpenStoreLogs ?? [];
|
||||
(globalThis as any).__e2eNbstoreOpenStoreLogs.push({
|
||||
key,
|
||||
remotes: Object.keys(options?.remotes ?? {}),
|
||||
diskSyncFolder:
|
||||
(options as any)?.remotes?.['disk']?.doc?.opts?.syncFolder ?? null,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const { store, dispose } = storeManagerClient.open(key, options);
|
||||
|
||||
return {
|
||||
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import type { DiskSyncEvent } from '@affine/nbstore/disk';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDiskSyncApis } from './disk-sync-bridge';
|
||||
|
||||
describe('createDiskSyncApis', () => {
|
||||
it('forwards handler calls and filters events by session id', async () => {
|
||||
const startSession = vi.fn(async () => {});
|
||||
const stopSession = vi.fn(async () => {});
|
||||
const applyLocalUpdate = vi.fn(async () => ({
|
||||
docId: 'doc-1',
|
||||
timestamp: new Date('2026-01-04T00:00:00.000Z'),
|
||||
}));
|
||||
|
||||
const listeners = new Set<
|
||||
(payload: { sessionId: string; event: DiskSyncEvent }) => void
|
||||
>();
|
||||
const onEvent = vi.fn(
|
||||
(
|
||||
callback: (payload: { sessionId: string; event: DiskSyncEvent }) => void
|
||||
) => {
|
||||
listeners.add(callback);
|
||||
return () => {
|
||||
listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const apis = createDiskSyncApis(
|
||||
{ startSession, stopSession, applyLocalUpdate },
|
||||
{ onEvent }
|
||||
);
|
||||
|
||||
await apis.startSession('session-a', {
|
||||
workspaceId: 'workspace-a',
|
||||
syncFolder: '/tmp/sync-a',
|
||||
});
|
||||
await apis.stopSession('session-a');
|
||||
await apis.applyLocalUpdate('session-a', {
|
||||
docId: 'doc-1',
|
||||
bin: new Uint8Array([1, 2, 3]),
|
||||
});
|
||||
|
||||
expect(startSession).toHaveBeenCalledWith('session-a', {
|
||||
workspaceId: 'workspace-a',
|
||||
syncFolder: '/tmp/sync-a',
|
||||
});
|
||||
expect(stopSession).toHaveBeenCalledWith('session-a');
|
||||
expect(applyLocalUpdate).toHaveBeenCalledWith(
|
||||
'session-a',
|
||||
expect.objectContaining({ docId: 'doc-1' }),
|
||||
undefined
|
||||
);
|
||||
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = apis.subscribeEvents('session-a', callback);
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
const docUpdate: DiskSyncEvent = {
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: 'doc-2',
|
||||
bin: new Uint8Array([4, 5, 6]),
|
||||
timestamp: new Date('2026-01-04T00:00:01.000Z'),
|
||||
},
|
||||
};
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener({ sessionId: 'session-b', event: { type: 'ready' } });
|
||||
listener({ sessionId: 'session-a', event: docUpdate });
|
||||
}
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(docUpdate);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { DocClock, DocUpdate } from '@affine/nbstore';
|
||||
import type {
|
||||
DiskSessionOptions,
|
||||
DiskSyncApis,
|
||||
DiskSyncEvent,
|
||||
} from '@affine/nbstore/disk';
|
||||
|
||||
type DiskSyncEventPayload = {
|
||||
sessionId: string;
|
||||
event: DiskSyncEvent;
|
||||
};
|
||||
|
||||
interface DiskSyncHandlers {
|
||||
startSession: (
|
||||
sessionId: string,
|
||||
options: DiskSessionOptions
|
||||
) => Promise<void>;
|
||||
stopSession: (sessionId: string) => Promise<void>;
|
||||
applyLocalUpdate: (
|
||||
sessionId: string,
|
||||
update: DocUpdate,
|
||||
origin?: string
|
||||
) => Promise<DocClock>;
|
||||
}
|
||||
|
||||
interface DiskSyncEvents {
|
||||
onEvent: (callback: (payload: DiskSyncEventPayload) => void) => () => void;
|
||||
}
|
||||
|
||||
export function createDiskSyncApis(
|
||||
handlers: DiskSyncHandlers,
|
||||
events: DiskSyncEvents
|
||||
): DiskSyncApis {
|
||||
return {
|
||||
startSession: (sessionId, options) => {
|
||||
return handlers.startSession(sessionId, options);
|
||||
},
|
||||
stopSession: sessionId => {
|
||||
return handlers.stopSession(sessionId);
|
||||
},
|
||||
applyLocalUpdate: (sessionId, update, origin) => {
|
||||
return handlers.applyLocalUpdate(sessionId, update, origin);
|
||||
},
|
||||
subscribeEvents: (sessionId, callback) => {
|
||||
return events.onEvent(payload => {
|
||||
if (payload.sessionId === sessionId) {
|
||||
callback(payload.event);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import '@affine/core/bootstrap/electron';
|
||||
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
|
||||
import { cloudStorages } from '@affine/nbstore/cloud';
|
||||
import { bindDiskSyncApis, diskStorages } from '@affine/nbstore/disk';
|
||||
import { bindNativeDBApis, sqliteStorages } from '@affine/nbstore/sqlite';
|
||||
import {
|
||||
bindNativeDBV1Apis,
|
||||
@@ -15,19 +14,14 @@ import {
|
||||
} from '@affine/nbstore/worker/consumer';
|
||||
import { OpConsumer } from '@toeverything/infra/op';
|
||||
|
||||
import { createDiskSyncApis } from './disk-sync-bridge';
|
||||
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
bindNativeDBApis(apis!.nbstore);
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
bindNativeDBV1Apis(apis!.db);
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
bindDiskSyncApis(createDiskSyncApis(apis!.diskSync, events!.diskSync));
|
||||
|
||||
const storeManager = new StoreManagerConsumer([
|
||||
...sqliteStorages,
|
||||
...sqliteV1Storages,
|
||||
...diskStorages,
|
||||
...broadcastChannelStorages,
|
||||
...cloudStorages,
|
||||
]);
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { DiskSyncEvent as NativeDiskSyncEvent } from '@affine/native';
|
||||
import { DiskSync } from '@affine/native';
|
||||
import type { DocClock, DocUpdate } from '@affine/nbstore';
|
||||
import type { DiskSessionOptions, DiskSyncEvent } from '@affine/nbstore/disk';
|
||||
|
||||
import { diskSyncSubjects } from './subjects';
|
||||
|
||||
interface DiskSyncSubscriber {
|
||||
unsubscribe(): Promise<void | Error> | void | Error;
|
||||
}
|
||||
|
||||
type NapiMaybe<T> = T | Error;
|
||||
|
||||
function unwrapNapiResult<T>(result: NapiMaybe<T>, action: string): T {
|
||||
if (result instanceof Error) {
|
||||
throw new Error(`[disk] ${action} failed: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(timestamp: unknown): Date | null {
|
||||
const normalized =
|
||||
timestamp instanceof Date ? timestamp : new Date(timestamp as string);
|
||||
if (Number.isNaN(normalized.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeDiskSyncEvent(
|
||||
event: NativeDiskSyncEvent
|
||||
): DiskSyncEvent | null {
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
return { type: 'ready' };
|
||||
case 'doc-update': {
|
||||
if (!event.update || !(event.update.bin instanceof Uint8Array)) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = normalizeTimestamp(event.update.timestamp);
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'doc-update',
|
||||
update: {
|
||||
docId: event.update.docId,
|
||||
bin: event.update.bin,
|
||||
timestamp,
|
||||
editor: event.update.editor,
|
||||
},
|
||||
origin: event.origin,
|
||||
};
|
||||
}
|
||||
case 'doc-delete': {
|
||||
if (typeof event.docId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const timestamp = normalizeTimestamp(event.timestamp);
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'doc-delete',
|
||||
docId: event.docId,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
case 'error': {
|
||||
if (typeof event.message !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'error',
|
||||
message: event.message,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type DiskSyncRuntime = InstanceType<typeof DiskSync> & {
|
||||
startSession(
|
||||
sessionId: string,
|
||||
options: DiskSessionOptions
|
||||
): Promise<NapiMaybe<void>>;
|
||||
stopSession(sessionId: string): Promise<NapiMaybe<void>>;
|
||||
applyLocalUpdate(
|
||||
sessionId: string,
|
||||
update: DocUpdate,
|
||||
origin?: string
|
||||
): Promise<NapiMaybe<DocClock>>;
|
||||
subscribeEvents(
|
||||
sessionId: string,
|
||||
callback: (err: Error | null, event: NativeDiskSyncEvent) => void
|
||||
): Promise<NapiMaybe<DiskSyncSubscriber>>;
|
||||
};
|
||||
|
||||
const diskSync = new DiskSync() as DiskSyncRuntime;
|
||||
const subscriptions = new Map<string, () => Promise<void>>();
|
||||
|
||||
function e2eLog(options: DiskSessionOptions, line: string) {
|
||||
if (process.env.AFFINE_E2E !== '1') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const p = path.join(options.syncFolder, '.disk-e2e.log');
|
||||
fs.appendFileSync(p, `${new Date().toISOString()}\t${line}\n`, 'utf8');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export async function startSession(
|
||||
sessionId: string,
|
||||
options: DiskSessionOptions
|
||||
): Promise<void> {
|
||||
e2eLog(
|
||||
options,
|
||||
`startSession\t${sessionId}\tworkspaceId=${options.workspaceId}\tsyncFolder=${options.syncFolder}`
|
||||
);
|
||||
unwrapNapiResult(
|
||||
await diskSync.startSession(sessionId, options),
|
||||
'startSession'
|
||||
);
|
||||
|
||||
if (subscriptions.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriber = unwrapNapiResult(
|
||||
await diskSync.subscribeEvents(sessionId, (err, event) => {
|
||||
if (err) {
|
||||
return;
|
||||
}
|
||||
const normalizedEvent = normalizeDiskSyncEvent(event);
|
||||
if (!normalizedEvent) {
|
||||
return;
|
||||
}
|
||||
diskSyncSubjects.event$.next({ sessionId, event: normalizedEvent });
|
||||
}),
|
||||
'subscribeEvents'
|
||||
);
|
||||
subscriptions.set(sessionId, async () => {
|
||||
unwrapNapiResult(await subscriber.unsubscribe(), 'unsubscribe');
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopSession(sessionId: string): Promise<void> {
|
||||
await subscriptions.get(sessionId)?.();
|
||||
subscriptions.delete(sessionId);
|
||||
unwrapNapiResult(await diskSync.stopSession(sessionId), 'stopSession');
|
||||
}
|
||||
|
||||
export async function applyLocalUpdate(
|
||||
sessionId: string,
|
||||
update: DocUpdate,
|
||||
origin?: string
|
||||
): Promise<DocClock> {
|
||||
// syncFolder isn't directly available here; we log per session start only.
|
||||
return unwrapNapiResult(
|
||||
await diskSync.applyLocalUpdate(sessionId, update, origin),
|
||||
'applyLocalUpdate'
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { applyLocalUpdate, startSession, stopSession } from './handlers';
|
||||
import { diskSyncSubjects } from './subjects';
|
||||
|
||||
export const diskSyncHandlers = {
|
||||
startSession,
|
||||
stopSession,
|
||||
applyLocalUpdate,
|
||||
};
|
||||
|
||||
export const diskSyncEvents = {
|
||||
onEvent: ((callback: (...args: any[]) => void) => {
|
||||
const subscription = diskSyncSubjects.event$.subscribe(payload => {
|
||||
callback(payload);
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}) satisfies MainEventRegister,
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { DiskSyncEvent } from '@affine/nbstore/disk';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export interface DiskSyncSessionEvent {
|
||||
sessionId: string;
|
||||
event: DiskSyncEvent;
|
||||
}
|
||||
|
||||
export const diskSyncSubjects = {
|
||||
event$: new Subject<DiskSyncSessionEvent>(),
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { dialogHandlers } from './dialog';
|
||||
import { diskSyncEvents, diskSyncHandlers } from './disk-sync';
|
||||
import { dbEventsV1, dbHandlersV1, nbstoreHandlers } from './nbstore';
|
||||
import { provideExposed } from './provide';
|
||||
import { workspaceEvents, workspaceHandlers } from './workspace';
|
||||
@@ -7,14 +6,12 @@ import { workspaceEvents, workspaceHandlers } from './workspace';
|
||||
export const handlers = {
|
||||
db: dbHandlersV1,
|
||||
nbstore: nbstoreHandlers,
|
||||
diskSync: diskSyncHandlers,
|
||||
workspace: workspaceHandlers,
|
||||
dialog: dialogHandlers,
|
||||
};
|
||||
|
||||
export const events = {
|
||||
db: dbEventsV1,
|
||||
diskSync: diskSyncEvents,
|
||||
workspace: workspaceEvents,
|
||||
};
|
||||
|
||||
|
||||
@@ -82,33 +82,30 @@ function createSharedStorageApi(
|
||||
}
|
||||
});
|
||||
|
||||
// Load initial state synchronously so consumers can read values during early
|
||||
// bootstrap without awaiting `ready`. This prevents feature config races
|
||||
// (e.g. folder sync remote options) on first app load.
|
||||
try {
|
||||
memory.setAll(init);
|
||||
const latest = ipcRenderer.sendSync(
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
event === 'onGlobalStateChanged'
|
||||
? 'sharedStorage:getAllGlobalState'
|
||||
: 'sharedStorage:getAllGlobalCache'
|
||||
);
|
||||
if (latest && typeof latest === 'object') {
|
||||
memory.setAll(latest);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial shared storage (sync)', err);
|
||||
} finally {
|
||||
loaded = true;
|
||||
while (updateQueue.length) {
|
||||
const updates = updateQueue.shift();
|
||||
if (updates) {
|
||||
applyUpdates(updates);
|
||||
const initPromise = (async () => {
|
||||
try {
|
||||
memory.setAll(init);
|
||||
const latest = await ipcRenderer.invoke(
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
event === 'onGlobalStateChanged'
|
||||
? 'sharedStorage:getAllGlobalState'
|
||||
: 'sharedStorage:getAllGlobalCache'
|
||||
);
|
||||
if (latest && typeof latest === 'object') {
|
||||
memory.setAll(latest);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial shared storage', err);
|
||||
} finally {
|
||||
loaded = true;
|
||||
while (updateQueue.length) {
|
||||
const updates = updateQueue.shift();
|
||||
if (updates) {
|
||||
applyUpdates(updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initPromise = Promise.resolve();
|
||||
})();
|
||||
|
||||
return {
|
||||
ready: initPromise,
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { DiskSyncEvent } from '@affine/nbstore/disk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const diskSyncMocks = vi.hoisted(() => {
|
||||
return {
|
||||
startSession: vi.fn(async () => {}),
|
||||
stopSession: vi.fn(async () => {}),
|
||||
applyLocalUpdate: vi.fn(async () => ({
|
||||
docId: 'doc-1',
|
||||
timestamp: new Date('2026-01-06T00:00:00.000Z'),
|
||||
})),
|
||||
subscribeEvents: vi.fn(
|
||||
(
|
||||
_sessionId: string,
|
||||
_callback: (err: Error | null, event: DiskSyncEvent) => void
|
||||
) => {
|
||||
return Promise.resolve({
|
||||
unsubscribe: () => {},
|
||||
});
|
||||
}
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@affine/native', () => {
|
||||
class DiskSyncMock {
|
||||
subscribeEvents(
|
||||
sessionId: string,
|
||||
callback: (err: Error | null, event: DiskSyncEvent) => void
|
||||
) {
|
||||
return diskSyncMocks.subscribeEvents(sessionId, callback);
|
||||
}
|
||||
|
||||
startSession(
|
||||
sessionId: string,
|
||||
options: { workspaceId: string; syncFolder: string }
|
||||
) {
|
||||
return diskSyncMocks.startSession(sessionId, options);
|
||||
}
|
||||
|
||||
stopSession(sessionId: string) {
|
||||
return diskSyncMocks.stopSession(sessionId);
|
||||
}
|
||||
|
||||
applyLocalUpdate(
|
||||
sessionId: string,
|
||||
update: { docId: string; bin: Uint8Array },
|
||||
origin?: string
|
||||
) {
|
||||
return diskSyncMocks.applyLocalUpdate(sessionId, update, origin);
|
||||
}
|
||||
}
|
||||
|
||||
return { DiskSync: DiskSyncMock };
|
||||
});
|
||||
|
||||
import {
|
||||
applyLocalUpdate,
|
||||
startSession,
|
||||
stopSession,
|
||||
} from '../../src/helper/disk-sync/handlers';
|
||||
import { diskSyncSubjects } from '../../src/helper/disk-sync/subjects';
|
||||
|
||||
describe('disk helper handlers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('forwards subscribeEvents payload and unsubscribes on stop', async () => {
|
||||
const unsubscribe = vi.fn();
|
||||
diskSyncMocks.subscribeEvents.mockImplementation(
|
||||
(
|
||||
_sessionId: string,
|
||||
callback: (err: Error | null, event: DiskSyncEvent) => void
|
||||
) => {
|
||||
callback(null, {
|
||||
type: 'ready',
|
||||
} as DiskSyncEvent);
|
||||
return Promise.resolve({
|
||||
unsubscribe,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const seen: string[] = [];
|
||||
const subscription = diskSyncSubjects.event$.subscribe(payload => {
|
||||
seen.push(payload.event.type);
|
||||
});
|
||||
|
||||
await startSession('session-subscribe', {
|
||||
workspaceId: 'workspace-subscribe',
|
||||
syncFolder: '/tmp/disk-sync',
|
||||
});
|
||||
|
||||
expect(seen).toContain('ready');
|
||||
expect(diskSyncMocks.subscribeEvents).toHaveBeenCalledWith(
|
||||
'session-subscribe',
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
await stopSession('session-subscribe');
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('throws when native applyLocalUpdate returns Error payload', async () => {
|
||||
diskSyncMocks.applyLocalUpdate.mockResolvedValueOnce(
|
||||
new Error('invalid_binary')
|
||||
);
|
||||
|
||||
await expect(
|
||||
applyLocalUpdate('session-subscribe', {
|
||||
docId: 'doc-failed',
|
||||
bin: new Uint8Array([1, 2, 3]),
|
||||
})
|
||||
).rejects.toThrow('[disk] applyLocalUpdate failed: invalid_binary');
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
|
||||
private func showAIConsentAlert() {
|
||||
let alert = UIAlertController(
|
||||
title: "AI Feature Data Usage",
|
||||
message: "To provide AI-powered features, your input (such as document content and conversation messages) will be sent to a third-party AI service for processing. This data is used solely to generate responses and is not used for any other purpose.\n\nBy continuing, you agree to share this data with the AI service.",
|
||||
message: "To provide AI-powered features, your input (such as document content and conversation messages) will be sent to our third-party AI service providers (Google, Anthropic, or OpenAI, based on your choice) for processing. This data is used solely to generate responses and is not used for any other purpose.\n\nBy continuing, you agree to share this data with these AI services.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@dotlottie/player-component": "^2.7.12",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@lit/context": "^1.1.4",
|
||||
"@lottiefiles/dotlottie-wc": "^0.9.4",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2",
|
||||
|
||||
File diff suppressed because one or more lines are too long
-7
@@ -1,7 +0,0 @@
|
||||
export function shouldReloadDiskSyncSession(
|
||||
enabled: boolean,
|
||||
previousFolder: string | null,
|
||||
nextFolder: string | null
|
||||
) {
|
||||
return enabled && previousFolder !== nextFolder;
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldReloadDiskSyncSession } from './disk-sync-session';
|
||||
|
||||
describe('shouldReloadDiskSyncSession', () => {
|
||||
it('does not reload when feature is disabled', () => {
|
||||
expect(
|
||||
shouldReloadDiskSyncSession(false, '/tmp/folder-a', '/tmp/folder-b')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not reload when folder is unchanged', () => {
|
||||
expect(
|
||||
shouldReloadDiskSyncSession(true, '/tmp/folder-a', '/tmp/folder-a')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reloads when folder changes while enabled', () => {
|
||||
expect(
|
||||
shouldReloadDiskSyncSession(true, '/tmp/folder-a', '/tmp/folder-b')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reloads when clearing folder while enabled', () => {
|
||||
expect(shouldReloadDiskSyncSession(true, '/tmp/folder-a', null)).toBe(true);
|
||||
});
|
||||
});
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
import { notify, Switch } from '@affine/component';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import {
|
||||
DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY,
|
||||
getDiskSyncFolderPath,
|
||||
setDiskSyncFolderPath,
|
||||
} from '@affine/core/modules/workspace-engine/impls/disk-config';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { shouldReloadDiskSyncSession } from './disk-sync-session';
|
||||
|
||||
export const DiskSyncPanel = ({ workspaceId }: { workspaceId: string }) => {
|
||||
const desktopApi = useService(DesktopApiService);
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
const enabled = useLiveData(featureFlagService.flags.enable_disk_sync.$);
|
||||
const [folder, setFolder] = useState<string | null>(() =>
|
||||
getDiskSyncFolderPath(workspaceId)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFolder(getDiskSyncFolderPath(workspaceId));
|
||||
const unwatch = desktopApi.sharedStorage.globalState.watch<
|
||||
Record<string, string>
|
||||
>(DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY, folders => {
|
||||
const next = folders?.[workspaceId];
|
||||
setFolder(typeof next === 'string' && next.length > 0 ? next : null);
|
||||
});
|
||||
return () => {
|
||||
unwatch();
|
||||
};
|
||||
}, [desktopApi.sharedStorage.globalState, workspaceId]);
|
||||
|
||||
const onToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
featureFlagService.flags.enable_disk_sync.set(checked);
|
||||
},
|
||||
[featureFlagService]
|
||||
);
|
||||
|
||||
const onChooseFolder = useAsyncCallback(async () => {
|
||||
const result = await desktopApi.handler.dialog.selectDBFileLocation();
|
||||
if (result?.canceled || !result?.filePath) {
|
||||
return;
|
||||
}
|
||||
if (result.filePath === folder) {
|
||||
return;
|
||||
}
|
||||
setDiskSyncFolderPath(workspaceId, result.filePath);
|
||||
setFolder(result.filePath);
|
||||
if (shouldReloadDiskSyncSession(enabled, folder, result.filePath)) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
notify.success({
|
||||
title: 'Disk sync folder updated',
|
||||
});
|
||||
}, [desktopApi.handler.dialog, enabled, folder, workspaceId]);
|
||||
|
||||
const onClearFolder = useCallback(() => {
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
setDiskSyncFolderPath(workspaceId, null);
|
||||
setFolder(null);
|
||||
if (shouldReloadDiskSyncSession(enabled, folder, null)) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [enabled, folder, workspaceId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
name={'Markdown Folder Sync (Experimental)'}
|
||||
desc={
|
||||
'Enable local-folder Markdown sync through native pseudo remote (Electron only).'
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
aria-label="Disk Markdown Sync"
|
||||
data-testid="disk-sync-toggle"
|
||||
checked={!!enabled}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={'Sync Folder'}
|
||||
desc={folder ?? 'No folder selected'}
|
||||
spreadCol={false}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button
|
||||
data-testid="disk-sync-choose-folder"
|
||||
disabled={!enabled}
|
||||
onClick={onChooseFolder}
|
||||
>
|
||||
Choose Folder
|
||||
</Button>
|
||||
{folder ? (
|
||||
<Button
|
||||
data-testid="disk-sync-clear-folder"
|
||||
disabled={!enabled}
|
||||
onClick={onClearFolder}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import { EnableCloudPanel } from '../preference/enable-cloud';
|
||||
import { BlobManagementPanel } from './blob-management';
|
||||
import { DiskSyncPanel } from './disk-sync';
|
||||
import { DesktopExportPanel } from './export';
|
||||
import { WorkspaceQuotaPanel } from './workspace-quota';
|
||||
|
||||
@@ -36,11 +35,6 @@ export const WorkspaceSettingStorage = ({
|
||||
{workspace.flavour === 'local' ? (
|
||||
<>
|
||||
<EnableCloudPanel onCloseSetting={onCloseSetting} />{' '}
|
||||
{BUILD_CONFIG.isElectron && (
|
||||
<SettingWrapper>
|
||||
<DiskSyncPanel workspaceId={workspace.id} />
|
||||
</SettingWrapper>
|
||||
)}
|
||||
{BUILD_CONFIG.isElectron && (
|
||||
<SettingWrapper>
|
||||
<DesktopExportPanel workspace={workspace} />
|
||||
|
||||
@@ -287,14 +287,6 @@ export const AFFINE_FLAGS = {
|
||||
configurable: true,
|
||||
defaultState: isMobile,
|
||||
},
|
||||
enable_disk_sync: {
|
||||
category: 'affine',
|
||||
displayName: 'Enable Disk Markdown Sync',
|
||||
description:
|
||||
'Enable experimental local-folder Markdown bidirectional sync on Electron desktop. WARNING: We are not responsible for any data loss without thorough testing.',
|
||||
configurable: BUILD_CONFIG.isElectron && isCanaryBuild,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_mobile_database_editing: {
|
||||
category: 'blocksuite',
|
||||
bsFlag: 'enable_mobile_database_editing',
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { bindReloadOnFlagChange } from './feature-flag';
|
||||
|
||||
describe('bindReloadOnFlagChange', () => {
|
||||
it('reloads only when flag value changes after initialization', () => {
|
||||
const flag$ = new BehaviorSubject(false);
|
||||
const reload = vi.fn();
|
||||
const subscription = bindReloadOnFlagChange(flag$, reload);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
flag$.next(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
flag$.next(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
flag$.next(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
flag$.next(false);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('stops reloading after unsubscribe', () => {
|
||||
const flag$ = new BehaviorSubject(false);
|
||||
const reload = vi.fn();
|
||||
const subscription = bindReloadOnFlagChange(flag$, reload);
|
||||
|
||||
subscription.unsubscribe();
|
||||
flag$.next(true);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,19 @@
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
import type { Observable, Subscription } from 'rxjs';
|
||||
import { distinctUntilChanged, skip } from 'rxjs';
|
||||
|
||||
import { ApplicationStarted } from '../../lifecycle';
|
||||
import { Flags, type FlagsExt } from '../entities/flags';
|
||||
|
||||
export function bindReloadOnFlagChange(
|
||||
flag$: Observable<boolean>,
|
||||
reload: () => void
|
||||
): Subscription {
|
||||
return flag$.pipe(distinctUntilChanged(), skip(1)).subscribe(() => {
|
||||
reload();
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent(ApplicationStarted, e => e.setupRestartListener)
|
||||
export class FeatureFlagService extends Service {
|
||||
flags = this.framework.createEntity(Flags) as FlagsExt;
|
||||
|
||||
setupRestartListener() {
|
||||
const reload = () => window.location.reload();
|
||||
const enableAiReload = bindReloadOnFlagChange(
|
||||
this.flags.enable_ai.$,
|
||||
reload
|
||||
);
|
||||
const diskReload = bindReloadOnFlagChange(
|
||||
this.flags.enable_disk_sync.$,
|
||||
reload
|
||||
);
|
||||
this.disposables.push(
|
||||
() => enableAiReload.unsubscribe(),
|
||||
() => diskReload.unsubscribe()
|
||||
this.flags.enable_ai.$.pipe(distinctUntilChanged(), skip(1)).subscribe(
|
||||
() => {
|
||||
// when enable_ai flag changes, reload the page.
|
||||
window.location.reload();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DISK_SYNC_FEATURE_FLAG_KEY,
|
||||
DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY,
|
||||
getDiskSyncEnabled,
|
||||
getDiskSyncFolderPath,
|
||||
getDiskSyncRemoteOptions,
|
||||
setDiskSyncEnabled,
|
||||
setDiskSyncFolderPath,
|
||||
} from './disk-config';
|
||||
|
||||
describe('disk-config', () => {
|
||||
const originalBuildConfig = globalThis.BUILD_CONFIG;
|
||||
const originalSharedStorage = (globalThis as any).__sharedStorage;
|
||||
const state = new Map<string, unknown>();
|
||||
|
||||
beforeEach(() => {
|
||||
state.clear();
|
||||
(globalThis as any).__sharedStorage = {
|
||||
globalState: {
|
||||
get<T>(key: string): T | undefined {
|
||||
return state.get(key) as T | undefined;
|
||||
},
|
||||
set<T>(key: string, value: T): void {
|
||||
state.set(key, value);
|
||||
},
|
||||
},
|
||||
};
|
||||
globalThis.BUILD_CONFIG = {
|
||||
...originalBuildConfig,
|
||||
isElectron: true,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.BUILD_CONFIG = originalBuildConfig;
|
||||
(globalThis as any).__sharedStorage = originalSharedStorage;
|
||||
});
|
||||
|
||||
it('reads and writes feature flag from electron global state', () => {
|
||||
expect(getDiskSyncEnabled()).toBe(false);
|
||||
|
||||
setDiskSyncEnabled(true);
|
||||
expect(getDiskSyncEnabled()).toBe(true);
|
||||
expect(state.get(DISK_SYNC_FEATURE_FLAG_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores folder path per workspace and resolves remote options only when enabled', () => {
|
||||
setDiskSyncFolderPath('workspace-a', '/tmp/a');
|
||||
expect(getDiskSyncFolderPath('workspace-a')).toBe('/tmp/a');
|
||||
expect(state.get(DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY)).toEqual({
|
||||
'workspace-a': '/tmp/a',
|
||||
});
|
||||
|
||||
expect(getDiskSyncRemoteOptions('workspace-a')).toBeNull();
|
||||
|
||||
setDiskSyncEnabled(true);
|
||||
expect(getDiskSyncRemoteOptions('workspace-a')).toEqual({
|
||||
syncFolder: '/tmp/a',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores config when not running in electron', () => {
|
||||
globalThis.BUILD_CONFIG = {
|
||||
...globalThis.BUILD_CONFIG,
|
||||
isElectron: false,
|
||||
};
|
||||
state.set(DISK_SYNC_FEATURE_FLAG_KEY, true);
|
||||
state.set(DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY, {
|
||||
'workspace-b': '/tmp/b',
|
||||
});
|
||||
|
||||
expect(getDiskSyncEnabled()).toBe(false);
|
||||
expect(getDiskSyncFolderPath('workspace-b')).toBeNull();
|
||||
expect(getDiskSyncRemoteOptions('workspace-b')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
const DISK_SYNC_FLAG_STORAGE_KEY = 'affine-flag:enable_disk_sync';
|
||||
const DISK_SYNC_FOLDERS_STORAGE_KEY = 'workspace-engine:disk-sync-folders:v1';
|
||||
|
||||
type GlobalStateStorageLike = {
|
||||
get<T>(key: string): T | undefined;
|
||||
set<T>(key: string, value: T): void;
|
||||
};
|
||||
|
||||
function getElectronGlobalStateStorage(): GlobalStateStorageLike | null {
|
||||
if (!BUILD_CONFIG.isElectron) {
|
||||
return null;
|
||||
}
|
||||
const sharedStorage = (
|
||||
globalThis as {
|
||||
__sharedStorage?: { globalState?: GlobalStateStorageLike };
|
||||
}
|
||||
).__sharedStorage;
|
||||
return sharedStorage?.globalState ?? null;
|
||||
}
|
||||
|
||||
function normalizeFolderMap(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const validEntries = Object.entries(value).filter(
|
||||
([workspaceId, folder]) =>
|
||||
typeof workspaceId === 'string' &&
|
||||
workspaceId.length > 0 &&
|
||||
typeof folder === 'string' &&
|
||||
folder.length > 0
|
||||
);
|
||||
|
||||
return Object.fromEntries(validEntries);
|
||||
}
|
||||
|
||||
function readFolderMap(): Record<string, string> {
|
||||
const storage = getElectronGlobalStateStorage();
|
||||
if (!storage) {
|
||||
return {};
|
||||
}
|
||||
return normalizeFolderMap(
|
||||
storage.get<Record<string, string>>(DISK_SYNC_FOLDERS_STORAGE_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDiskSyncEnabled(): boolean {
|
||||
const storage = getElectronGlobalStateStorage();
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
return storage.get<boolean>(DISK_SYNC_FLAG_STORAGE_KEY) ?? false;
|
||||
}
|
||||
|
||||
export function setDiskSyncEnabled(enabled: boolean): void {
|
||||
const storage = getElectronGlobalStateStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
storage.set(DISK_SYNC_FLAG_STORAGE_KEY, enabled);
|
||||
}
|
||||
|
||||
export function getDiskSyncFolderPath(workspaceId: string): string | null {
|
||||
return readFolderMap()[workspaceId] ?? null;
|
||||
}
|
||||
|
||||
export function setDiskSyncFolderPath(
|
||||
workspaceId: string,
|
||||
folder: string | null
|
||||
): void {
|
||||
const storage = getElectronGlobalStateStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const folders = readFolderMap();
|
||||
if (!folder) {
|
||||
delete folders[workspaceId];
|
||||
} else {
|
||||
folders[workspaceId] = folder;
|
||||
}
|
||||
storage.set(DISK_SYNC_FOLDERS_STORAGE_KEY, folders);
|
||||
}
|
||||
|
||||
export function getDiskSyncRemoteOptions(workspaceId: string): {
|
||||
syncFolder: string;
|
||||
} | null {
|
||||
if (!getDiskSyncEnabled()) {
|
||||
return null;
|
||||
}
|
||||
const folder = getDiskSyncFolderPath(workspaceId);
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
return { syncFolder: folder };
|
||||
}
|
||||
|
||||
export const DISK_SYNC_FEATURE_FLAG_KEY = DISK_SYNC_FLAG_STORAGE_KEY;
|
||||
export const DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY = DISK_SYNC_FOLDERS_STORAGE_KEY;
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type ListedBlobRecord,
|
||||
universalId,
|
||||
} from '@affine/nbstore';
|
||||
import { DiskDocStorage } from '@affine/nbstore/disk';
|
||||
import {
|
||||
IndexedDBBlobStorage,
|
||||
IndexedDBBlobSyncStorage,
|
||||
@@ -47,7 +46,6 @@ import type {
|
||||
WorkspaceProfileInfo,
|
||||
} from '../../workspace';
|
||||
import { WorkspaceImpl } from '../../workspace/impls/workspace';
|
||||
import { getDiskSyncRemoteOptions } from './disk-config';
|
||||
import { getWorkspaceProfileWorker } from './out-worker';
|
||||
import {
|
||||
dedupeWorkspaceIds,
|
||||
@@ -432,7 +430,6 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
}
|
||||
|
||||
getEngineWorkerInitOptions(workspaceId: string): WorkerInitOptions {
|
||||
const disk = getDiskSyncRemoteOptions(workspaceId);
|
||||
return {
|
||||
local: {
|
||||
doc: {
|
||||
@@ -491,21 +488,6 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
},
|
||||
},
|
||||
remotes: {
|
||||
...(disk
|
||||
? {
|
||||
disk: {
|
||||
doc: {
|
||||
name: DiskDocStorage.identifier,
|
||||
opts: {
|
||||
flavour: this.flavour,
|
||||
type: 'workspace',
|
||||
id: workspaceId,
|
||||
syncFolder: disk.syncFolder,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
v1: {
|
||||
doc: this.DocStorageV1Type
|
||||
? {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"pl": 98,
|
||||
"pt-BR": 96,
|
||||
"ru": 98,
|
||||
"sv-SE": 97,
|
||||
"sv-SE": 96,
|
||||
"uk": 96,
|
||||
"ur": 2,
|
||||
"zh-Hans": 98,
|
||||
|
||||
@@ -11,11 +11,9 @@ affine_common = { workspace = true, features = ["hashcash"] }
|
||||
affine_media_capture = { path = "./media_capture" }
|
||||
affine_nbstore = { workspace = true, features = ["napi"] }
|
||||
affine_sqlite_v1 = { path = "./sqlite_v1" }
|
||||
chrono = { workspace = true }
|
||||
napi = { workspace = true }
|
||||
napi-derive = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
sha3 = { workspace = true }
|
||||
sqlx = { workspace = true, default-features = false, features = [
|
||||
"chrono",
|
||||
"macros",
|
||||
@@ -26,7 +24,6 @@ sqlx = { workspace = true, default-features = false, features = [
|
||||
] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
y-octo = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
mimalloc = { workspace = true }
|
||||
@@ -35,6 +32,7 @@ mimalloc = { workspace = true }
|
||||
mimalloc = { workspace = true, features = ["local_dynamic_tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
|
||||
Vendored
-45
@@ -40,51 +40,6 @@ export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | u
|
||||
|
||||
/** Decode audio file into a Float32Array */
|
||||
export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array
|
||||
export declare class DiskSync {
|
||||
constructor()
|
||||
startSession(sessionId: string, options: DiskSessionOptions): Promise<NapiResult<undefined>>
|
||||
stopSession(sessionId: string): Promise<NapiResult<undefined>>
|
||||
applyLocalUpdate(sessionId: string, update: DiskDocUpdateInput, origin?: string | undefined | null): Promise<NapiResult<DiskDocClock>>
|
||||
pullEvents(sessionId: string): Promise<NapiResult<Array<DiskSyncEvent>>>
|
||||
subscribeEvents(sessionId: string, callback: ((err: Error | null, arg: DiskSyncEvent) => void)): Promise<NapiResult<DiskSyncSubscriber>>
|
||||
}
|
||||
|
||||
export declare class DiskSyncSubscriber {
|
||||
unsubscribe(): Promise<NapiResult<undefined>>
|
||||
}
|
||||
|
||||
export interface DiskDocClock {
|
||||
docId: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface DiskDocUpdateInput {
|
||||
docId: string
|
||||
bin: Uint8Array
|
||||
editor?: string
|
||||
}
|
||||
|
||||
export interface DiskSessionOptions {
|
||||
workspaceId: string
|
||||
syncFolder: string
|
||||
}
|
||||
|
||||
export interface DiskSyncDocUpdateEvent {
|
||||
docId: string
|
||||
bin: Uint8Array
|
||||
timestamp: Date
|
||||
editor?: string
|
||||
}
|
||||
|
||||
export interface DiskSyncEvent {
|
||||
type: string
|
||||
update?: DiskSyncDocUpdateEvent
|
||||
docId?: string
|
||||
timestamp?: Date
|
||||
origin?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
|
||||
|
||||
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
|
||||
|
||||
@@ -77,8 +77,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-android-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-android-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -93,8 +93,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-android-arm-eabi')
|
||||
const bindingPackageVersion = require('@affine/native-android-arm-eabi/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -114,8 +114,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-x64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-win32-x64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -130,8 +130,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-x64-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-x64-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -147,8 +147,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-ia32-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-ia32-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -163,8 +163,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-arm64-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-arm64-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -182,8 +182,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-universal')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-universal/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -198,8 +198,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-x64')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -214,8 +214,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -234,8 +234,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-freebsd-x64')
|
||||
const bindingPackageVersion = require('@affine/native-freebsd-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -250,8 +250,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-freebsd-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-freebsd-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -271,8 +271,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-x64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-x64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -287,8 +287,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-x64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-x64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -305,8 +305,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -321,8 +321,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -339,8 +339,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm-musleabihf')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm-musleabihf/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -355,8 +355,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm-gnueabihf')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm-gnueabihf/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -373,8 +373,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-loong64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-loong64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -389,8 +389,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-loong64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-loong64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -407,8 +407,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-riscv64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-riscv64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -423,8 +423,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-riscv64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-riscv64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -440,8 +440,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-ppc64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-ppc64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -456,8 +456,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-s390x-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-s390x-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -476,8 +476,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -492,8 +492,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-x64')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -508,8 +508,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-arm')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-arm/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -579,8 +579,6 @@ module.exports.AudioCaptureSession = nativeBinding.AudioCaptureSession
|
||||
module.exports.ShareableContent = nativeBinding.ShareableContent
|
||||
module.exports.decodeAudio = nativeBinding.decodeAudio
|
||||
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync
|
||||
module.exports.DiskSync = nativeBinding.DiskSync
|
||||
module.exports.DiskSyncSubscriber = nativeBinding.DiskSyncSubscriber
|
||||
module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse
|
||||
module.exports.verifyChallengeResponse = nativeBinding.verifyChallengeResponse
|
||||
module.exports.DocStorage = nativeBinding.DocStorage
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
use super::types::FrontmatterMeta;
|
||||
|
||||
pub(crate) fn parse_frontmatter(markdown: &str) -> (FrontmatterMeta, String) {
|
||||
let normalized = markdown.replace("\r\n", "\n");
|
||||
if !normalized.starts_with("---\n") {
|
||||
return (FrontmatterMeta::default(), normalized);
|
||||
}
|
||||
|
||||
let rest = &normalized[4..];
|
||||
let Some(end) = rest.find("\n---\n") else {
|
||||
return (FrontmatterMeta::default(), normalized);
|
||||
};
|
||||
|
||||
let frontmatter_block = &rest[..end];
|
||||
let body = rest[(end + 5)..].to_string();
|
||||
|
||||
let mut meta = FrontmatterMeta::default();
|
||||
let mut in_tags_block = false;
|
||||
|
||||
for raw_line in frontmatter_block.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_tags_block && line.starts_with('-') {
|
||||
let value = normalize_scalar(line.trim_start_matches('-').trim());
|
||||
if !value.is_empty() {
|
||||
meta.tags.get_or_insert_with(Vec::new).push(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
in_tags_block = false;
|
||||
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
match key {
|
||||
"id" => {
|
||||
let normalized = normalize_scalar(value);
|
||||
if !normalized.is_empty() {
|
||||
meta.id = Some(normalized);
|
||||
}
|
||||
}
|
||||
"title" => {
|
||||
let normalized = normalize_scalar(value);
|
||||
// Preserve explicit empty titles (`title: ""`) so round-trip hashing can
|
||||
// distinguish them from a missing title field.
|
||||
meta.title = Some(normalized);
|
||||
}
|
||||
"favorite" => {
|
||||
meta.favorite = parse_bool(value);
|
||||
}
|
||||
"trash" => {
|
||||
meta.trash = parse_bool(value);
|
||||
}
|
||||
"tags" => {
|
||||
if value.is_empty() {
|
||||
in_tags_block = true;
|
||||
} else {
|
||||
let tags = parse_tags(value);
|
||||
if !tags.is_empty() {
|
||||
meta.tags = Some(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(meta, body)
|
||||
}
|
||||
|
||||
pub(crate) fn render_frontmatter(meta: &FrontmatterMeta, body: &str) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("---".to_string());
|
||||
|
||||
if let Some(id) = meta.id.as_ref() {
|
||||
lines.push(format!("id: {}", quote_yaml_scalar(id)));
|
||||
}
|
||||
|
||||
if let Some(title) = meta.title.as_ref() {
|
||||
lines.push(format!("title: {}", quote_yaml_scalar(title)));
|
||||
}
|
||||
|
||||
if let Some(tags) = normalize_tags(meta.tags.clone()) {
|
||||
if tags.is_empty() {
|
||||
lines.push("tags: []".to_string());
|
||||
} else {
|
||||
lines.push("tags:".to_string());
|
||||
for tag in tags {
|
||||
lines.push(format!(" - {}", quote_yaml_scalar(&tag)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(favorite) = meta.favorite {
|
||||
lines.push(format!("favorite: {}", favorite));
|
||||
}
|
||||
|
||||
if let Some(trash) = meta.trash {
|
||||
lines.push(format!("trash: {}", trash));
|
||||
}
|
||||
|
||||
lines.push("---".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
let mut rendered = lines.join("\n");
|
||||
rendered.push_str(body.trim_start_matches('\n'));
|
||||
|
||||
if !rendered.ends_with('\n') {
|
||||
rendered.push('\n');
|
||||
}
|
||||
|
||||
rendered
|
||||
}
|
||||
|
||||
fn normalize_scalar(value: &str) -> String {
|
||||
value.trim().trim_matches('"').trim_matches('\'').to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_bool(value: &str) -> Option<bool> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "yes" | "1" => Some(true),
|
||||
"false" | "no" | "0" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_tags(value: &str) -> Vec<String> {
|
||||
let trimmed = value.trim();
|
||||
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
let inner = &trimmed[1..trimmed.len() - 1];
|
||||
return inner
|
||||
.split(',')
|
||||
.map(normalize_scalar)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
}
|
||||
|
||||
trimmed
|
||||
.split(',')
|
||||
.map(normalize_scalar)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_tags(tags: Option<Vec<String>>) -> Option<Vec<String>> {
|
||||
tags.map(|values| {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn quote_yaml_scalar(value: &str) -> String {
|
||||
if value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.')
|
||||
{
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let escaped = value.replace('"', "\\\"");
|
||||
format!("\"{}\"", escaped)
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use napi::{
|
||||
bindgen_prelude::{Error as NapiError, Result as NapiResult, Uint8Array},
|
||||
threadsafe_function::ThreadsafeFunction,
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
mod frontmatter;
|
||||
mod root_meta;
|
||||
mod session;
|
||||
mod state_db;
|
||||
mod types;
|
||||
mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use session::DiskSession;
|
||||
|
||||
static SESSIONS: Lazy<RwLock<HashMap<String, Arc<DiskSession>>>> = Lazy::new(|| RwLock::new(HashMap::new()));
|
||||
static NEXT_SUBSCRIBER_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DiskSessionOptions {
|
||||
pub workspace_id: String,
|
||||
pub sync_folder: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DiskDocUpdateInput {
|
||||
pub doc_id: String,
|
||||
#[napi(ts_type = "Uint8Array")]
|
||||
pub bin: Uint8Array,
|
||||
pub editor: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DiskDocClock {
|
||||
pub doc_id: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DiskSyncDocUpdateEvent {
|
||||
pub doc_id: String,
|
||||
pub bin: Uint8Array,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub editor: Option<String>,
|
||||
}
|
||||
|
||||
impl Clone for DiskSyncDocUpdateEvent {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
doc_id: self.doc_id.clone(),
|
||||
bin: Uint8Array::new(self.bin.as_ref().to_vec()),
|
||||
timestamp: self.timestamp,
|
||||
editor: self.editor.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi(object)]
|
||||
pub struct DiskSyncEvent {
|
||||
pub r#type: String,
|
||||
pub update: Option<DiskSyncDocUpdateEvent>,
|
||||
pub doc_id: Option<String>,
|
||||
pub timestamp: Option<NaiveDateTime>,
|
||||
pub origin: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct DiskSync;
|
||||
|
||||
#[napi]
|
||||
pub struct DiskSyncSubscriber {
|
||||
session_id: String,
|
||||
subscriber_id: u64,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DiskSync {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn start_session(&self, session_id: String, options: DiskSessionOptions) -> NapiResult<()> {
|
||||
{
|
||||
let sessions = SESSIONS.read().await;
|
||||
if sessions.contains_key(&session_id) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let session = DiskSession::new(options).await.map_err(to_napi_error)?;
|
||||
session.queue_ready_event().await.map_err(to_napi_error)?;
|
||||
session.scan_once().await.map_err(to_napi_error)?;
|
||||
|
||||
let mut sessions = SESSIONS.write().await;
|
||||
sessions.insert(session_id, Arc::new(session));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn stop_session(&self, session_id: String) -> NapiResult<()> {
|
||||
let mut sessions = SESSIONS.write().await;
|
||||
if let Some(session) = sessions.remove(&session_id) {
|
||||
session.close().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn apply_local_update(
|
||||
&self,
|
||||
session_id: String,
|
||||
update: DiskDocUpdateInput,
|
||||
origin: Option<String>,
|
||||
) -> NapiResult<DiskDocClock> {
|
||||
let session = {
|
||||
let sessions = SESSIONS.read().await;
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
|
||||
};
|
||||
|
||||
session.apply_local_update(update, origin).await.map_err(to_napi_error)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn pull_events(&self, session_id: String) -> NapiResult<Vec<DiskSyncEvent>> {
|
||||
let session = {
|
||||
let sessions = SESSIONS.read().await;
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
|
||||
};
|
||||
|
||||
session.pull_events().await.map_err(to_napi_error)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn subscribe_events(
|
||||
&self,
|
||||
session_id: String,
|
||||
callback: ThreadsafeFunction<DiskSyncEvent, ()>,
|
||||
) -> NapiResult<DiskSyncSubscriber> {
|
||||
let session = {
|
||||
let sessions = SESSIONS.read().await;
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
|
||||
};
|
||||
|
||||
let subscriber_id = NEXT_SUBSCRIBER_ID.fetch_add(1, Ordering::Relaxed);
|
||||
session
|
||||
.add_subscriber(subscriber_id, callback)
|
||||
.await
|
||||
.map_err(to_napi_error)?;
|
||||
|
||||
Ok(DiskSyncSubscriber {
|
||||
session_id,
|
||||
subscriber_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DiskSyncSubscriber {
|
||||
#[napi]
|
||||
pub async fn unsubscribe(&self) -> NapiResult<()> {
|
||||
let session = {
|
||||
let sessions = SESSIONS.read().await;
|
||||
sessions.get(&self.session_id).cloned()
|
||||
};
|
||||
|
||||
if let Some(session) = session {
|
||||
session.remove_subscriber(self.subscriber_id).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_napi_error(message: impl Into<String>) -> NapiError {
|
||||
NapiError::from_reason(message.into())
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use y_octo::{Any, Array, Doc, Map, Value};
|
||||
|
||||
use super::{
|
||||
frontmatter::{parse_bool, parse_tags},
|
||||
types::FrontmatterMeta,
|
||||
utils::{is_empty_update, load_doc_or_new},
|
||||
};
|
||||
|
||||
pub(crate) fn build_root_meta_update(
|
||||
existing_root: &[u8],
|
||||
workspace_id: &str,
|
||||
doc_id: &str,
|
||||
meta: &FrontmatterMeta,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let doc = load_doc_or_new(existing_root, Some(workspace_id))?;
|
||||
|
||||
let state_before = doc.get_state_vector();
|
||||
let mut meta_map = doc
|
||||
.get_or_create_map("meta")
|
||||
.map_err(|err| format!("failed to open root meta map: {}", err))?;
|
||||
let mut pages = ensure_pages_array(&doc, &mut meta_map)?;
|
||||
|
||||
let mut found = false;
|
||||
for idx in 0..pages.len() {
|
||||
let Some(mut page) = pages.get(idx).and_then(|value| value.to_map()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if get_string_from_map(&page, "id").as_deref() == Some(doc_id) {
|
||||
apply_page_meta(&doc, &mut page, doc_id, meta)?;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
let page_map = doc
|
||||
.create_map()
|
||||
.map_err(|err| format!("failed to create root page map: {}", err))?;
|
||||
|
||||
let idx = pages.len();
|
||||
pages
|
||||
.insert(idx, Value::Map(page_map))
|
||||
.map_err(|err| format!("failed to insert root page map: {}", err))?;
|
||||
|
||||
if let Some(mut page) = pages.get(idx).and_then(|value| value.to_map()) {
|
||||
apply_page_meta(&doc, &mut page, doc_id, meta)?;
|
||||
}
|
||||
}
|
||||
|
||||
doc
|
||||
.encode_state_as_update_v1(&state_before)
|
||||
.map_err(|err| format!("failed to encode root meta update: {}", err))
|
||||
}
|
||||
|
||||
pub(crate) fn extract_root_meta_for_doc(root_bin: &[u8], doc_id: &str) -> Result<Option<FrontmatterMeta>, String> {
|
||||
let metas = extract_all_root_meta(root_bin)?;
|
||||
Ok(metas.get(doc_id).cloned())
|
||||
}
|
||||
|
||||
pub(crate) fn extract_all_root_meta(root_bin: &[u8]) -> Result<HashMap<String, FrontmatterMeta>, String> {
|
||||
if is_empty_update(root_bin) {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let doc = load_doc_or_new(root_bin, None)?;
|
||||
let meta = match doc.get_map("meta") {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => return Ok(HashMap::new()),
|
||||
};
|
||||
|
||||
let pages_value = meta.get("pages");
|
||||
let mut result = HashMap::new();
|
||||
|
||||
if let Some(pages) = pages_value.as_ref().and_then(|value| value.to_array()) {
|
||||
for page_value in pages.iter() {
|
||||
let Some(page_map) = page_value.to_map() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(doc_id) = get_string_from_map(&page_map, "id") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
result.insert(doc_id.clone(), extract_meta_from_page_map(&page_map, Some(doc_id)));
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if let Some(Any::Array(entries)) = pages_value.and_then(|value| value.to_any()) {
|
||||
for entry in entries {
|
||||
let Any::Object(values) = entry else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(Any::String(doc_id)) = values.get("id") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut meta = FrontmatterMeta::default();
|
||||
meta.id = Some(doc_id.clone());
|
||||
|
||||
if let Some(Any::String(title)) = values.get("title") {
|
||||
meta.title = Some(title.clone());
|
||||
}
|
||||
if let Some(tags) = values.get("tags") {
|
||||
meta.tags = extract_tags_from_any(tags);
|
||||
}
|
||||
if let Some(value) = values.get("favorite") {
|
||||
meta.favorite = any_to_bool(value);
|
||||
}
|
||||
if let Some(value) = values.get("trash") {
|
||||
meta.trash = any_to_bool(value);
|
||||
}
|
||||
|
||||
result.insert(doc_id.clone(), meta);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn apply_page_meta(doc: &Doc, page: &mut Map, doc_id: &str, meta: &FrontmatterMeta) -> Result<(), String> {
|
||||
page
|
||||
.insert("id".to_string(), Any::String(doc_id.to_string()))
|
||||
.map_err(|err| format!("failed to set root meta id: {}", err))?;
|
||||
|
||||
if let Some(title) = meta.title.as_ref() {
|
||||
page
|
||||
.insert("title".to_string(), Any::String(title.clone()))
|
||||
.map_err(|err| format!("failed to set root meta title: {}", err))?;
|
||||
}
|
||||
|
||||
if let Some(tags) = super::frontmatter::normalize_tags(meta.tags.clone()) {
|
||||
let mut tags_array = doc
|
||||
.create_array()
|
||||
.map_err(|err| format!("failed to create tags array: {}", err))?;
|
||||
for tag in tags {
|
||||
tags_array
|
||||
.push(Any::String(tag))
|
||||
.map_err(|err| format!("failed to push tag: {}", err))?;
|
||||
}
|
||||
|
||||
page
|
||||
.insert("tags".to_string(), Value::Array(tags_array))
|
||||
.map_err(|err| format!("failed to set tags array: {}", err))?;
|
||||
}
|
||||
|
||||
if let Some(favorite) = meta.favorite {
|
||||
page
|
||||
.insert("favorite".to_string(), if favorite { Any::True } else { Any::False })
|
||||
.map_err(|err| format!("failed to set favorite metadata: {}", err))?;
|
||||
}
|
||||
|
||||
if let Some(trash) = meta.trash {
|
||||
page
|
||||
.insert("trash".to_string(), if trash { Any::True } else { Any::False })
|
||||
.map_err(|err| format!("failed to set trash metadata: {}", err))?;
|
||||
}
|
||||
|
||||
let now_ms = Utc::now().timestamp_millis() as f64;
|
||||
if !has_numeric_timestamp(page, "createDate") {
|
||||
page
|
||||
.insert("createDate".to_string(), Any::Float64(now_ms.into()))
|
||||
.map_err(|err| format!("failed to set createDate metadata: {}", err))?;
|
||||
}
|
||||
|
||||
page
|
||||
.insert("updatedDate".to_string(), Any::Float64(now_ms.into()))
|
||||
.map_err(|err| format!("failed to set updatedDate metadata: {}", err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_pages_array(doc: &Doc, meta: &mut Map) -> Result<Array, String> {
|
||||
let pages_value = meta.get("pages");
|
||||
if let Some(pages) = pages_value.as_ref().and_then(|value| value.to_array()) {
|
||||
return Ok(pages);
|
||||
}
|
||||
|
||||
if let Some(Any::Array(entries)) = pages_value.and_then(|value| value.to_any()) {
|
||||
let mut pages = doc
|
||||
.create_array()
|
||||
.map_err(|err| format!("failed to create pages array: {}", err))?;
|
||||
|
||||
for entry in entries {
|
||||
let value = any_to_value(doc, entry)?;
|
||||
pages
|
||||
.push(value)
|
||||
.map_err(|err| format!("failed to migrate page entry: {}", err))?;
|
||||
}
|
||||
|
||||
meta
|
||||
.insert("pages".to_string(), Value::Array(pages.clone()))
|
||||
.map_err(|err| format!("failed to assign pages array: {}", err))?;
|
||||
|
||||
return Ok(pages);
|
||||
}
|
||||
|
||||
let pages = doc
|
||||
.create_array()
|
||||
.map_err(|err| format!("failed to create pages array: {}", err))?;
|
||||
meta
|
||||
.insert("pages".to_string(), Value::Array(pages.clone()))
|
||||
.map_err(|err| format!("failed to assign pages array: {}", err))?;
|
||||
|
||||
Ok(pages)
|
||||
}
|
||||
|
||||
fn any_to_value(doc: &Doc, any: Any) -> Result<Value, String> {
|
||||
match any {
|
||||
Any::Array(values) => {
|
||||
let mut array = doc
|
||||
.create_array()
|
||||
.map_err(|err| format!("failed to create nested array: {}", err))?;
|
||||
for value in values {
|
||||
let nested = any_to_value(doc, value)?;
|
||||
array
|
||||
.push(nested)
|
||||
.map_err(|err| format!("failed to push nested array value: {}", err))?;
|
||||
}
|
||||
Ok(Value::Array(array))
|
||||
}
|
||||
Any::Object(values) => {
|
||||
let mut map = doc
|
||||
.create_map()
|
||||
.map_err(|err| format!("failed to create nested map: {}", err))?;
|
||||
for (key, value) in values {
|
||||
let nested = any_to_value(doc, value)?;
|
||||
map
|
||||
.insert(key, nested)
|
||||
.map_err(|err| format!("failed to insert nested map value: {}", err))?;
|
||||
}
|
||||
Ok(Value::Map(map))
|
||||
}
|
||||
_ => Ok(Value::Any(any)),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_meta_from_page_map(page_map: &Map, doc_id: Option<String>) -> FrontmatterMeta {
|
||||
let mut meta = FrontmatterMeta::default();
|
||||
meta.id = doc_id.or_else(|| get_string_from_map(page_map, "id"));
|
||||
meta.title = get_string_from_map(page_map, "title");
|
||||
|
||||
if let Some(tags) = page_map.get("tags") {
|
||||
meta.tags = extract_tags_from_value(&tags);
|
||||
}
|
||||
|
||||
meta.favorite = page_map
|
||||
.get("favorite")
|
||||
.and_then(|value| value.to_any())
|
||||
.and_then(|value| any_to_bool(&value));
|
||||
meta.trash = page_map
|
||||
.get("trash")
|
||||
.and_then(|value| value.to_any())
|
||||
.and_then(|value| any_to_bool(&value));
|
||||
|
||||
meta
|
||||
}
|
||||
|
||||
fn extract_tags_from_value(value: &Value) -> Option<Vec<String>> {
|
||||
if let Some(array) = value.to_array() {
|
||||
let mut tags = Vec::new();
|
||||
for item in array.iter() {
|
||||
if let Some(any) = item.to_any()
|
||||
&& let Some(value) = any_to_string(&any)
|
||||
{
|
||||
tags.push(value);
|
||||
}
|
||||
}
|
||||
return Some(tags);
|
||||
}
|
||||
|
||||
value.to_any().and_then(|any| extract_tags_from_any(&any))
|
||||
}
|
||||
|
||||
fn extract_tags_from_any(value: &Any) -> Option<Vec<String>> {
|
||||
match value {
|
||||
Any::Array(values) => {
|
||||
let mut tags = Vec::new();
|
||||
for value in values {
|
||||
if let Some(tag) = any_to_string(value) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
Some(tags)
|
||||
}
|
||||
Any::String(value) => Some(parse_tags(value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn any_to_bool(value: &Any) -> Option<bool> {
|
||||
match value {
|
||||
Any::True => Some(true),
|
||||
Any::False => Some(false),
|
||||
Any::Integer(value) => Some(*value != 0),
|
||||
Any::BigInt64(value) => Some(*value != 0),
|
||||
Any::Float32(value) => Some(value.0 != 0.0),
|
||||
Any::Float64(value) => Some(value.0 != 0.0),
|
||||
Any::String(value) => parse_bool(value),
|
||||
Any::Null | Any::Undefined => None,
|
||||
Any::Array(_) | Any::Object(_) | Any::Binary(_) => Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn any_to_string(value: &Any) -> Option<String> {
|
||||
match value {
|
||||
Any::String(value) => Some(value.to_string()),
|
||||
Any::Integer(value) => Some(value.to_string()),
|
||||
Any::BigInt64(value) => Some(value.to_string()),
|
||||
Any::Float32(value) => Some(value.0.to_string()),
|
||||
Any::Float64(value) => Some(value.0.to_string()),
|
||||
Any::True => Some("true".to_string()),
|
||||
Any::False => Some("false".to_string()),
|
||||
Any::Null | Any::Undefined => None,
|
||||
Any::Array(_) | Any::Object(_) | Any::Binary(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_string_from_map(map: &Map, key: &str) -> Option<String> {
|
||||
map.get(key).and_then(|value| {
|
||||
if let Some(text) = value.to_text() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
value.to_any().and_then(|any| any_to_string(&any))
|
||||
})
|
||||
}
|
||||
|
||||
fn has_numeric_timestamp(page: &Map, key: &str) -> bool {
|
||||
page
|
||||
.get(key)
|
||||
.and_then(|value| value.to_any())
|
||||
.is_some_and(|value| match value {
|
||||
Any::Integer(_) | Any::BigInt64(_) => true,
|
||||
Any::Float32(value) => value.0.is_finite(),
|
||||
Any::Float64(value) => value.0.is_finite(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user