mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 05:48:59 +08:00
feat: init disk remote source
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,20 @@ 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
@@ -0,0 +1,79 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
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,8 +1,9 @@
|
||||
import '@affine/core/bootstrap/electron';
|
||||
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { apis, events } 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,
|
||||
@@ -14,14 +15,19 @@ 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,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
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,4 +1,5 @@
|
||||
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';
|
||||
@@ -6,12 +7,14 @@ 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,30 +82,33 @@ function createSharedStorageApi(
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
// 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 = Promise.resolve();
|
||||
|
||||
return {
|
||||
ready: initPromise,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export function shouldReloadDiskSyncSession(
|
||||
enabled: boolean,
|
||||
previousFolder: string | null,
|
||||
nextFolder: string | null
|
||||
) {
|
||||
return enabled && previousFolder !== nextFolder;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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
@@ -0,0 +1,116 @@
|
||||
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,6 +9,7 @@ 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';
|
||||
|
||||
@@ -35,6 +36,11 @@ 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,6 +287,14 @@ 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',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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,19 +1,36 @@
|
||||
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() {
|
||||
this.flags.enable_ai.$.pipe(distinctUntilChanged(), skip(1)).subscribe(
|
||||
() => {
|
||||
// when enable_ai flag changes, reload the page.
|
||||
window.location.reload();
|
||||
}
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
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,6 +6,7 @@ import {
|
||||
type ListedBlobRecord,
|
||||
universalId,
|
||||
} from '@affine/nbstore';
|
||||
import { DiskDocStorage } from '@affine/nbstore/disk';
|
||||
import {
|
||||
IndexedDBBlobStorage,
|
||||
IndexedDBBlobSyncStorage,
|
||||
@@ -46,6 +47,7 @@ import type {
|
||||
WorkspaceProfileInfo,
|
||||
} from '../../workspace';
|
||||
import { WorkspaceImpl } from '../../workspace/impls/workspace';
|
||||
import { getDiskSyncRemoteOptions } from './disk-config';
|
||||
import { getWorkspaceProfileWorker } from './out-worker';
|
||||
import {
|
||||
dedupeWorkspaceIds,
|
||||
@@ -430,6 +432,7 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
}
|
||||
|
||||
getEngineWorkerInitOptions(workspaceId: string): WorkerInitOptions {
|
||||
const disk = getDiskSyncRemoteOptions(workspaceId);
|
||||
return {
|
||||
local: {
|
||||
doc: {
|
||||
@@ -488,6 +491,21 @@ 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
|
||||
? {
|
||||
|
||||
@@ -11,9 +11,11 @@ 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",
|
||||
@@ -24,6 +26,7 @@ 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 }
|
||||
@@ -32,7 +35,6 @@ 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,6 +40,51 @@ 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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
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.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.`)
|
||||
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.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -579,6 +579,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use affine_common::doc_parser::{build_full_doc, parse_doc_to_markdown, update_doc};
|
||||
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
|
||||
use tokio::{sync::Mutex, task::JoinHandle};
|
||||
|
||||
use super::{
|
||||
DiskDocClock, DiskDocUpdateInput, DiskSessionOptions, DiskSyncDocUpdateEvent, DiskSyncEvent,
|
||||
frontmatter::{normalize_tags, parse_frontmatter, render_frontmatter},
|
||||
root_meta::{build_root_meta_update, extract_all_root_meta, extract_root_meta_for_doc},
|
||||
state_db::StateDb,
|
||||
types::{Baseline, FrontmatterMeta},
|
||||
utils::{
|
||||
collect_markdown_files, derive_title_from_markdown, derive_title_from_path, generate_missing_doc_id, hash_meta,
|
||||
hash_string, is_empty_update, merge_update_binary, now_naive, paths_equal, sanitize_file_stem, write_atomic,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DiskSession {
|
||||
workspace_id: String,
|
||||
sync_folder: PathBuf,
|
||||
state_db: StateDb,
|
||||
events: Arc<Mutex<VecDeque<DiskSyncEvent>>>,
|
||||
docs: Arc<Mutex<HashMap<String, Vec<u8>>>>,
|
||||
root_doc: Arc<Mutex<Vec<u8>>>,
|
||||
bindings: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||
path_bindings: Arc<Mutex<HashMap<PathBuf, String>>>,
|
||||
baselines: Arc<Mutex<HashMap<String, Baseline>>>,
|
||||
missing_logged: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
last_sync: Arc<Mutex<HashMap<String, chrono::NaiveDateTime>>>,
|
||||
last_error: Arc<Mutex<Option<String>>>,
|
||||
subscribers: Arc<Mutex<HashMap<u64, Arc<ThreadsafeFunction<DiskSyncEvent, ()>>>>>,
|
||||
poll_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
closed: Arc<AtomicBool>,
|
||||
scan_guard: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl DiskSession {
|
||||
pub(crate) async fn new(options: DiskSessionOptions) -> Result<Self, String> {
|
||||
let sync_folder = PathBuf::from(&options.sync_folder);
|
||||
fs::create_dir_all(&sync_folder)
|
||||
.map_err(|err| format!("failed to create sync folder {}: {}", sync_folder.display(), err))?;
|
||||
|
||||
let state_db = StateDb::open(&sync_folder, &options.workspace_id).await?;
|
||||
let bindings = state_db.load_bindings().await?;
|
||||
let baselines = state_db.load_baselines().await?;
|
||||
|
||||
let mut path_bindings = HashMap::new();
|
||||
for (doc_id, file_path) in &bindings {
|
||||
path_bindings.insert(file_path.clone(), doc_id.clone());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
workspace_id: options.workspace_id,
|
||||
sync_folder,
|
||||
state_db,
|
||||
events: Arc::new(Mutex::new(VecDeque::new())),
|
||||
docs: Arc::new(Mutex::new(HashMap::new())),
|
||||
root_doc: Arc::new(Mutex::new(Vec::new())),
|
||||
bindings: Arc::new(Mutex::new(bindings)),
|
||||
path_bindings: Arc::new(Mutex::new(path_bindings)),
|
||||
baselines: Arc::new(Mutex::new(baselines)),
|
||||
missing_logged: Arc::new(Mutex::new(HashSet::new())),
|
||||
last_sync: Arc::new(Mutex::new(HashMap::new())),
|
||||
last_error: Arc::new(Mutex::new(None)),
|
||||
subscribers: Arc::new(Mutex::new(HashMap::new())),
|
||||
poll_task: Arc::new(Mutex::new(None)),
|
||||
closed: Arc::new(AtomicBool::new(false)),
|
||||
scan_guard: Arc::new(Mutex::new(())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn close(&self) {
|
||||
self.closed.store(true, Ordering::Relaxed);
|
||||
self.stop_poll_task().await;
|
||||
self.subscribers.lock().await.clear();
|
||||
self.state_db.close().await;
|
||||
}
|
||||
|
||||
pub(crate) async fn add_subscriber(
|
||||
&self,
|
||||
subscriber_id: u64,
|
||||
callback: ThreadsafeFunction<DiskSyncEvent, ()>,
|
||||
) -> Result<(), String> {
|
||||
let callback = Arc::new(callback);
|
||||
|
||||
let backlog = {
|
||||
let mut events = self.events.lock().await;
|
||||
events.drain(..).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
{
|
||||
let mut subscribers = self.subscribers.lock().await;
|
||||
subscribers.insert(subscriber_id, callback.clone());
|
||||
}
|
||||
|
||||
for event in backlog {
|
||||
let _ = callback.call(Ok(event), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
|
||||
self.ensure_poll_task().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_subscriber(&self, subscriber_id: u64) {
|
||||
let should_stop = {
|
||||
let mut subscribers = self.subscribers.lock().await;
|
||||
subscribers.remove(&subscriber_id);
|
||||
subscribers.is_empty()
|
||||
};
|
||||
|
||||
if should_stop {
|
||||
self.stop_poll_task().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_poll_task(&self) {
|
||||
if self.closed.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let has_subscribers = {
|
||||
let subscribers = self.subscribers.lock().await;
|
||||
!subscribers.is_empty()
|
||||
};
|
||||
if !has_subscribers {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut poll_task = self.poll_task.lock().await;
|
||||
if poll_task.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let poll_interval_ms = std::env::var("AFFINE_DISK_POLL_INTERVAL_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(500);
|
||||
|
||||
let session = self.clone();
|
||||
*poll_task = Some(tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(poll_interval_ms));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
if session.closed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let has_subscribers = {
|
||||
let subscribers = session.subscribers.lock().await;
|
||||
!subscribers.is_empty()
|
||||
};
|
||||
if !has_subscribers {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Err(err) = session.scan_once().await {
|
||||
session.queue_error_event(err).await;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async fn stop_poll_task(&self) {
|
||||
let mut poll_task = self.poll_task.lock().await;
|
||||
if let Some(handle) = poll_task.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn queue_ready_event(&self) -> Result<(), String> {
|
||||
self
|
||||
.emit_event(DiskSyncEvent {
|
||||
r#type: "ready".to_string(),
|
||||
update: None,
|
||||
doc_id: None,
|
||||
timestamp: None,
|
||||
origin: None,
|
||||
message: None,
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_event(&self, event: DiskSyncEvent) {
|
||||
let subscribers = {
|
||||
let subscribers = self.subscribers.lock().await;
|
||||
subscribers.values().cloned().collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if subscribers.is_empty() {
|
||||
let mut events = self.events.lock().await;
|
||||
events.push_back(event);
|
||||
return;
|
||||
}
|
||||
|
||||
for callback in subscribers {
|
||||
let _ = callback.call(Ok(event.clone()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
}
|
||||
|
||||
async fn queue_error_event(&self, message: impl Into<String>) {
|
||||
let message = message.into();
|
||||
{
|
||||
let mut last_error = self.last_error.lock().await;
|
||||
*last_error = Some(message.clone());
|
||||
}
|
||||
|
||||
self
|
||||
.emit_event(DiskSyncEvent {
|
||||
r#type: "error".to_string(),
|
||||
update: None,
|
||||
doc_id: None,
|
||||
timestamp: Some(now_naive()),
|
||||
origin: None,
|
||||
message: Some(message),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn queue_doc_update_event(&self, update: DiskSyncDocUpdateEvent, origin: Option<String>) {
|
||||
self
|
||||
.emit_event(DiskSyncEvent {
|
||||
r#type: "doc-update".to_string(),
|
||||
doc_id: Some(update.doc_id.clone()),
|
||||
timestamp: Some(update.timestamp),
|
||||
update: Some(update),
|
||||
origin,
|
||||
message: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn pull_events(&self) -> Result<Vec<DiskSyncEvent>, String> {
|
||||
if let Err(err) = self.scan_once().await {
|
||||
self.queue_error_event(err).await;
|
||||
}
|
||||
|
||||
let mut events = self.events.lock().await;
|
||||
let mut drained = Vec::with_capacity(events.len());
|
||||
while let Some(event) = events.pop_front() {
|
||||
drained.push(event);
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
pub(crate) async fn scan_once(&self) -> Result<(), String> {
|
||||
let _guard = self.scan_guard.lock().await;
|
||||
|
||||
let mut markdown_files = Vec::new();
|
||||
collect_markdown_files(&self.sync_folder, &mut markdown_files)?;
|
||||
|
||||
let mut seen_paths = HashSet::new();
|
||||
for file_path in markdown_files {
|
||||
seen_paths.insert(file_path.clone());
|
||||
if let Err(err) = self.import_file_if_changed(&file_path).await {
|
||||
self.state_db.append_event(None, "import-error", &err).await.ok();
|
||||
self.queue_error_event(err).await;
|
||||
}
|
||||
}
|
||||
|
||||
self.handle_missing_files(&seen_paths).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_missing_files(&self, seen_paths: &HashSet<PathBuf>) -> Result<(), String> {
|
||||
let path_bindings = self.path_bindings.lock().await.clone();
|
||||
let mut missing_logged = self.missing_logged.lock().await;
|
||||
|
||||
for (path, doc_id) in path_bindings {
|
||||
if seen_paths.contains(&path) {
|
||||
missing_logged.remove(&path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if missing_logged.contains(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
missing_logged.insert(path.clone());
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "file-missing", &path.to_string_lossy())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import_file_if_changed(&self, file_path: &Path) -> Result<(), String> {
|
||||
let raw = fs::read_to_string(file_path)
|
||||
.map_err(|err| format!("failed to read markdown file {}: {}", file_path.display(), err))?;
|
||||
|
||||
let (mut meta, body) = parse_frontmatter(&raw);
|
||||
let mut doc_id = meta.id.clone();
|
||||
|
||||
if doc_id.is_none() {
|
||||
doc_id = Some(generate_missing_doc_id(file_path));
|
||||
meta.id = doc_id.clone();
|
||||
|
||||
let rendered = render_frontmatter(&meta, &body);
|
||||
write_atomic(file_path, &rendered)?;
|
||||
}
|
||||
|
||||
let doc_id = doc_id.ok_or_else(|| format!("failed to resolve doc id for markdown file {}", file_path.display()))?;
|
||||
|
||||
let title = meta
|
||||
.title
|
||||
.clone()
|
||||
.or_else(|| derive_title_from_markdown(&body))
|
||||
.unwrap_or_else(|| derive_title_from_path(file_path));
|
||||
|
||||
let normalized_meta = FrontmatterMeta {
|
||||
id: Some(doc_id.clone()),
|
||||
title: Some(title),
|
||||
tags: normalize_tags(meta.tags.clone()),
|
||||
favorite: Some(meta.favorite.unwrap_or(false)),
|
||||
trash: Some(meta.trash.unwrap_or(false)),
|
||||
};
|
||||
|
||||
let md_hash = hash_string(&body);
|
||||
let meta_hash = hash_meta(&normalized_meta);
|
||||
|
||||
let baseline = {
|
||||
let baselines = self.baselines.lock().await;
|
||||
baselines.get(&doc_id).cloned()
|
||||
};
|
||||
|
||||
let current_binding = {
|
||||
let bindings = self.bindings.lock().await;
|
||||
bindings.get(&doc_id).cloned()
|
||||
};
|
||||
|
||||
let unchanged = baseline
|
||||
.as_ref()
|
||||
.zip(current_binding.as_ref())
|
||||
.map(|(baseline, bound_path)| {
|
||||
baseline.md_hash == md_hash && baseline.meta_hash == meta_hash && paths_equal(bound_path, file_path)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if unchanged {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (page_update, full_doc) = {
|
||||
let docs = self.docs.lock().await;
|
||||
let maybe_existing = docs.get(&doc_id).cloned();
|
||||
|
||||
match maybe_existing {
|
||||
Some(existing_bin) if !is_empty_update(&existing_bin) => {
|
||||
let delta = update_doc(&existing_bin, &body, &doc_id)
|
||||
.map_err(|err| format!("failed to update doc from markdown {}: {}", doc_id, err))?;
|
||||
let merged = merge_update_binary(Some(&existing_bin), &delta, Some(&doc_id))?;
|
||||
(delta, merged)
|
||||
}
|
||||
_ => {
|
||||
let built = build_full_doc(normalized_meta.title.as_deref().unwrap_or("Untitled"), &body, &doc_id)
|
||||
.map_err(|err| format!("failed to build doc from markdown {}: {}", doc_id, err))?;
|
||||
(built.clone(), built)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut docs = self.docs.lock().await;
|
||||
docs.insert(doc_id.clone(), full_doc);
|
||||
}
|
||||
|
||||
let now = now_naive();
|
||||
self
|
||||
.queue_doc_update_event(
|
||||
DiskSyncDocUpdateEvent {
|
||||
doc_id: doc_id.clone(),
|
||||
bin: page_update.into(),
|
||||
timestamp: now,
|
||||
editor: None,
|
||||
},
|
||||
Some("disk:file-import".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
self.apply_root_meta_from_file(&doc_id, &normalized_meta, now).await?;
|
||||
|
||||
{
|
||||
let mut bindings = self.bindings.lock().await;
|
||||
let mut path_bindings = self.path_bindings.lock().await;
|
||||
|
||||
if let Some(prev) = bindings.insert(doc_id.clone(), file_path.to_path_buf()) {
|
||||
path_bindings.remove(&prev);
|
||||
}
|
||||
path_bindings.insert(file_path.to_path_buf(), doc_id.clone());
|
||||
}
|
||||
|
||||
self.state_db.upsert_binding(&doc_id, file_path).await.map_err(|err| {
|
||||
format!(
|
||||
"failed to persist binding for doc {} path {}: {}",
|
||||
doc_id,
|
||||
file_path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
|
||||
let baseline = Baseline {
|
||||
base_clock: String::new(),
|
||||
base_vector: String::new(),
|
||||
md_hash,
|
||||
meta_hash,
|
||||
synced_at: now,
|
||||
};
|
||||
|
||||
{
|
||||
let mut baselines = self.baselines.lock().await;
|
||||
baselines.insert(doc_id.clone(), baseline.clone());
|
||||
}
|
||||
|
||||
self
|
||||
.state_db
|
||||
.upsert_baseline(&doc_id, &baseline)
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist baseline for doc {}: {}", doc_id, err))?;
|
||||
|
||||
{
|
||||
let mut last_sync = self.last_sync.lock().await;
|
||||
last_sync.insert(doc_id.clone(), now);
|
||||
}
|
||||
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "import", &file_path.to_string_lossy())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_root_meta_from_file(
|
||||
&self,
|
||||
doc_id: &str,
|
||||
meta: &FrontmatterMeta,
|
||||
timestamp: chrono::NaiveDateTime,
|
||||
) -> Result<(), String> {
|
||||
let current_root = self.root_doc.lock().await.clone();
|
||||
let delta = build_root_meta_update(¤t_root, &self.workspace_id, doc_id, meta)?;
|
||||
|
||||
if is_empty_update(&delta) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let merged = merge_update_binary(Some(¤t_root), &delta, Some(&self.workspace_id))?;
|
||||
{
|
||||
let mut root = self.root_doc.lock().await;
|
||||
*root = merged;
|
||||
}
|
||||
|
||||
self
|
||||
.queue_doc_update_event(
|
||||
DiskSyncDocUpdateEvent {
|
||||
doc_id: self.workspace_id.clone(),
|
||||
bin: delta.into(),
|
||||
timestamp,
|
||||
editor: None,
|
||||
},
|
||||
Some("disk:file-meta".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_local_update(
|
||||
&self,
|
||||
update: DiskDocUpdateInput,
|
||||
origin: Option<String>,
|
||||
) -> Result<DiskDocClock, String> {
|
||||
// Serialize local updates with filesystem scanning/importing.
|
||||
//
|
||||
// Without this guard, root-meta exports and page exports can run concurrently
|
||||
// and race on the same markdown file/baseline, causing the file content to
|
||||
// flip between different snapshots while the client is editing.
|
||||
let _guard = self.scan_guard.lock().await;
|
||||
|
||||
let timestamp = now_naive();
|
||||
|
||||
if update.doc_id == self.workspace_id {
|
||||
self
|
||||
.apply_local_root_update(update.bin.as_ref().to_vec(), timestamp, origin)
|
||||
.await?;
|
||||
return Ok(DiskDocClock {
|
||||
doc_id: update.doc_id,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
self
|
||||
.apply_local_page_update(
|
||||
update.doc_id.clone(),
|
||||
update.bin.as_ref().to_vec(),
|
||||
update.editor,
|
||||
timestamp,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(DiskDocClock {
|
||||
doc_id: update.doc_id,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_local_root_update(
|
||||
&self,
|
||||
update_bin: Vec<u8>,
|
||||
timestamp: chrono::NaiveDateTime,
|
||||
_origin: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let current_root = self.root_doc.lock().await.clone();
|
||||
let merged_root = merge_update_binary(Some(¤t_root), &update_bin, Some(&self.workspace_id))?;
|
||||
|
||||
{
|
||||
let mut root = self.root_doc.lock().await;
|
||||
*root = merged_root.clone();
|
||||
}
|
||||
|
||||
let metas = extract_all_root_meta(&merged_root)?;
|
||||
|
||||
for (doc_id, meta) in metas {
|
||||
let binding_path = {
|
||||
let bindings = self.bindings.lock().await;
|
||||
bindings.get(&doc_id).cloned()
|
||||
};
|
||||
|
||||
let doc_body = if let Some(doc_bin) = self.docs.lock().await.get(&doc_id).cloned() {
|
||||
parse_doc_to_markdown(doc_bin, doc_id.clone(), true, None)
|
||||
.ok()
|
||||
.map(|result| result.markdown)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (body, body_from_doc) = if let Some(body) = doc_body {
|
||||
(body, true)
|
||||
} else if let Some(path) = binding_path.as_ref().filter(|path| path.exists()) {
|
||||
let existing = fs::read_to_string(path).map_err(|err| {
|
||||
format!(
|
||||
"failed to read markdown for metadata update {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
let (_, body) = parse_frontmatter(&existing);
|
||||
(body, false)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !body_from_doc && body.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = if let Some(path) = binding_path {
|
||||
path
|
||||
} else {
|
||||
self.resolve_file_path(&doc_id, meta.title.as_deref()).await?
|
||||
};
|
||||
|
||||
// Avoid overwriting local filesystem edits that haven't been imported yet.
|
||||
if self.is_markdown_dirty(&doc_id, &path).await {
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-root-skip-dirty", &path.to_string_lossy())
|
||||
.await
|
||||
.ok();
|
||||
continue;
|
||||
}
|
||||
|
||||
let meta_with_id = meta.clone().with_id(doc_id.clone());
|
||||
let rendered = render_frontmatter(&meta_with_id, &body);
|
||||
write_atomic(&path, &rendered)?;
|
||||
|
||||
let baseline = Baseline {
|
||||
base_clock: String::new(),
|
||||
base_vector: String::new(),
|
||||
md_hash: hash_string(&body),
|
||||
meta_hash: hash_meta(&meta_with_id),
|
||||
synced_at: timestamp,
|
||||
};
|
||||
|
||||
{
|
||||
let mut baselines = self.baselines.lock().await;
|
||||
baselines.insert(doc_id.clone(), baseline.clone());
|
||||
}
|
||||
self.state_db.upsert_baseline(&doc_id, &baseline).await?;
|
||||
}
|
||||
|
||||
self
|
||||
.state_db
|
||||
.append_event(None, "export-root-meta", "root metadata update applied")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_local_page_update(
|
||||
&self,
|
||||
doc_id: String,
|
||||
update_bin: Vec<u8>,
|
||||
editor: Option<String>,
|
||||
timestamp: chrono::NaiveDateTime,
|
||||
) -> Result<(), String> {
|
||||
// Internal docs (e.g. `db$folders`) are not page documents and are not
|
||||
// exportable to markdown. Avoid emitting noisy parser errors for them.
|
||||
if doc_id.starts_with("db$") {
|
||||
self
|
||||
.state_db
|
||||
.append_event(
|
||||
Some(&doc_id),
|
||||
"export-skip-internal",
|
||||
"internal doc skipped (not a page)",
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_doc = {
|
||||
let docs = self.docs.lock().await;
|
||||
docs.get(&doc_id).cloned()
|
||||
};
|
||||
|
||||
let merged_doc = match merge_update_binary(current_doc.as_deref(), &update_bin, Some(&doc_id)) {
|
||||
Ok(merged_doc) => merged_doc,
|
||||
Err(err) => {
|
||||
// A single malformed document update must not break the whole sync loop.
|
||||
// Otherwise every push retries globally and delays other documents.
|
||||
if current_doc.is_some() && err.contains("failed to apply existing update") {
|
||||
{
|
||||
let mut docs = self.docs.lock().await;
|
||||
docs.remove(&doc_id);
|
||||
}
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-recover-reset-doc-cache", &err)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
match merge_update_binary(None, &update_bin, Some(&doc_id)) {
|
||||
Ok(recovered) => recovered,
|
||||
Err(recover_err) => {
|
||||
self
|
||||
.state_db
|
||||
.append_event(
|
||||
Some(&doc_id),
|
||||
"export-skip-invalid-update",
|
||||
&format!("{err}; recover failed: {recover_err}"),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-skip-invalid-update", &err)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut docs = self.docs.lock().await;
|
||||
docs.insert(doc_id.clone(), merged_doc.clone());
|
||||
}
|
||||
|
||||
let markdown = match parse_doc_to_markdown(merged_doc, doc_id.clone(), true, None) {
|
||||
Ok(markdown) => markdown,
|
||||
Err(err) => {
|
||||
self
|
||||
.state_db
|
||||
.append_event(
|
||||
Some(&doc_id),
|
||||
"export-skip",
|
||||
&format!("failed to convert doc {} to markdown: {}", doc_id, err),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let meta = self.meta_for_doc(&doc_id, Some(markdown.title)).await?;
|
||||
let file_path = self.resolve_file_path(&doc_id, meta.title.as_deref()).await?;
|
||||
|
||||
// Avoid overwriting local filesystem edits that haven't been imported yet.
|
||||
// This is especially important when multiple export passes happen (e.g. page
|
||||
// update + root meta update) and users edit the markdown file in between
|
||||
// them.
|
||||
if self.is_markdown_dirty(&doc_id, &file_path).await {
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-skip-dirty", &file_path.to_string_lossy())
|
||||
.await
|
||||
.ok();
|
||||
let _ = editor;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let meta_with_id = meta.clone().with_id(doc_id.clone());
|
||||
let rendered = render_frontmatter(&meta_with_id, &markdown.markdown);
|
||||
write_atomic(&file_path, &rendered)?;
|
||||
|
||||
let baseline = Baseline {
|
||||
base_clock: String::new(),
|
||||
base_vector: String::new(),
|
||||
md_hash: hash_string(&markdown.markdown),
|
||||
meta_hash: hash_meta(&meta_with_id),
|
||||
synced_at: timestamp,
|
||||
};
|
||||
|
||||
{
|
||||
let mut baselines = self.baselines.lock().await;
|
||||
baselines.insert(doc_id.clone(), baseline.clone());
|
||||
}
|
||||
|
||||
self.state_db.upsert_baseline(&doc_id, &baseline).await?;
|
||||
|
||||
{
|
||||
let mut last_sync = self.last_sync.lock().await;
|
||||
last_sync.insert(doc_id.clone(), timestamp);
|
||||
}
|
||||
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-page", &file_path.to_string_lossy())
|
||||
.await?;
|
||||
|
||||
let _ = editor;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_markdown_dirty(&self, doc_id: &str, file_path: &Path) -> bool {
|
||||
if !file_path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No baseline means the file is not tracked by this session yet.
|
||||
// If it already exists, treat it as dirty and let the import path handle it.
|
||||
let baseline = {
|
||||
let baselines = self.baselines.lock().await;
|
||||
baselines.get(doc_id).cloned()
|
||||
};
|
||||
let Some(baseline) = baseline else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let raw = match fs::read_to_string(file_path) {
|
||||
Ok(raw) => raw,
|
||||
Err(err) => {
|
||||
self
|
||||
.state_db
|
||||
.append_event(
|
||||
Some(doc_id),
|
||||
"export-skip-read-error",
|
||||
&format!("{}: {}", file_path.display(), err),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
let (meta, body) = parse_frontmatter(&raw);
|
||||
let title = meta
|
||||
.title
|
||||
.clone()
|
||||
.or_else(|| derive_title_from_markdown(&body))
|
||||
.unwrap_or_else(|| derive_title_from_path(file_path));
|
||||
let normalized_meta = FrontmatterMeta {
|
||||
id: meta.id.clone().or_else(|| Some(doc_id.to_string())),
|
||||
title: Some(title),
|
||||
tags: normalize_tags(meta.tags.clone()),
|
||||
favorite: Some(meta.favorite.unwrap_or(false)),
|
||||
trash: Some(meta.trash.unwrap_or(false)),
|
||||
};
|
||||
|
||||
let md_hash = hash_string(&body);
|
||||
let meta_hash = hash_meta(&normalized_meta);
|
||||
|
||||
baseline.md_hash != md_hash || baseline.meta_hash != meta_hash
|
||||
}
|
||||
|
||||
async fn meta_for_doc(&self, doc_id: &str, fallback_title: Option<String>) -> Result<FrontmatterMeta, String> {
|
||||
let root = self.root_doc.lock().await.clone();
|
||||
let mut meta = extract_root_meta_for_doc(&root, doc_id)?.unwrap_or_default();
|
||||
|
||||
if meta.title.is_none() {
|
||||
meta.title = fallback_title;
|
||||
}
|
||||
if meta.tags.is_none() {
|
||||
meta.tags = Some(Vec::new());
|
||||
}
|
||||
if meta.favorite.is_none() {
|
||||
meta.favorite = Some(false);
|
||||
}
|
||||
if meta.trash.is_none() {
|
||||
meta.trash = Some(false);
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
async fn resolve_file_path(&self, doc_id: &str, title_hint: Option<&str>) -> Result<PathBuf, String> {
|
||||
if let Some(path) = self.bindings.lock().await.get(doc_id).cloned() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let base_name = sanitize_file_stem(title_hint.unwrap_or(doc_id));
|
||||
let mut index = 1usize;
|
||||
|
||||
loop {
|
||||
let candidate_name = if index == 1 {
|
||||
format!("{}.md", base_name)
|
||||
} else {
|
||||
format!("{}-{}.md", base_name, index)
|
||||
};
|
||||
let candidate = self.sync_folder.join(candidate_name);
|
||||
|
||||
let taken = {
|
||||
let path_bindings = self.path_bindings.lock().await;
|
||||
path_bindings.get(&candidate).cloned()
|
||||
};
|
||||
|
||||
if let Some(existing_doc_id) = taken {
|
||||
if existing_doc_id == doc_id {
|
||||
return Ok(candidate);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
let mut bindings = self.bindings.lock().await;
|
||||
bindings.insert(doc_id.to_string(), candidate.clone());
|
||||
}
|
||||
{
|
||||
let mut path_bindings = self.path_bindings.lock().await;
|
||||
path_bindings.insert(candidate.clone(), doc_id.to_string());
|
||||
}
|
||||
|
||||
self.state_db.upsert_binding(doc_id, &candidate).await?;
|
||||
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use sqlx::{
|
||||
Pool, Row, Sqlite,
|
||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
use super::{types::Baseline, utils::now_naive};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct StateDb {
|
||||
workspace_id: String,
|
||||
pool: Pool<Sqlite>,
|
||||
}
|
||||
|
||||
impl StateDb {
|
||||
pub(crate) async fn open(sync_folder: &Path, workspace_id: &str) -> Result<Self, String> {
|
||||
let state_dir = sync_folder.join(".affine-sync");
|
||||
fs::create_dir_all(&state_dir)
|
||||
.map_err(|err| format!("failed to create state dir {}: {}", state_dir.display(), err))?;
|
||||
|
||||
let db_path = state_dir.join("state.db");
|
||||
let connect_options = SqliteConnectOptions::new()
|
||||
.filename(&db_path)
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.map_err(|err| format!("failed to open state db {}: {}", db_path.display(), err))?;
|
||||
|
||||
let db = Self {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
pool,
|
||||
};
|
||||
|
||||
db.init().await?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn init(&self) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create schema_version table: {}", err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS bindings (
|
||||
workspace_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY(workspace_id, doc_id)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create bindings table: {}", err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_bindings_workspace_file
|
||||
ON bindings(workspace_id, file_path);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create bindings index: {}", err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS baselines (
|
||||
workspace_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
base_clock TEXT NOT NULL,
|
||||
base_vector TEXT NOT NULL,
|
||||
md_hash TEXT NOT NULL,
|
||||
meta_hash TEXT NOT NULL,
|
||||
synced_at DATETIME NOT NULL,
|
||||
PRIMARY KEY(workspace_id, doc_id)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create baselines table: {}", err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id TEXT NOT NULL,
|
||||
doc_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
ts DATETIME NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create events table: {}", err))?;
|
||||
|
||||
sqlx::query("INSERT OR IGNORE INTO schema_version(version) VALUES (1);")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to initialize schema_version: {}", err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_bindings(&self) -> Result<HashMap<String, PathBuf>, String> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT doc_id, file_path
|
||||
FROM bindings
|
||||
WHERE workspace_id = ? AND enabled = 1;
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to load bindings: {}", err))?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let doc_id: String = row.get("doc_id");
|
||||
let file_path: String = row.get("file_path");
|
||||
map.insert(doc_id, PathBuf::from(file_path));
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_binding(&self, doc_id: &str, file_path: &Path) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO bindings (workspace_id, doc_id, file_path, enabled, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?)
|
||||
ON CONFLICT(workspace_id, doc_id)
|
||||
DO UPDATE SET
|
||||
file_path = excluded.file_path,
|
||||
enabled = 1,
|
||||
updated_at = excluded.updated_at;
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.bind(doc_id)
|
||||
.bind(file_path.to_string_lossy().to_string())
|
||||
.bind(now_naive())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to upsert binding for doc {}: {}", doc_id, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_baselines(&self) -> Result<HashMap<String, Baseline>, String> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT doc_id, base_clock, base_vector, md_hash, meta_hash, synced_at
|
||||
FROM baselines
|
||||
WHERE workspace_id = ?;
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to load baselines: {}", err))?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let doc_id: String = row.get("doc_id");
|
||||
map.insert(
|
||||
doc_id,
|
||||
Baseline {
|
||||
base_clock: row.get("base_clock"),
|
||||
base_vector: row.get("base_vector"),
|
||||
md_hash: row.get("md_hash"),
|
||||
meta_hash: row.get("meta_hash"),
|
||||
synced_at: row.get("synced_at"),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_baseline(&self, doc_id: &str, baseline: &Baseline) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO baselines (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
base_clock,
|
||||
base_vector,
|
||||
md_hash,
|
||||
meta_hash,
|
||||
synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(workspace_id, doc_id)
|
||||
DO UPDATE SET
|
||||
base_clock = excluded.base_clock,
|
||||
base_vector = excluded.base_vector,
|
||||
md_hash = excluded.md_hash,
|
||||
meta_hash = excluded.meta_hash,
|
||||
synced_at = excluded.synced_at;
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.bind(doc_id)
|
||||
.bind(&baseline.base_clock)
|
||||
.bind(&baseline.base_vector)
|
||||
.bind(&baseline.md_hash)
|
||||
.bind(&baseline.meta_hash)
|
||||
.bind(baseline.synced_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to upsert baseline for doc {}: {}", doc_id, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn append_event(&self, doc_id: Option<&str>, kind: &str, payload: &str) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO events (workspace_id, doc_id, kind, ts, payload)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.bind(doc_id)
|
||||
.bind(kind)
|
||||
.bind(now_naive())
|
||||
.bind(payload)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to append event {}: {}", kind, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn close(&self) {
|
||||
self.pool.close().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use affine_common::doc_parser::{build_full_doc, update_doc};
|
||||
use chrono::Utc;
|
||||
use napi::bindgen_prelude::Uint8Array;
|
||||
use uuid::Uuid;
|
||||
use y_octo::{Any, DocOptions, Value};
|
||||
|
||||
use super::{
|
||||
DiskDocUpdateInput, DiskSessionOptions, DiskSync, frontmatter::parse_frontmatter, root_meta::build_root_meta_update,
|
||||
types::FrontmatterMeta, utils::collect_markdown_files,
|
||||
};
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"affine-disk-sync-{}-{}-{}",
|
||||
std::process::id(),
|
||||
Utc::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn build_doc_with_unsupported_block(doc_id: &str, title: &str, flavour: &str) -> Vec<u8> {
|
||||
let doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
let mut blocks = doc.get_or_create_map("blocks").expect("create blocks map");
|
||||
|
||||
let mut page = doc.create_map().expect("create page block");
|
||||
page.insert("sys:id".into(), "page").expect("set page id");
|
||||
page
|
||||
.insert("sys:flavour".into(), "affine:page")
|
||||
.expect("set page flavour");
|
||||
let mut page_children = doc.create_array().expect("create page children");
|
||||
page_children.push("note").expect("append page child");
|
||||
page
|
||||
.insert("sys:children".into(), Value::Array(page_children))
|
||||
.expect("set page children");
|
||||
let mut page_title = doc.create_text().expect("create page title");
|
||||
page_title.insert(0, title).expect("set page title");
|
||||
page
|
||||
.insert("prop:title".into(), Value::Text(page_title))
|
||||
.expect("set page title prop");
|
||||
blocks
|
||||
.insert("page".into(), Value::Map(page))
|
||||
.expect("insert page block");
|
||||
|
||||
let mut note = doc.create_map().expect("create note block");
|
||||
note.insert("sys:id".into(), "note").expect("set note id");
|
||||
note
|
||||
.insert("sys:flavour".into(), "affine:note")
|
||||
.expect("set note flavour");
|
||||
let mut note_children = doc.create_array().expect("create note children");
|
||||
note_children.push("unsupported").expect("append unsupported child");
|
||||
note
|
||||
.insert("sys:children".into(), Value::Array(note_children))
|
||||
.expect("set note children");
|
||||
note
|
||||
.insert("prop:displayMode".into(), "page")
|
||||
.expect("set note display mode");
|
||||
blocks
|
||||
.insert("note".into(), Value::Map(note))
|
||||
.expect("insert note block");
|
||||
|
||||
let mut unsupported = doc.create_map().expect("create unsupported block");
|
||||
unsupported
|
||||
.insert("sys:id".into(), "unsupported")
|
||||
.expect("set unsupported id");
|
||||
unsupported
|
||||
.insert("sys:flavour".into(), flavour)
|
||||
.expect("set unsupported flavour");
|
||||
unsupported
|
||||
.insert(
|
||||
"sys:children".into(),
|
||||
Value::Array(doc.create_array().expect("create unsupported children")),
|
||||
)
|
||||
.expect("set unsupported children");
|
||||
blocks
|
||||
.insert("unsupported".into(), Value::Map(unsupported))
|
||||
.expect("insert unsupported block");
|
||||
|
||||
doc.encode_update_v1().expect("encode unsupported doc")
|
||||
}
|
||||
|
||||
async fn teardown(sync: &DiskSync, session_id: &str, dir: &Path) {
|
||||
sync.stop_session(session_id.to_string()).await.expect("stop session");
|
||||
if dir.exists() {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_numeric_any(value: &Any) -> bool {
|
||||
match value {
|
||||
Any::Integer(_) | Any::BigInt64(_) => true,
|
||||
Any::Float32(v) => v.0.is_finite(),
|
||||
Any::Float64(v) => v.0.is_finite(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_supported_fields() {
|
||||
let raw = r#"---
|
||||
id: doc-1
|
||||
title: "Demo"
|
||||
tags:
|
||||
- alpha
|
||||
- beta
|
||||
favorite: true
|
||||
trash: false
|
||||
---
|
||||
|
||||
# Heading
|
||||
|
||||
Body.
|
||||
"#;
|
||||
|
||||
let (meta, body) = parse_frontmatter(raw);
|
||||
assert_eq!(meta.id.as_deref(), Some("doc-1"));
|
||||
assert_eq!(meta.title.as_deref(), Some("Demo"));
|
||||
assert_eq!(meta.tags, Some(vec!["alpha".to_string(), "beta".to_string()]));
|
||||
assert_eq!(meta.favorite, Some(true));
|
||||
assert_eq!(meta.trash, Some(false));
|
||||
assert!(body.contains("# Heading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_preserves_explicit_empty_title() {
|
||||
let raw = r#"---
|
||||
id: doc-empty-title
|
||||
title: ""
|
||||
---
|
||||
|
||||
Body
|
||||
"#;
|
||||
|
||||
let (meta, _) = parse_frontmatter(raw);
|
||||
assert_eq!(meta.id.as_deref(), Some("doc-empty-title"));
|
||||
assert_eq!(meta.title.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_session_imports_markdown_and_creates_state_db() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-a.md");
|
||||
fs::write(
|
||||
&md_path,
|
||||
"---\nid: doc-a\ntitle: A\ntags: [one,two]\n---\n\n# A\n\ncontent",
|
||||
)
|
||||
.expect("write markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-import";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-a".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let events = sync.pull_events(session_id.to_string()).await.expect("pull events");
|
||||
|
||||
assert!(events.iter().any(|event| event.r#type == "ready"));
|
||||
assert!(events.iter().any(|event| {
|
||||
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "doc-a")
|
||||
}));
|
||||
assert!(events.iter().any(|event| {
|
||||
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "ws-a")
|
||||
}));
|
||||
|
||||
assert!(dir.join(".affine-sync/state.db").exists());
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_update_exports_markdown_even_with_unsupported_block() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-export-unsupported";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-export-unsupported".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
|
||||
|
||||
let doc_bin = build_doc_with_unsupported_block("doc-unsupported", "Unsupported", "affine:latex");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: "doc-unsupported".to_string(),
|
||||
bin: Uint8Array::new(doc_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 1);
|
||||
|
||||
let content = fs::read_to_string(&exported_files[0]).expect("read exported markdown");
|
||||
assert!(content.contains("id: doc-unsupported"));
|
||||
assert!(content.contains("unsupported_block_flavour:affine:latex"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_update_exports_markdown_with_stable_id() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-export";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-export".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
|
||||
|
||||
let doc_bin = build_full_doc("Exported", "# Exported\n\nHello", "doc-export").expect("build doc bin");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: "doc-export".to_string(),
|
||||
bin: Uint8Array::new(doc_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 1);
|
||||
|
||||
let content = fs::read_to_string(&exported_files[0]).expect("read exported markdown");
|
||||
assert!(content.contains("id: doc-export"));
|
||||
assert!(content.contains("# Exported"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_title_export_does_not_trigger_self_import() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-empty-title";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-empty-title".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let doc_id = "doc-empty-title";
|
||||
let doc_bin = build_full_doc("", "", doc_id).expect("build empty-title doc");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(doc_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let events = sync
|
||||
.pull_events(session_id.to_string())
|
||||
.await
|
||||
.expect("pull after export");
|
||||
|
||||
assert!(!events.iter().any(|event| {
|
||||
event.r#type == "doc-update"
|
||||
&& event.origin.as_deref() == Some("disk:file-import")
|
||||
&& event.update.as_ref().is_some_and(|update| update.doc_id == doc_id)
|
||||
}));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_local_update_does_not_block_other_docs_exports() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-invalid-update";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-invalid-update".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let doc_a_id = "doc-invalid-a";
|
||||
let doc_a_bin = build_full_doc("A", "# A\n\none", doc_a_id).expect("build doc A");
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_a_id.to_string(),
|
||||
bin: Uint8Array::new(doc_a_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply doc A update");
|
||||
|
||||
let invalid = sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_a_id.to_string(),
|
||||
bin: Uint8Array::new(vec![1, 2, 3]),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert!(invalid.is_ok());
|
||||
|
||||
let doc_b_id = "doc-valid-b";
|
||||
let doc_b_bin = build_full_doc("B", "# B\n\ntwo", doc_b_id).expect("build doc B");
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_b_id.to_string(),
|
||||
bin: Uint8Array::new(doc_b_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply doc B update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert!(!exported_files.is_empty());
|
||||
|
||||
let mut found_doc_b = false;
|
||||
for file in exported_files {
|
||||
let content = fs::read_to_string(file).expect("read markdown");
|
||||
if content.contains(&format!("id: {doc_b_id}")) && content.contains("two") {
|
||||
found_doc_b = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_doc_b);
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_root_update_skips_metadata_only_placeholder_without_doc_body() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-root-meta-export";
|
||||
let workspace_id = "ws-root-meta-export";
|
||||
let doc_id = "doc-root-meta";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
|
||||
|
||||
let root_update = build_root_meta_update(
|
||||
&[],
|
||||
workspace_id,
|
||||
doc_id,
|
||||
&FrontmatterMeta {
|
||||
id: None,
|
||||
title: Some("Root Meta Title".to_string()),
|
||||
tags: Some(vec!["alpha".to_string()]),
|
||||
favorite: Some(true),
|
||||
trash: Some(false),
|
||||
},
|
||||
)
|
||||
.expect("build root meta update");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: workspace_id.to_string(),
|
||||
bin: Uint8Array::new(root_update),
|
||||
editor: None,
|
||||
},
|
||||
Some("origin:root-meta".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply root update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 0);
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_change_after_export_is_imported_into_workspace() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-export-import";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-export-import".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let doc_id = "doc-export-import";
|
||||
let doc_bin = build_full_doc("Export", "# Export\n\none", doc_id).expect("build doc bin");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(doc_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 1);
|
||||
|
||||
let file_path = exported_files[0].clone();
|
||||
fs::write(
|
||||
&file_path,
|
||||
format!(
|
||||
"---\nid: {doc_id}\ntitle: Export\ntags: [edited]\nfavorite: false\ntrash: false\n---\n\n# Export\n\nchanged"
|
||||
),
|
||||
)
|
||||
.expect("edit exported markdown");
|
||||
|
||||
let events = sync
|
||||
.pull_events(session_id.to_string())
|
||||
.await
|
||||
.expect("pull after local file edit");
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event.r#type == "doc-update"
|
||||
&& event.update.as_ref().is_some_and(|update| update.doc_id == doc_id)
|
||||
&& event.origin.as_deref() == Some("disk:file-import")
|
||||
}));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_block_update_keeps_markdown_exporting() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-code-block-export";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-code-block-export".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let doc_id = "doc-code-block";
|
||||
let initial_doc = build_full_doc("Code", "# Code\n\nbefore", doc_id).expect("build initial doc");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(initial_doc.clone()),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply initial doc");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 1);
|
||||
let file_path = exported_files[0].clone();
|
||||
|
||||
let markdown_with_code = "# Code\n\n```js\nconsole.log(1)\n```\n";
|
||||
let delta_code = update_doc(&initial_doc, markdown_with_code, doc_id).expect("build code block delta");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(delta_code.clone()),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply code block delta");
|
||||
|
||||
let mut doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
doc
|
||||
.apply_update_from_binary_v1(&initial_doc)
|
||||
.expect("apply initial update");
|
||||
doc.apply_update_from_binary_v1(&delta_code).expect("apply code update");
|
||||
let merged_after_code = doc.encode_update_v1().expect("encode merged after code");
|
||||
|
||||
let markdown_after_code_edit = "# Code\n\n```js\nconsole.log(2)\n```\n\nnext\n";
|
||||
let delta_after_code_edit =
|
||||
update_doc(&merged_after_code, markdown_after_code_edit, doc_id).expect("build delta after code edit");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(delta_after_code_edit),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply follow-up delta");
|
||||
|
||||
let exported = fs::read_to_string(&file_path).expect("read exported markdown");
|
||||
assert!(exported.contains("```js"));
|
||||
assert!(exported.contains("console.log(2)"));
|
||||
assert!(exported.contains("next"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_change_after_start_is_imported_via_pull_events() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-poll.md");
|
||||
fs::write(&md_path, "---\nid: doc-poll\ntitle: Poll\n---\n\n# Poll\n\none").expect("write initial markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-poll";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-poll".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
fs::write(&md_path, "---\nid: doc-poll\ntitle: Poll\n---\n\n# Poll\n\ntwo").expect("write changed markdown");
|
||||
|
||||
let events = sync
|
||||
.pull_events(session_id.to_string())
|
||||
.await
|
||||
.expect("pull after change");
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "doc-poll")
|
||||
}));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_without_title_allows_followup_local_export() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-no-title.md");
|
||||
fs::write(&md_path, "# Imported\n\none").expect("write markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-import-no-title";
|
||||
let workspace_id = "ws-import-no-title";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let events = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let imported = fs::read_to_string(&md_path).expect("read imported markdown");
|
||||
let (meta, _) = parse_frontmatter(&imported);
|
||||
let doc_id = meta.id.expect("doc id should be generated");
|
||||
|
||||
let imported_doc_bin = events
|
||||
.iter()
|
||||
.find_map(|event| {
|
||||
event
|
||||
.update
|
||||
.as_ref()
|
||||
.filter(|update| update.doc_id == doc_id)
|
||||
.map(|update| update.bin.as_ref().to_vec())
|
||||
})
|
||||
.expect("imported page update");
|
||||
|
||||
let delta = update_doc(&imported_doc_bin, "# Imported\n\ntwo", &doc_id).expect("build local edit delta");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.clone(),
|
||||
bin: Uint8Array::new(delta),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let updated = fs::read_to_string(&md_path).expect("read markdown after local edit");
|
||||
assert!(updated.contains("two"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_sets_root_meta_create_and_updated_date() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-dates.md");
|
||||
fs::write(&md_path, "# Dates\n\ncontent").expect("write markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-import-dates";
|
||||
let workspace_id = "ws-import-dates";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let events = sync.pull_events(session_id.to_string()).await.expect("pull events");
|
||||
let imported = fs::read_to_string(&md_path).expect("read imported markdown");
|
||||
let (meta, _) = parse_frontmatter(&imported);
|
||||
let doc_id = meta.id.expect("doc id should exist");
|
||||
|
||||
let root_update = events
|
||||
.iter()
|
||||
.find_map(|event| {
|
||||
event
|
||||
.update
|
||||
.as_ref()
|
||||
.filter(|update| update.doc_id == workspace_id)
|
||||
.map(|update| update.bin.as_ref().to_vec())
|
||||
})
|
||||
.expect("root-meta update");
|
||||
|
||||
let mut root = DocOptions::new().with_guid(workspace_id.to_string()).build();
|
||||
root
|
||||
.apply_update_from_binary_v1(&root_update)
|
||||
.expect("apply root-meta update");
|
||||
|
||||
let meta_map = root.get_map("meta").expect("meta map");
|
||||
let pages = meta_map
|
||||
.get("pages")
|
||||
.and_then(|value| value.to_array())
|
||||
.expect("pages array");
|
||||
let page_map = pages
|
||||
.iter()
|
||||
.find_map(|value| {
|
||||
let page = value.to_map()?;
|
||||
let id = page
|
||||
.get("id")
|
||||
.and_then(|value| value.to_any())
|
||||
.and_then(|any| match any {
|
||||
Any::String(value) => Some(value),
|
||||
_ => None,
|
||||
})?;
|
||||
if id == doc_id { Some(page) } else { None }
|
||||
})
|
||||
.expect("imported page meta");
|
||||
|
||||
let create_date = page_map
|
||||
.get("createDate")
|
||||
.and_then(|value| value.to_any())
|
||||
.expect("createDate should exist");
|
||||
assert!(is_numeric_any(&create_date));
|
||||
|
||||
let updated_date = page_map
|
||||
.get("updatedDate")
|
||||
.and_then(|value| value.to_any())
|
||||
.expect("updatedDate should exist");
|
||||
assert!(is_numeric_any(&updated_date));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_delete_policy_does_not_emit_doc_delete() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-delete.md");
|
||||
fs::write(&md_path, "---\nid: doc-delete\ntitle: Delete\n---\n\n# Delete\n\none").expect("write markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-no-delete";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-delete".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
fs::remove_file(&md_path).expect("remove markdown file");
|
||||
|
||||
let events = sync
|
||||
.pull_events(session_id.to_string())
|
||||
.await
|
||||
.expect("pull after delete");
|
||||
|
||||
assert!(!events.iter().any(|event| event.r#type == "doc-delete"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Baseline {
|
||||
pub(crate) base_clock: String,
|
||||
pub(crate) base_vector: String,
|
||||
pub(crate) md_hash: String,
|
||||
pub(crate) meta_hash: String,
|
||||
pub(crate) synced_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct FrontmatterMeta {
|
||||
pub(crate) id: Option<String>,
|
||||
pub(crate) title: Option<String>,
|
||||
pub(crate) tags: Option<Vec<String>>,
|
||||
pub(crate) favorite: Option<bool>,
|
||||
pub(crate) trash: Option<bool>,
|
||||
}
|
||||
|
||||
impl FrontmatterMeta {
|
||||
pub(crate) fn with_id(mut self, id: String) -> Self {
|
||||
self.id = Some(id);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use sha3::{Digest, Sha3_256};
|
||||
use y_octo::{Doc, DocOptions, StateVector};
|
||||
|
||||
use super::{frontmatter::normalize_tags, types::FrontmatterMeta};
|
||||
|
||||
pub(crate) fn collect_markdown_files(root: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
|
||||
let entries = fs::read_dir(root).map_err(|err| format!("failed to read directory {}: {}", root.display(), err))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|err| format!("failed to read directory entry: {}", err))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == ".affine-sync")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
collect_markdown_files(&path, output)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|
||||
{
|
||||
output.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn generate_missing_doc_id(file_path: &Path) -> String {
|
||||
let stem = file_path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(sanitize_file_stem)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "doc".to_string());
|
||||
|
||||
format!("{}-{}", stem, Utc::now().timestamp_millis())
|
||||
}
|
||||
|
||||
pub(crate) fn derive_title_from_markdown(markdown: &str) -> Option<String> {
|
||||
for line in markdown.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(title) = trimmed.strip_prefix("# ") {
|
||||
let title = title.trim();
|
||||
if !title.is_empty() {
|
||||
return Some(title.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn derive_title_from_path(file_path: &Path) -> String {
|
||||
file_path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_file_stem(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
|
||||
for ch in input.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
} else if (ch == '-' || ch == '_' || ch == ' ') && !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
|
||||
let out = out.trim_matches('-').to_string();
|
||||
if out.is_empty() { "doc".to_string() } else { out }
|
||||
}
|
||||
|
||||
pub(crate) fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("path {} has no parent directory", path.display()))?;
|
||||
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("failed to create parent directory {}: {}", parent.display(), err))?;
|
||||
|
||||
let temp_name = format!(
|
||||
".affine-sync-tmp-{}-{}.md",
|
||||
std::process::id(),
|
||||
Utc::now().timestamp_millis()
|
||||
);
|
||||
let temp_path = parent.join(temp_name);
|
||||
|
||||
fs::write(&temp_path, content)
|
||||
.map_err(|err| format!("failed to write temp file {}: {}", temp_path.display(), err))?;
|
||||
|
||||
// On Unix, `rename` replaces the destination atomically. Avoiding an explicit
|
||||
// delete reduces "delete + create" file events, which can confuse file
|
||||
// watchers/editors and cause apparent content flapping.
|
||||
//
|
||||
// On Windows, `rename` fails if destination exists, so we remove first.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|err| format!("failed to replace file {}: {}", path.display(), err))?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::rename(&temp_path, path).map_err(|err| {
|
||||
format!(
|
||||
"failed to move temp file {} to {}: {}",
|
||||
temp_path.display(),
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn hash_string(value: &str) -> String {
|
||||
let mut hasher = Sha3_256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
out.push(hex_char(byte >> 4));
|
||||
out.push(hex_char(byte & 0x0f));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn hash_meta(meta: &FrontmatterMeta) -> String {
|
||||
let mut canonical = String::new();
|
||||
canonical.push_str("id=");
|
||||
canonical.push_str(meta.id.as_deref().unwrap_or_default());
|
||||
|
||||
canonical.push_str("|title=");
|
||||
canonical.push_str(meta.title.as_deref().unwrap_or_default());
|
||||
|
||||
canonical.push_str("|tags=");
|
||||
if let Some(tags) = normalize_tags(meta.tags.clone()) {
|
||||
canonical.push_str(&tags.join("\u{1f}"));
|
||||
}
|
||||
|
||||
canonical.push_str("|favorite=");
|
||||
canonical.push_str(if meta.favorite.unwrap_or(false) {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
});
|
||||
|
||||
canonical.push_str("|trash=");
|
||||
canonical.push_str(if meta.trash.unwrap_or(false) { "true" } else { "false" });
|
||||
|
||||
hash_string(&canonical)
|
||||
}
|
||||
|
||||
fn hex_char(value: u8) -> char {
|
||||
match value {
|
||||
0..=9 => (b'0' + value) as char,
|
||||
10..=15 => (b'a' + (value - 10)) as char,
|
||||
_ => '0',
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn now_naive() -> NaiveDateTime {
|
||||
DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
|
||||
.unwrap_or_else(Utc::now)
|
||||
.naive_utc()
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty_update(value: &[u8]) -> bool {
|
||||
value.is_empty() || value == [0, 0]
|
||||
}
|
||||
|
||||
pub(crate) fn merge_update_binary(
|
||||
existing: Option<&[u8]>,
|
||||
update: &[u8],
|
||||
doc_id: Option<&str>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut doc = if let Some(existing) = existing {
|
||||
if is_empty_update(existing) {
|
||||
build_doc(doc_id)
|
||||
} else {
|
||||
let mut doc = build_doc(doc_id);
|
||||
doc
|
||||
.apply_update_from_binary_v1(existing)
|
||||
.map_err(|err| format!("failed to apply existing update: {}", err))?;
|
||||
doc
|
||||
}
|
||||
} else {
|
||||
build_doc(doc_id)
|
||||
};
|
||||
|
||||
if !is_empty_update(update) {
|
||||
doc
|
||||
.apply_update_from_binary_v1(update)
|
||||
.map_err(|err| format!("failed to merge update: {}", err))?;
|
||||
}
|
||||
|
||||
doc
|
||||
.encode_state_as_update_v1(&StateVector::default())
|
||||
.map_err(|err| format!("failed to encode merged update: {}", err))
|
||||
}
|
||||
|
||||
pub(crate) fn build_doc(doc_id: Option<&str>) -> Doc {
|
||||
let options = DocOptions::new();
|
||||
match doc_id {
|
||||
Some(doc_id) => options.with_guid(doc_id.to_string()).build(),
|
||||
None => options.build(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_doc_or_new(binary: &[u8], doc_id: Option<&str>) -> Result<Doc, String> {
|
||||
if is_empty_update(binary) {
|
||||
return Ok(build_doc(doc_id));
|
||||
}
|
||||
|
||||
let mut doc = build_doc(doc_id);
|
||||
doc
|
||||
.apply_update_from_binary_v1(binary)
|
||||
.map_err(|err| format!("failed to decode doc binary: {}", err))?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
pub(crate) fn paths_equal(lhs: &Path, rhs: &Path) -> bool {
|
||||
if lhs == rhs {
|
||||
return true;
|
||||
}
|
||||
|
||||
match (lhs.canonicalize(), rhs.canonicalize()) {
|
||||
(Ok(lhs), Ok(rhs)) => lhs == rhs,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod disk_sync;
|
||||
pub mod hashcash;
|
||||
|
||||
#[cfg(not(target_arch = "arm"))]
|
||||
@@ -8,3 +9,4 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
pub use affine_media_capture::*;
|
||||
pub use affine_nbstore::*;
|
||||
pub use affine_sqlite_v1::*;
|
||||
pub use disk_sync::*;
|
||||
|
||||
Reference in New Issue
Block a user