1
0
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:
mrjo118
2025-10-16 12:06:48 +01:00
committed by GitHub
parent 468cf00d77
commit 1111bde017
11 changed files with 367 additions and 30 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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);
}

View 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',
];
};

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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' });

View File

@@ -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) {