mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 09:21:24 +08:00
fix(infra): compatibility fix for space prefix (#4912)
It seems there are some cases that [this upstream PR](https://github.com/toeverything/blocksuite/pull/4747) will cause data loss. Because of some historical reasons, the page id could be different with its doc id. It might be caused by subdoc migration in the following (not 100% sure if all white screen issue is caused by it) https://github.com/toeverything/AFFiNE/blob/0714c12703ff8840acc180633e5489c755ddfba3/packages/common/infra/src/blocksuite/index.ts#L538-L540 In version 0.10, page id in spaces no longer has prefix "space:" The data flow for fetching a doc's updates is: - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid` if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId - because of guid logic change, the doc that previously prefixed with `space:` will not be found in `doc.spaces` - when fetching the rows of this doc using the doc id === page id, it will return EMPTY since there is no updates associated with the page id The provided fix in the PR will patch the `spaces` field of the root doc so that after 0.10 the page doc can still be found in the `spaces` map. It shall apply to both of the idb & sqlite datasources. Special thanks to @lawvs 's db file for investigation!
This commit is contained in:
@@ -2,7 +2,7 @@ import type { Page, PageMeta, Workspace } from '@blocksuite/store';
|
|||||||
import { createIndexeddbStorage } from '@blocksuite/store';
|
import { createIndexeddbStorage } from '@blocksuite/store';
|
||||||
import type { createStore, WritableAtom } from 'jotai/vanilla';
|
import type { createStore, WritableAtom } from 'jotai/vanilla';
|
||||||
import type { Doc } from 'yjs';
|
import type { Doc } from 'yjs';
|
||||||
import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
|
import { Array as YArray, Doc as YDoc, Map as YMap, transact } from 'yjs';
|
||||||
|
|
||||||
export async function initEmptyPage(page: Page, title?: string) {
|
export async function initEmptyPage(page: Page, title?: string) {
|
||||||
await page.waitForLoaded();
|
await page.waitForLoaded();
|
||||||
@@ -537,6 +537,7 @@ function migrateBlocks(
|
|||||||
const originalBlocks = oldDoc.getMap(spaceId) as YMap<unknown>;
|
const originalBlocks = oldDoc.getMap(spaceId) as YMap<unknown>;
|
||||||
const subdoc = new YDoc();
|
const subdoc = new YDoc();
|
||||||
spaces.set(newId, subdoc);
|
spaces.set(newId, subdoc);
|
||||||
|
subdoc.guid = id;
|
||||||
const blocks = subdoc.getMap('blocks');
|
const blocks = subdoc.getMap('blocks');
|
||||||
Array.from(originalBlocks.entries()).forEach(([key, value]) => {
|
Array.from(originalBlocks.entries()).forEach(([key, value]) => {
|
||||||
const blockData = value.clone();
|
const blockData = value.clone();
|
||||||
@@ -602,6 +603,7 @@ export async function forceUpgradePages(
|
|||||||
const versions = meta.get('blockVersions') as YMap<number>;
|
const versions = meta.get('blockVersions') as YMap<number>;
|
||||||
const schema = options.getSchema();
|
const schema = options.getSchema();
|
||||||
const oldVersions = versions.toJSON();
|
const oldVersions = versions.toJSON();
|
||||||
|
guidCompatibilityFix(rootDoc);
|
||||||
spaces.forEach((space: Doc) => {
|
spaces.forEach((space: Doc) => {
|
||||||
try {
|
try {
|
||||||
schema.upgradePage(0, oldVersions, space);
|
schema.upgradePage(0, oldVersions, space);
|
||||||
@@ -623,6 +625,7 @@ async function upgradeV2ToV3(options: UpgradeOptions): Promise<boolean> {
|
|||||||
const meta = rootDoc.getMap('meta') as YMap<unknown>;
|
const meta = rootDoc.getMap('meta') as YMap<unknown>;
|
||||||
const versions = meta.get('blockVersions') as YMap<number>;
|
const versions = meta.get('blockVersions') as YMap<number>;
|
||||||
const schema = options.getSchema();
|
const schema = options.getSchema();
|
||||||
|
guidCompatibilityFix(rootDoc);
|
||||||
spaces.forEach((space: Doc) => {
|
spaces.forEach((space: Doc) => {
|
||||||
schema.upgradePage(
|
schema.upgradePage(
|
||||||
0,
|
0,
|
||||||
@@ -654,6 +657,39 @@ async function upgradeV2ToV3(options: UpgradeOptions): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// patch root doc's space guid compatibility issue
|
||||||
|
//
|
||||||
|
// in version 0.10, page id in spaces no longer has prefix "space:"
|
||||||
|
// The data flow for fetching a doc's updates is:
|
||||||
|
// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid`
|
||||||
|
// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId
|
||||||
|
// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces`
|
||||||
|
// - when fetching the rows of this doc using the doc id === page id,
|
||||||
|
// it will return empty since there is no updates associated with the page id
|
||||||
|
export function guidCompatibilityFix(rootDoc: YDoc) {
|
||||||
|
let changed = false;
|
||||||
|
transact(rootDoc, () => {
|
||||||
|
const spaces = rootDoc.getMap('spaces') as YMap<YDoc>;
|
||||||
|
spaces.forEach((doc: YDoc, pageId: string) => {
|
||||||
|
if (pageId.includes(':')) {
|
||||||
|
const newPageId = pageId.split(':').at(-1) ?? pageId;
|
||||||
|
const newDoc = new YDoc();
|
||||||
|
// clone the original doc. yjs is not happy to use the same doc instance
|
||||||
|
applyUpdate(newDoc, encodeStateAsUpdate(doc));
|
||||||
|
newDoc.guid = doc.guid;
|
||||||
|
spaces.set(newPageId, newDoc);
|
||||||
|
// should remove the old doc, otherwise we will do it again in the next run
|
||||||
|
spaces.delete(pageId);
|
||||||
|
changed = true;
|
||||||
|
console.debug(
|
||||||
|
`fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
export enum WorkspaceVersion {
|
export enum WorkspaceVersion {
|
||||||
// v1 is treated as undefined
|
// v1 is treated as undefined
|
||||||
SubDoc = 2,
|
SubDoc = 2,
|
||||||
|
|||||||
@@ -98,38 +98,21 @@ export const createLazyProvider = (
|
|||||||
async function syncDoc(doc: Doc) {
|
async function syncDoc(doc: Doc) {
|
||||||
const guid = doc.guid;
|
const guid = doc.guid;
|
||||||
{
|
{
|
||||||
// backport from `@blocksuite/store`
|
const update = await datasource.queryDocState(guid);
|
||||||
const prefixId = guid.startsWith('space:') ? guid.slice(6) : guid;
|
|
||||||
const possible1 = `${rootDoc.guid}:space:${prefixId}`;
|
|
||||||
const possible2 = `space:${prefixId}`;
|
|
||||||
const update1 = await datasource.queryDocState(possible1);
|
|
||||||
const update2 = await datasource.queryDocState(possible2);
|
|
||||||
let hasUpdate = false;
|
let hasUpdate = false;
|
||||||
if (
|
if (
|
||||||
update1 &&
|
update &&
|
||||||
update1.missing.length !== 2 &&
|
update.missing.length !== 2 &&
|
||||||
update1.missing[0] !== 0 &&
|
update.missing[0] !== 0 &&
|
||||||
update1.missing[1] !== 0
|
update.missing[1] !== 0
|
||||||
) {
|
) {
|
||||||
applyUpdate(doc, update1.missing, origin);
|
applyUpdate(doc, update.missing, origin);
|
||||||
hasUpdate = true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
update2 &&
|
|
||||||
update2.missing.length !== 2 &&
|
|
||||||
update2.missing[0] !== 0 &&
|
|
||||||
update2.missing[1] !== 0
|
|
||||||
) {
|
|
||||||
applyUpdate(doc, update2.missing, origin);
|
|
||||||
hasUpdate = true;
|
hasUpdate = true;
|
||||||
}
|
}
|
||||||
if (hasUpdate) {
|
if (hasUpdate) {
|
||||||
await datasource.sendDocUpdate(
|
await datasource.sendDocUpdate(
|
||||||
guid,
|
guid,
|
||||||
encodeStateAsUpdate(
|
encodeStateAsUpdate(doc, update ? update.state : undefined)
|
||||||
doc,
|
|
||||||
update1 ? update1.state : update2 ? update2.state : undefined
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"builder-util-runtime": "^9.2.1",
|
"builder-util-runtime": "^9.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"electron": "^27.0.0",
|
"electron": "^27.0.0",
|
||||||
"electron-log": "^5.0.0-rc.1",
|
"electron-log": "^5.0.0",
|
||||||
"electron-squirrel-startup": "1.0.0",
|
"electron-squirrel-startup": "1.0.0",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"esbuild": "^0.19.4",
|
"esbuild": "^0.19.4",
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ function spawnOrReloadElectron() {
|
|||||||
if (code && code !== 0) {
|
if (code && code !== 0) {
|
||||||
console.log(`Electron exited with code ${code}`);
|
console.log(`Electron exited with code ${code}`);
|
||||||
}
|
}
|
||||||
process.exit(code ?? 0);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '@affine/native';
|
} from '@affine/native';
|
||||||
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
|
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
|
||||||
|
|
||||||
import { migrateToLatest } from '../db/migration';
|
import { applyGuidCompatibilityFix, migrateToLatest } from '../db/migration';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +29,7 @@ export abstract class BaseSQLiteAdapter {
|
|||||||
if (maxVersion !== WorkspaceVersion.Surface) {
|
if (maxVersion !== WorkspaceVersion.Surface) {
|
||||||
await migrateToLatest(this.path, WorkspaceVersion.Surface);
|
await migrateToLatest(this.path, WorkspaceVersion.Surface);
|
||||||
}
|
}
|
||||||
|
await applyGuidCompatibilityFix(this.db);
|
||||||
logger.info(`[SQLiteAdapter:${this.role}]`, 'connected:', this.path);
|
logger.info(`[SQLiteAdapter:${this.role}]`, 'connected:', this.path);
|
||||||
}
|
}
|
||||||
return this.db;
|
return this.db;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
|||||||
import { Schema } from '@blocksuite/store';
|
import { Schema } from '@blocksuite/store';
|
||||||
import {
|
import {
|
||||||
forceUpgradePages,
|
forceUpgradePages,
|
||||||
|
guidCompatibilityFix,
|
||||||
migrateToSubdoc,
|
migrateToSubdoc,
|
||||||
WorkspaceVersion,
|
WorkspaceVersion,
|
||||||
} from '@toeverything/infra/blocksuite';
|
} from '@toeverything/infra/blocksuite';
|
||||||
@@ -119,3 +120,21 @@ async function replaceRows(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const applyGuidCompatibilityFix = async (db: SqliteConnection) => {
|
||||||
|
const oldRows = await db.getUpdates(undefined);
|
||||||
|
|
||||||
|
const rootDoc = new YDoc();
|
||||||
|
oldRows.forEach(row => applyUpdate(rootDoc, row.data));
|
||||||
|
|
||||||
|
// see comments of guidCompatibilityFix
|
||||||
|
guidCompatibilityFix(rootDoc);
|
||||||
|
|
||||||
|
// todo: backup?
|
||||||
|
await db.replaceUpdates(undefined, [
|
||||||
|
{
|
||||||
|
docId: undefined,
|
||||||
|
data: encodeStateAsUpdate(rootDoc),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { shell } from 'electron';
|
import { shell } from 'electron';
|
||||||
|
import { app } from 'electron';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
|
||||||
export const logger = log.scope('main');
|
export const logger = log.scope('main');
|
||||||
@@ -12,3 +13,7 @@ export async function revealLogFile() {
|
|||||||
const filePath = getLogFilePath();
|
const filePath = getLogFilePath();
|
||||||
return await shell.openPath(filePath);
|
return await shell.openPath(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
log.transports.console.level = false;
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { updaterSubjects } from './event';
|
|||||||
const mode = process.env.NODE_ENV;
|
const mode = process.env.NODE_ENV;
|
||||||
const isDev = mode === 'development';
|
const isDev = mode === 'development';
|
||||||
|
|
||||||
|
// skip auto update in dev mode & internal
|
||||||
|
const disabled = buildType === 'internal' || isDev;
|
||||||
|
|
||||||
export const quitAndInstall = async () => {
|
export const quitAndInstall = async () => {
|
||||||
autoUpdater.quitAndInstall();
|
autoUpdater.quitAndInstall();
|
||||||
};
|
};
|
||||||
@@ -17,7 +20,7 @@ export const quitAndInstall = async () => {
|
|||||||
let lastCheckTime = 0;
|
let lastCheckTime = 0;
|
||||||
export const checkForUpdates = async (force = true) => {
|
export const checkForUpdates = async (force = true) => {
|
||||||
// check every 30 minutes (1800 seconds) at most
|
// check every 30 minutes (1800 seconds) at most
|
||||||
if (force || lastCheckTime + 1000 * 1800 < Date.now()) {
|
if (!disabled && (force || lastCheckTime + 1000 * 1800 < Date.now())) {
|
||||||
lastCheckTime = Date.now();
|
lastCheckTime = Date.now();
|
||||||
return await autoUpdater.checkForUpdates();
|
return await autoUpdater.checkForUpdates();
|
||||||
}
|
}
|
||||||
@@ -25,8 +28,7 @@ export const checkForUpdates = async (force = true) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const registerUpdater = async () => {
|
export const registerUpdater = async () => {
|
||||||
// skip auto update in dev mode & internal
|
if (disabled) {
|
||||||
if (buildType === 'internal' || isDev) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +45,6 @@ export const registerUpdater = async () => {
|
|||||||
channel: buildType,
|
channel: buildType,
|
||||||
// hack for custom provider
|
// hack for custom provider
|
||||||
provider: 'custom' as 'github',
|
provider: 'custom' as 'github',
|
||||||
// @ts-expect-error - just ignore for now
|
|
||||||
repo: buildType !== 'internal' ? 'AFFiNE' : 'AFFiNE-Releases',
|
repo: buildType !== 'internal' ? 'AFFiNE' : 'AFFiNE-Releases',
|
||||||
owner: 'toeverything',
|
owner: 'toeverything',
|
||||||
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
|
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
|
||||||
|
|||||||
@@ -76,12 +76,25 @@ describe('useBlockSuitePagePreview', () => {
|
|||||||
page.getBlockByFlavour('affine:note')[0].id
|
page.getBlockByFlavour('affine:note')[0].id
|
||||||
);
|
);
|
||||||
const hook = renderHook(() => useAtomValue(useBlockSuitePagePreview(page)));
|
const hook = renderHook(() => useAtomValue(useBlockSuitePagePreview(page)));
|
||||||
expect(hook.result.current).toBe('\nHello, world!');
|
expect(hook.result.current).toBe('Hello, world!');
|
||||||
page.transact(() => {
|
page.transact(() => {
|
||||||
page.getBlockById(id)!.text!.insert('Test', 0);
|
page.getBlockById(id)!.text!.insert('Test', 0);
|
||||||
});
|
});
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
hook.rerender();
|
hook.rerender();
|
||||||
expect(hook.result.current).toBe('\nTestHello, world!');
|
expect(hook.result.current).toBe('TestHello, world!');
|
||||||
|
|
||||||
|
// Insert before
|
||||||
|
page.addBlock(
|
||||||
|
'affine:paragraph',
|
||||||
|
{
|
||||||
|
text: new page.Text('First block!'),
|
||||||
|
},
|
||||||
|
page.getBlockByFlavour('affine:note')[0].id,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
hook.rerender();
|
||||||
|
expect(hook.result.current).toBe('First block! TestHello, world!');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,49 @@
|
|||||||
import type { ParagraphBlockModel } from '@blocksuite/blocks/models';
|
|
||||||
import type { Page } from '@blocksuite/store';
|
import type { Page } from '@blocksuite/store';
|
||||||
import type { Atom } from 'jotai';
|
import type { Atom } from 'jotai';
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
const MAX_PREVIEW_LENGTH = 150;
|
||||||
|
const MAX_SEARCH_BLOCK_COUNT = 30;
|
||||||
|
|
||||||
const weakMap = new WeakMap<Page, Atom<string>>();
|
const weakMap = new WeakMap<Page, Atom<string>>();
|
||||||
|
|
||||||
export const getPagePreviewText = (page: Page) => {
|
export const getPagePreviewText = (page: Page) => {
|
||||||
// TODO this is incorrect, since the order of blocks is not guaranteed
|
const pageRoot = page.root;
|
||||||
const paragraphBlocks = page.getBlockByFlavour(
|
if (!pageRoot) {
|
||||||
'affine:paragraph'
|
return '';
|
||||||
) as ParagraphBlockModel[];
|
}
|
||||||
const text = paragraphBlocks
|
const preview: string[] = [];
|
||||||
.slice(0, 10)
|
// DFS
|
||||||
.map(block => block.text.toString())
|
const queue = [pageRoot];
|
||||||
.join('\n');
|
let previewLenNeeded = MAX_PREVIEW_LENGTH;
|
||||||
return text.slice(0, 300);
|
let count = MAX_SEARCH_BLOCK_COUNT;
|
||||||
|
while (queue.length && previewLenNeeded > 0 && count-- > 0) {
|
||||||
|
const block = queue.shift();
|
||||||
|
if (!block) {
|
||||||
|
console.error('Unexpected empty block');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (block.children) {
|
||||||
|
queue.unshift(...block.children);
|
||||||
|
}
|
||||||
|
if (block.role !== 'content') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block.text) {
|
||||||
|
const text = block.text.toString();
|
||||||
|
if (!text.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
previewLenNeeded -= text.length;
|
||||||
|
preview.push(text);
|
||||||
|
} else {
|
||||||
|
// image/attachment/bookmark
|
||||||
|
const type = block.flavour.split('affine:')[1] ?? null;
|
||||||
|
previewLenNeeded -= type.length + 2;
|
||||||
|
type && preview.push(`[${type}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return preview.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyAtom = atom<string>('');
|
const emptyAtom = atom<string>('');
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function loadPage(page: Page, priority = 0) {
|
|||||||
logger.debug('page loaded', page.id);
|
logger.debug('page loaded', page.id);
|
||||||
// we do not know how long it takes to load a page here
|
// we do not know how long it takes to load a page here
|
||||||
// so that we just use 300ms timeout as the default page processing time
|
// so that we just use 300ms timeout as the default page processing time
|
||||||
await awaitForTimeout(1000);
|
await awaitForTimeout(300);
|
||||||
} else {
|
} else {
|
||||||
// do nothing if it is already loaded
|
// do nothing if it is already loaded
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ __metadata:
|
|||||||
builder-util-runtime: "npm:^9.2.1"
|
builder-util-runtime: "npm:^9.2.1"
|
||||||
cross-env: "npm:^7.0.3"
|
cross-env: "npm:^7.0.3"
|
||||||
electron: "npm:^27.0.0"
|
electron: "npm:^27.0.0"
|
||||||
electron-log: "npm:^5.0.0-rc.1"
|
electron-log: "npm:^5.0.0"
|
||||||
electron-squirrel-startup: "npm:1.0.0"
|
electron-squirrel-startup: "npm:1.0.0"
|
||||||
electron-updater: "npm:^6.1.5"
|
electron-updater: "npm:^6.1.5"
|
||||||
electron-window-state: "npm:^5.0.3"
|
electron-window-state: "npm:^5.0.3"
|
||||||
@@ -19145,10 +19145,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"electron-log@npm:^5.0.0-rc.1":
|
"electron-log@npm:^5.0.0":
|
||||||
version: 5.0.0-rc.1
|
version: 5.0.0
|
||||||
resolution: "electron-log@npm:5.0.0-rc.1"
|
resolution: "electron-log@npm:5.0.0"
|
||||||
checksum: f4ec437197ec5801a325e062c19f182a14eba960ee683034bfea5854efe452cfa91985b7f7ab159599c8264fde869f7101d4e82303231865a6b7c8e621815f87
|
checksum: 23b14119a5753be24880e7466ee80ae1386f9df4123ed59bc8f4426a814c728875b07de13bf0729cba7202888fcd6230375e2b5302cee0d0c5f25584d9db3334
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user