You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-23 22:36:32 +02:00
All: Fixes #6517: Prevent Joplin from missing changes when syncing with file system or WebDAV (#13054)
This commit is contained in:
@@ -1415,6 +1415,7 @@ packages/lib/services/database/migrations/45.js
|
||||
packages/lib/services/database/migrations/46.js
|
||||
packages/lib/services/database/migrations/47.js
|
||||
packages/lib/services/database/migrations/48.js
|
||||
packages/lib/services/database/migrations/49.js
|
||||
packages/lib/services/database/migrations/index.js
|
||||
packages/lib/services/database/sqlStringToLines.js
|
||||
packages/lib/services/database/types.js
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1388,6 +1388,7 @@ packages/lib/services/database/migrations/45.js
|
||||
packages/lib/services/database/migrations/46.js
|
||||
packages/lib/services/database/migrations/47.js
|
||||
packages/lib/services/database/migrations/48.js
|
||||
packages/lib/services/database/migrations/49.js
|
||||
packages/lib/services/database/migrations/index.js
|
||||
packages/lib/services/database/sqlStringToLines.js
|
||||
packages/lib/services/database/types.js
|
||||
|
||||
@@ -20,7 +20,7 @@ import JoplinError from './JoplinError';
|
||||
import ShareService from './services/share/ShareService';
|
||||
import TaskQueue from './TaskQueue';
|
||||
import ItemUploader from './services/synchronizer/ItemUploader';
|
||||
import { FileApi, getSupportsDeltaWithItems, isLocalServer, PaginatedList, RemoteItem } from './file-api';
|
||||
import { FileApi, getSupportsDeltaWithItems, isLocalServer, PaginatedList, RemoteItem, enableEnhancedBasicDeltaAlgorithm } from './file-api';
|
||||
import JoplinDatabase from './JoplinDatabase';
|
||||
import { checkIfCanSync, fetchSyncInfo, checkSyncTargetIsValid, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
||||
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
||||
@@ -820,6 +820,15 @@ export default class Synchronizer {
|
||||
// on it (instead it uses a more reliable `context` object) and the itemsThatNeedSync loop
|
||||
// above also doesn't use it because it fetches the whole remote object and read the
|
||||
// more reliable 'updated_time' property. Basically remote.updated_time is deprecated.
|
||||
// 2025-08-27: remote.updated_time can now be utilised by the basic delta when using a sync target
|
||||
// where the 'server' is actually the same device that is running the client eg. file system sync.
|
||||
// This is required to correctly detect updated objects where an external sync service is being
|
||||
// used in combination with Joplin, as there are essentially multiple sources of truth, rather
|
||||
// than just one. So we can't rely on the server always containing the latest remote changes
|
||||
// during synchronization, as new changes can be later added which have a timestamp in the past.
|
||||
// In this scenario, we don't know the exact timestamp to specify for remoteItemUpdatedTime upon
|
||||
// uploading. So we can leave it unspecified and then on the next run of the delta step, it will
|
||||
// get set there
|
||||
|
||||
await ItemClass.saveSyncTime(syncTargetId, local, local.updated_time);
|
||||
}
|
||||
@@ -880,6 +889,11 @@ export default class Synchronizer {
|
||||
return BaseItem.syncedItemIds(syncTargetId);
|
||||
},
|
||||
|
||||
// This is only used by the basic delta
|
||||
allItemMetadataHandler: async () => {
|
||||
return BaseItem.remoteItemMetadata(syncTargetId);
|
||||
},
|
||||
|
||||
wipeOutFailSafe: Setting.value('sync.wipeOutFailSafe'),
|
||||
|
||||
logger: logger,
|
||||
@@ -973,6 +987,11 @@ export default class Synchronizer {
|
||||
if (content && content.updated_time > local.updated_time) {
|
||||
action = SyncAction.UpdateLocal;
|
||||
reason = 'remote is more recent than local';
|
||||
} else if (enableEnhancedBasicDeltaAlgorithm()) {
|
||||
// When the enhanced basic delta algorithm is first used, all items are rescanned and we need to persist the remoteItemUpdatedTime
|
||||
// to set up the initial synced state. This also catches the case if content.updated_time < local.updated_time due to manual manipulation
|
||||
// of the md files, to prevent these items being continually fetched on every sync
|
||||
await ItemClass.saveSyncTime(syncTargetId, local, local.updated_time, remote.updated_time);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1012,7 +1031,7 @@ export default class Synchronizer {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const options: any = {
|
||||
autoTimestamp: false,
|
||||
nextQueries: BaseItem.updateSyncTimeQueries(syncTargetId, content, time.unixMs()),
|
||||
nextQueries: BaseItem.updateSyncTimeQueries(syncTargetId, content, time.unixMs(), remote.updated_time),
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
};
|
||||
if (action === SyncAction.CreateLocal) options.isNew = true;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PaginatedList, RemoteItem, getSupportsDeltaWithItems, isLocalServer } from './file-api';
|
||||
import { PaginatedList, RemoteItem, getSupportsDeltaWithItems, enableEnhancedBasicDeltaAlgorithm, basicDelta, ItemStat, isLocalServer } from './file-api';
|
||||
import { RemoteItemMetadata } from './models/BaseItem';
|
||||
import Setting from './models/Setting';
|
||||
import SyncTargetRegistry from './SyncTargetRegistry';
|
||||
|
||||
const defaultPaginatedList = (): PaginatedList => {
|
||||
return {
|
||||
@@ -14,6 +17,86 @@ const defaultItem = (): RemoteItem => {
|
||||
};
|
||||
};
|
||||
|
||||
const validNoteId = '1b175bb38bba47baac22b0b47f778113';
|
||||
const basePath = '/';
|
||||
const baseTimestamp = new Date().getTime();
|
||||
|
||||
const setupWebDavSync = (isLocal: boolean) => {
|
||||
let url = 'http://www.example.com';
|
||||
if (isLocal) url = 'http://localhost';
|
||||
Setting.setValue('sync.target', SyncTargetRegistry.nameToId('webdav'));
|
||||
Setting.setValue('sync.6.path', url);
|
||||
};
|
||||
|
||||
const remotePath = (noteId: string) => {
|
||||
return `${noteId}.md`;
|
||||
};
|
||||
|
||||
const statItem = (noteId: string, remoteUpdatedTime: number) => {
|
||||
const stat: ItemStat = {
|
||||
path: remotePath(noteId),
|
||||
updated_time: remoteUpdatedTime,
|
||||
isDir: false,
|
||||
};
|
||||
|
||||
return stat;
|
||||
};
|
||||
|
||||
const dirStatFunc = (statItem: ItemStat) => {
|
||||
return (): ItemStat[] => {
|
||||
if (statItem) {
|
||||
return [statItem];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const syncOptions = (noteId: string, localUpdatedTime: number, contextTimestamp: number = undefined, includeFilesAtTimestamp = true) => {
|
||||
const syncContextTimestamp = contextTimestamp ? contextTimestamp : localUpdatedTime;
|
||||
const metadataMap = new Map<string, RemoteItemMetadata>();
|
||||
let itemIds: string[] = [];
|
||||
let filesAtTimestamp: string[] = [];
|
||||
|
||||
const metadata = {
|
||||
item_id: noteId,
|
||||
updated_time: localUpdatedTime,
|
||||
};
|
||||
|
||||
if (noteId) {
|
||||
metadataMap.set(noteId, metadata);
|
||||
itemIds = [noteId];
|
||||
}
|
||||
|
||||
if (includeFilesAtTimestamp) {
|
||||
filesAtTimestamp = [remotePath(noteId)];
|
||||
}
|
||||
|
||||
const allItemIdsHandler = async () => {
|
||||
return itemIds;
|
||||
};
|
||||
|
||||
const allItemMetadataHandler = async () => {
|
||||
return metadataMap;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const syncContext: any = {
|
||||
timestamp: syncContextTimestamp,
|
||||
filesAtTimestamp: filesAtTimestamp,
|
||||
statsCache: null,
|
||||
statIdsCache: null,
|
||||
deletedItemsProcessed: false,
|
||||
};
|
||||
|
||||
return {
|
||||
allItemIdsHandler: allItemIdsHandler,
|
||||
allItemMetadataHandler: allItemMetadataHandler,
|
||||
wipeOutFailSafe: false,
|
||||
context: syncContext,
|
||||
};
|
||||
};
|
||||
|
||||
describe('file-api', () => {
|
||||
|
||||
test.each([
|
||||
@@ -94,4 +177,139 @@ describe('file-api', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should use enhanced basic delta algorithm when using file system sync', () => {
|
||||
Setting.setValue('sync.target', SyncTargetRegistry.nameToId('filesystem'));
|
||||
const result = enableEnhancedBasicDeltaAlgorithm();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://localhost',
|
||||
'http://localhost/',
|
||||
'https://localhost:8080',
|
||||
'http://127.0.0.1',
|
||||
'https://127.100.50.25:3000/test',
|
||||
'http://[::1]',
|
||||
'http://localhost/api/v1',
|
||||
])('should use enhanced basic delta algorithm when using WebDAV for a local server url', (url: string) => {
|
||||
Setting.setValue('sync.target', SyncTargetRegistry.nameToId('webdav'));
|
||||
Setting.setValue('sync.6.path', url);
|
||||
const result = enableEnhancedBasicDeltaAlgorithm();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://localhostXYZ',
|
||||
'http://127.0.0.1foobar',
|
||||
'http://192.168.1.1',
|
||||
'http://example.com',
|
||||
'https://my-localhost.com',
|
||||
])('should not use enhanced basic delta algorithm when using WebDAV for a non local server url', (url: string) => {
|
||||
Setting.setValue('sync.target', SyncTargetRegistry.nameToId('webdav'));
|
||||
Setting.setValue('sync.6.path', url);
|
||||
const result = enableEnhancedBasicDeltaAlgorithm();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not use enhanced basic delta algorithm when not using file system sync or WebDAV', () => {
|
||||
Setting.setValue('sync.target', SyncTargetRegistry.nameToId('joplinServer'));
|
||||
const result = enableEnhancedBasicDeltaAlgorithm();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test.each([false, true])('basicDelta (enhancedAlgorithm: %s) should not return item, where remote item is a directory', async (enhancedAlgorithm) => {
|
||||
setupWebDavSync(enhancedAlgorithm);
|
||||
const stat = {
|
||||
path: remotePath(validNoteId),
|
||||
updated_time: baseTimestamp + 1,
|
||||
isDir: true,
|
||||
};
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(undefined, baseTimestamp));
|
||||
expect(context.items.length).toBe(0);
|
||||
});
|
||||
|
||||
test.each([false, true])('basicDelta (enhancedAlgorithm: %s) should not return item, where remote item is not a system path', async (enhancedAlgorithm) => {
|
||||
setupWebDavSync(enhancedAlgorithm);
|
||||
const noteId = '1b175bb38bba47baac22b0b47f77811'; // 1 char too short
|
||||
const stat = statItem(noteId, baseTimestamp + 1);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(undefined, baseTimestamp));
|
||||
expect(context.items.length).toBe(0);
|
||||
});
|
||||
|
||||
test.each([false, true])('basicDelta (enhancedAlgorithm: %s) should return item with isDeleted true, where remote item not longer exists', async (enhancedAlgorithm) => {
|
||||
setupWebDavSync(enhancedAlgorithm);
|
||||
const context = await basicDelta(basePath, dirStatFunc(undefined), syncOptions(validNoteId, baseTimestamp));
|
||||
expect(context.items.length).toBe(1);
|
||||
expect(context.items[0].isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
test.each([false, true])('basicDelta (enhancedAlgorithm: %s) should return item, where local item does not exist', async (enhancedAlgorithm) => {
|
||||
setupWebDavSync(enhancedAlgorithm);
|
||||
const stat = statItem(validNoteId, baseTimestamp);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(undefined, baseTimestamp));
|
||||
expect(context.items.length).toBe(1);
|
||||
expect(context.items[0]).toBe(stat);
|
||||
});
|
||||
|
||||
test.each([false, true])('basicDelta (enhancedAlgorithm: %s) should return item, where local item exists and remote item has a newer timestamp', async (enhancedAlgorithm) => {
|
||||
setupWebDavSync(enhancedAlgorithm);
|
||||
const stat = statItem(validNoteId, baseTimestamp + 1);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(validNoteId, baseTimestamp));
|
||||
expect(context.items.length).toBe(1);
|
||||
expect(context.items[0]).toBe(stat);
|
||||
});
|
||||
|
||||
test.each([false, true])('basicDelta (enhancedAlgorithm: %s) should not return item, where local item exists and remote item has an equal timestamp', async (enhancedAlgorithm) => {
|
||||
setupWebDavSync(enhancedAlgorithm);
|
||||
const stat = statItem(validNoteId, baseTimestamp);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(validNoteId, baseTimestamp));
|
||||
expect(context.items.length).toBe(0);
|
||||
});
|
||||
|
||||
test('basicDelta (enhancedAlgorithm: false) should return item, where local item exists and remote item has an equal timestamp, but it is not present in fileAtTimestamp', async () => {
|
||||
setupWebDavSync(false);
|
||||
const stat = statItem(validNoteId, baseTimestamp);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(validNoteId, baseTimestamp, baseTimestamp, false));
|
||||
expect(context.items.length).toBe(1);
|
||||
expect(context.items[0]).toBe(stat);
|
||||
});
|
||||
|
||||
test('basicDelta (enhancedAlgorithm: false) should not return item, where local item exists and remote item has an older timestamp', async () => {
|
||||
setupWebDavSync(false);
|
||||
const stat = statItem(validNoteId, baseTimestamp - 1);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(validNoteId, baseTimestamp));
|
||||
expect(context.items.length).toBe(0);
|
||||
});
|
||||
|
||||
test('basicDelta (enhancedAlgorithm: false) should use context timestamp for timestamp comparisons, ignoring items with earlier timestamps', async () => {
|
||||
setupWebDavSync(false);
|
||||
const stat = statItem(validNoteId, baseTimestamp + 1);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(validNoteId, baseTimestamp, baseTimestamp + 2));
|
||||
expect(context.items.length).toBe(0);
|
||||
});
|
||||
|
||||
test('basicDelta (enhancedAlgorithm: true) should return item, where local item exists and remote item has an older timestamp', async () => {
|
||||
setupWebDavSync(true);
|
||||
const stat = statItem(validNoteId, baseTimestamp - 1);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(validNoteId, baseTimestamp));
|
||||
expect(context.items.length).toBe(1);
|
||||
expect(context.items[0]).toBe(stat);
|
||||
});
|
||||
|
||||
test('basicDelta (enhancedAlgorithm: true) should ignore context timestamp for timestamp comparisons, and return item based on metadata timestamp', async () => {
|
||||
setupWebDavSync(true);
|
||||
const stat = statItem(validNoteId, baseTimestamp + 1);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(validNoteId, baseTimestamp, baseTimestamp + 2));
|
||||
expect(context.items.length).toBe(1);
|
||||
expect(context.items[0]).toBe(stat);
|
||||
});
|
||||
|
||||
test('basicDelta (enhancedAlgorithm: true) should always return item if there is no metadata timestamp set', async () => {
|
||||
setupWebDavSync(true);
|
||||
const stat = statItem(validNoteId, baseTimestamp);
|
||||
const context = await basicDelta(basePath, dirStatFunc(stat), syncOptions(validNoteId, undefined, baseTimestamp + 1));
|
||||
expect(context.items.length).toBe(1);
|
||||
expect(context.items[0]).toBe(stat);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Logger, { LoggerWrapper } from '@joplin/utils/Logger';
|
||||
import shim from './shim';
|
||||
import BaseItem from './models/BaseItem';
|
||||
import BaseItem, { RemoteItemMetadata } from './models/BaseItem';
|
||||
import time from './time';
|
||||
|
||||
const { isHidden } = require('./path-utils');
|
||||
import JoplinError from './JoplinError';
|
||||
import { Lock, LockClientType, LockType } from './services/synchronizer/LockHandler';
|
||||
import * as ArrayUtils from './ArrayUtils';
|
||||
import Setting from './models/Setting';
|
||||
import SyncTargetRegistry from './SyncTargetRegistry';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const Mutex = require('async-mutex').Mutex;
|
||||
|
||||
@@ -58,6 +60,21 @@ export const isLocalServer = (url: string) => {
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
// The enhanced basic delta algorithm detects incoming changes based on both timestamp increases and decreases, which resolves issues where an external
|
||||
// service is syncing to the sync target directory at the same time as Joplin. Change detection is still limited by the precision of the modified timestamp
|
||||
// of the filesystem in use, but at worst this would mean that if 2 Joplin clients synced a conflicting change to the same note within 2 seconds, the incoming
|
||||
// change may get ignored (but this is a limitation of the normal basic algorithm as well). However, with the enhanced algorithm, the timing of syncs made by
|
||||
// an external sync service are irrelevant, providing the service is set to sync the modified time of files it syncs
|
||||
export const enableEnhancedBasicDeltaAlgorithm = () => {
|
||||
if (Setting.value('sync.target') === SyncTargetRegistry.nameToId('filesystem')) {
|
||||
return true;
|
||||
} else if (Setting.value('sync.target') === SyncTargetRegistry.nameToId('webdav')) {
|
||||
return isLocalServer(Setting.value('sync.6.path'));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function requestCanBeRepeated(error: any) {
|
||||
const errorCode = typeof error === 'object' && error.code ? error.code : null;
|
||||
@@ -107,6 +124,7 @@ async function tryAndRepeat(fn: Function, count: number) {
|
||||
|
||||
export interface DeltaOptions {
|
||||
allItemIdsHandler(): Promise<string[]>;
|
||||
allItemMetadataHandler(): Promise<Map<string, RemoteItemMetadata>>;
|
||||
logger?: LoggerWrapper;
|
||||
wipeOutFailSafe: boolean;
|
||||
}
|
||||
@@ -458,7 +476,8 @@ function basicDeltaContextFromOptions_(options: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
|
||||
async function basicDelta(path: string, getDirStatFn: Function, options: DeltaOptions) {
|
||||
const outputLimit = 50;
|
||||
const itemIds = await options.allItemIdsHandler();
|
||||
const itemIds: string[] = await options.allItemIdsHandler();
|
||||
|
||||
if (!Array.isArray(itemIds)) throw new Error('Delta API not supported - local IDs must be provided');
|
||||
|
||||
const logger = options && options.logger ? options.logger : new Logger();
|
||||
@@ -499,6 +518,12 @@ async function basicDelta(path: string, getDirStatFn: Function, options: DeltaOp
|
||||
equal: 0,
|
||||
};
|
||||
|
||||
let remoteItemMetadata: Map<string, RemoteItemMetadata>;
|
||||
|
||||
if (enableEnhancedBasicDeltaAlgorithm()) {
|
||||
remoteItemMetadata = await options.allItemMetadataHandler();
|
||||
}
|
||||
|
||||
// Find out which files have been changed since the last time. Note that we keep
|
||||
// both the timestamp of the most recent change, *and* the items that exactly match
|
||||
// this timestamp. This to handle cases where an item is modified while this delta
|
||||
@@ -512,33 +537,72 @@ async function basicDelta(path: string, getDirStatFn: Function, options: DeltaOp
|
||||
const stat = newContext.statsCache[i];
|
||||
|
||||
if (stat.isDir) continue;
|
||||
if (!BaseItem.isSystemPath(stat.path)) continue;
|
||||
|
||||
if (stat.updated_time < context.timestamp) {
|
||||
updateReport.older++;
|
||||
continue;
|
||||
}
|
||||
let lastRemoteItemUpdatedTime = 0;
|
||||
const itemId = BaseItem.pathToId(stat.path);
|
||||
|
||||
// Special case for items that exactly match the timestamp
|
||||
if (stat.updated_time === context.timestamp) {
|
||||
if (context.filesAtTimestamp.indexOf(stat.path) >= 0) {
|
||||
updateReport.equal++;
|
||||
if (enableEnhancedBasicDeltaAlgorithm()) {
|
||||
const metadata = remoteItemMetadata.get(itemId);
|
||||
|
||||
if (metadata) {
|
||||
// Check if update is needed
|
||||
lastRemoteItemUpdatedTime = metadata.updated_time;
|
||||
|
||||
if (stat.updated_time === lastRemoteItemUpdatedTime) {
|
||||
// Item has already been synced and is up to date
|
||||
updateReport.equal++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.updated_time < lastRemoteItemUpdatedTime) {
|
||||
updateReport.older++;
|
||||
}
|
||||
|
||||
if (stat.updated_time > lastRemoteItemUpdatedTime) {
|
||||
updateReport.newer++;
|
||||
}
|
||||
} else {
|
||||
// Item needs to be created locally
|
||||
updateReport.newer++;
|
||||
}
|
||||
|
||||
output.push(stat);
|
||||
} else {
|
||||
if (stat.updated_time < context.timestamp) {
|
||||
updateReport.older++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (stat.updated_time > newContext.timestamp) {
|
||||
newContext.timestamp = stat.updated_time;
|
||||
newContext.filesAtTimestamp = [];
|
||||
updateReport.newer++;
|
||||
}
|
||||
// Special case for items that exactly match the timestamp
|
||||
if (stat.updated_time === context.timestamp) {
|
||||
if (context.filesAtTimestamp.indexOf(stat.path) >= 0) {
|
||||
updateReport.equal++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
newContext.filesAtTimestamp.push(stat.path);
|
||||
output.push(stat);
|
||||
if (stat.updated_time > newContext.timestamp) {
|
||||
newContext.timestamp = stat.updated_time;
|
||||
newContext.filesAtTimestamp = [];
|
||||
updateReport.newer++;
|
||||
}
|
||||
|
||||
newContext.filesAtTimestamp.push(stat.path);
|
||||
output.push(stat);
|
||||
}
|
||||
|
||||
if (output.length >= outputLimit) break;
|
||||
}
|
||||
|
||||
logger.info(`BasicDelta: Report: ${JSON.stringify(updateReport)}`);
|
||||
if (enableEnhancedBasicDeltaAlgorithm()) {
|
||||
// context.timestamp and filesAtTimestamp are not required when syncing based on any timestamp changes, but should be updated for backwards compatibility
|
||||
newContext.timestamp = time.unixMs();
|
||||
newContext.filesAtTimestamp = [];
|
||||
logger.info(`BasicDelta (enhanced): Report: ${JSON.stringify(updateReport)}`);
|
||||
} else {
|
||||
logger.info(`BasicDelta: Report: ${JSON.stringify(updateReport)}`);
|
||||
}
|
||||
|
||||
if (!newContext.deletedItemsProcessed) {
|
||||
// Find out which items have been deleted on the sync target by comparing the items
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ItemsThatNeedDecryptionResult {
|
||||
export interface ItemThatNeedSync {
|
||||
id: string;
|
||||
sync_time: number;
|
||||
remote_item_updated_time: number;
|
||||
type_: ModelType;
|
||||
updated_time: number;
|
||||
encryption_applied: number;
|
||||
@@ -40,6 +41,11 @@ export interface ItemsThatNeedSyncResult {
|
||||
neverSyncedItemIds: string[];
|
||||
}
|
||||
|
||||
export interface RemoteItemMetadata {
|
||||
item_id: string;
|
||||
updated_time: number;
|
||||
}
|
||||
|
||||
export interface EncryptedItemsStats {
|
||||
encrypted: number;
|
||||
total: number;
|
||||
@@ -203,6 +209,20 @@ export default class BaseItem extends BaseModel {
|
||||
return output;
|
||||
}
|
||||
|
||||
public static async remoteItemMetadata(syncTarget: number): Promise<Map<string, RemoteItemMetadata>> {
|
||||
if (!syncTarget) throw new Error('No syncTarget specified');
|
||||
const temp = await this.db().selectAll('SELECT item_id, remote_item_updated_time FROM sync_items WHERE sync_time > 0 AND sync_target = ?', [syncTarget]);
|
||||
const output = new Map<string, RemoteItemMetadata>();
|
||||
for (let i = 0; i < temp.length; i++) {
|
||||
const metadata: RemoteItemMetadata = {
|
||||
item_id: temp[i].item_id,
|
||||
updated_time: temp[i].remote_item_updated_time,
|
||||
};
|
||||
output.set(temp[i].item_id, metadata);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public static async syncItem(syncTarget: number, itemId: string, options: LoadOptions = null): Promise<SyncItemEntity> {
|
||||
options = {
|
||||
fields: '*',
|
||||
@@ -751,6 +771,7 @@ export default class BaseItem extends BaseModel {
|
||||
|
||||
if (newLimit > 0) {
|
||||
fieldNames.push('sync_time');
|
||||
fieldNames.push('remote_item_updated_time');
|
||||
|
||||
const sql = sprintf(
|
||||
`
|
||||
@@ -850,7 +871,7 @@ export default class BaseItem extends BaseModel {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public static updateSyncTimeQueries(syncTarget: number, item: any, syncTime: number, syncDisabled = false, syncDisabledReason = '', itemLocation: number = null) {
|
||||
public static updateSyncTimeQueries(syncTarget: number, item: any, syncTime: number, remoteItemUpdatedTime = 0, syncDisabled = false, syncDisabledReason = '', itemLocation: number = null) {
|
||||
const itemType = item.type_;
|
||||
const itemId = item.id;
|
||||
if (!itemType || !itemId || syncTime === undefined) throw new Error(sprintf('Invalid parameters in updateSyncTimeQueries(): %d, %s, %d', syncTarget, JSON.stringify(item), syncTime));
|
||||
@@ -863,22 +884,23 @@ export default class BaseItem extends BaseModel {
|
||||
params: [syncTarget, itemType, itemId],
|
||||
},
|
||||
{
|
||||
sql: 'INSERT INTO sync_items (sync_target, item_type, item_id, item_location, sync_time, sync_disabled, sync_disabled_reason) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
params: [syncTarget, itemType, itemId, itemLocation, syncTime, syncDisabled ? 1 : 0, `${syncDisabledReason}`],
|
||||
sql: 'INSERT INTO sync_items (sync_target, item_type, item_id, item_location, sync_time, remote_item_updated_time, sync_disabled, sync_disabled_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
params: [syncTarget, itemType, itemId, itemLocation, syncTime, remoteItemUpdatedTime, syncDisabled ? 1 : 0, `${syncDisabledReason}`],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public static async saveSyncTime(syncTarget: number, item: any, syncTime: number) {
|
||||
const queries = this.updateSyncTimeQueries(syncTarget, item, syncTime);
|
||||
public static async saveSyncTime(syncTarget: number, item: any, syncTime: number, remoteItemUpdatedTime = 0) {
|
||||
const queries = this.updateSyncTimeQueries(syncTarget, item, syncTime, remoteItemUpdatedTime);
|
||||
return this.db().transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public static async saveSyncDisabled(syncTargetId: number, item: any, syncDisabledReason: string, itemLocation: number = null) {
|
||||
const syncTime = 'sync_time' in item ? item.sync_time : 0;
|
||||
const queries = this.updateSyncTimeQueries(syncTargetId, item, syncTime, true, syncDisabledReason, itemLocation);
|
||||
const remoteItemUpdatedTime = 'remote_item_updated_time' in item ? item.remote_item_updated_time : 0;
|
||||
const queries = this.updateSyncTimeQueries(syncTargetId, item, syncTime, remoteItemUpdatedTime, true, syncDisabledReason, itemLocation);
|
||||
return this.db().transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
|
||||
7
packages/lib/services/database/migrations/49.ts
Normal file
7
packages/lib/services/database/migrations/49.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SqlQuery } from '../types';
|
||||
|
||||
export default (): (SqlQuery|string)[] => {
|
||||
return [
|
||||
'ALTER TABLE sync_items ADD COLUMN remote_item_updated_time INT NOT NULL DEFAULT 0',
|
||||
];
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import migration45 from './45';
|
||||
import migration46 from './46';
|
||||
import migration47 from './47';
|
||||
import migration48 from './48';
|
||||
import migration49 from './49';
|
||||
|
||||
import { Migration } from '../types';
|
||||
|
||||
@@ -17,6 +18,7 @@ const index: Migration[] = [
|
||||
migration46,
|
||||
migration47,
|
||||
migration48,
|
||||
migration49,
|
||||
];
|
||||
|
||||
export default index;
|
||||
|
||||
@@ -339,6 +339,7 @@ export interface SyncItemEntity {
|
||||
'sync_target'?: number;
|
||||
'sync_time'?: number;
|
||||
'sync_warning_ignored'?: number;
|
||||
'remote_item_updated_time'?: number;
|
||||
'type_'?: number;
|
||||
}
|
||||
export interface TableFieldEntity {
|
||||
@@ -444,6 +445,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
sync_target: { type: 'number' },
|
||||
sync_time: { type: 'number' },
|
||||
sync_warning_ignored: { type: 'number' },
|
||||
remote_item_updated_time: { type: 'number' },
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
version: {
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async (action: SyncAction, ItemClass: typeof BaseItem, remoteExis
|
||||
if (remoteExists) {
|
||||
local = remoteContent;
|
||||
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs(), remoteContent.updated_time);
|
||||
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
||||
} else {
|
||||
// If the item is a folder, avoid deleting child notes and folders, as this could cause massive data loss where this conflict happens unexpectedly
|
||||
@@ -82,7 +82,7 @@ export default async (action: SyncAction, ItemClass: typeof BaseItem, remoteExis
|
||||
|
||||
if (remoteExists) {
|
||||
local = remoteContent;
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs(), remoteContent.updated_time);
|
||||
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
||||
|
||||
if (local.encryption_applied) dispatch({ type: 'SYNC_GOT_ENCRYPTED_ITEM' });
|
||||
|
||||
@@ -32,6 +32,7 @@ export default async (syncTargetId: number, cancelling: boolean, logSyncOperatio
|
||||
if (remoteContent) {
|
||||
remoteContent = await BaseItem.unserialize(remoteContent);
|
||||
const ItemClass = BaseItem.itemClass(item.item_type);
|
||||
// For remote deletion, remoteItemUpdatedTime can be reset to 0
|
||||
let nextQueries = BaseItem.updateSyncTimeQueries(syncTargetId, remoteContent, time.unixMs());
|
||||
|
||||
if (isResource) {
|
||||
|
||||
Reference in New Issue
Block a user