diff --git a/src/sync/impl/__tests__/applyRemote.test.js b/src/sync/impl/__tests__/applyRemote.test.js index 45f06653b..011b1e0e7 100644 --- a/src/sync/impl/__tests__/applyRemote.test.js +++ b/src/sync/impl/__tests__/applyRemote.test.js @@ -506,4 +506,86 @@ describe('applyRemoteChanges', () => { // Record ID mock_tasks#tSynced was sent over the bridge, but it's not cached await database.get('mock_tasks').find('tSynced') }) + describe('shouldUpdateRecord', () => { + it('can ignore record', async () => { + const { database, tasks } = makeDatabase() + + await makeLocalChanges(database) + await testApplyRemoteChanges(database, { + mock_tasks: { + updated: [ + // update / updated - will be ignored when any local change + { id: 'tUpdated', name: 'remote', description: 'remote' }, + ], + }, + }, { + shouldUpdateRecord: (_table, local, _remote) => { + return local._status !== 'updated' + }, + }) + + await expectSyncedAndMatches(tasks, 'tUpdated', { + _status: 'updated', + _changed: 'name,position', + name: 'local', // local change preserved + position: 100, + description: 'orig', // orig should be preserved + project_id: 'orig', // unchanged + }) + }) + it('can still update', async () => { + const { database, tasks } = makeDatabase() + + await makeLocalChanges(database) + await testApplyRemoteChanges(database, { + mock_tasks: { + updated: [ + // update / updated - should not be ignored when local wasn't changed + { id: 'tSynced', name: 'remote', description: 'remote' }, + ], + }, + }, { + shouldUpdateRecord: (_table, local, _remote) => { + return local._status !== 'updated' + }, + }) + + await expectSyncedAndMatches(tasks, 'tSynced', { + _status: 'synced', + name: 'remote', // remote change + description: 'remote', // remote change + }) + }) + }) + describe('conflictResolver', () => { + it('can account for conflictResolver', async () => { + const { database, tasks } = makeDatabase() + + await makeLocalChanges(database) + await testApplyRemoteChanges(database, { + mock_tasks: { + updated: [ + // update / updated - resolve and update (description is concat of local/remote on change) + { id: 'tUpdated', name: 'remote', description: 'remote' }, + ], + }, + }, { + conflictResolver: (_table, local, remote, resolved) => { + if (local.name !== remote.name) { + resolved.name = `${remote.name} ${local.name}` + } + return resolved + }, + }) + + await expectSyncedAndMatches(tasks, 'tUpdated', { + _status: 'updated', + _changed: 'name,position', + name: 'remote local', // concat of remote and local change + position: 100, + description: 'remote', // remote change + project_id: 'orig', // unchanged + }) + }) + }) }) diff --git a/src/sync/impl/applyRemote.d.ts b/src/sync/impl/applyRemote.d.ts index 32b5a8ad0..bcc4ae31b 100644 --- a/src/sync/impl/applyRemote.d.ts +++ b/src/sync/impl/applyRemote.d.ts @@ -1,6 +1,11 @@ import type { Database } from '../..' -import type { SyncDatabaseChangeSet, SyncLog, SyncConflictResolver } from '../index' +import type { + SyncDatabaseChangeSet, + SyncLog, + SyncShouldUpdateRecord, + SyncConflictResolver, +} from '../index' export default function applyRemoteChanges( remoteChanges: SyncDatabaseChangeSet, @@ -8,6 +13,7 @@ export default function applyRemoteChanges( db: Database, sendCreatedAsUpdated: boolean, log?: SyncLog, + shouldUpdateRecord?: SyncShouldUpdateRecord, conflictResolver?: SyncConflictResolver, _unsafeBatchPerCollection?: boolean, } diff --git a/src/sync/impl/applyRemote.js b/src/sync/impl/applyRemote.js index c8a988172..5773ca93f 100644 --- a/src/sync/impl/applyRemote.js +++ b/src/sync/impl/applyRemote.js @@ -23,6 +23,7 @@ import type { SyncDatabaseChangeSet, SyncLog, SyncConflictResolver, + SyncShouldUpdateRecord, SyncPullStrategy, } from '../index' import { prepareCreateFromRaw, prepareUpdateFromRaw, recordFromRaw } from './helpers' @@ -32,6 +33,7 @@ type ApplyRemoteChangesContext = $Exact<{ strategy?: ?SyncPullStrategy, sendCreatedAsUpdated?: boolean, log?: SyncLog, + shouldUpdateRecord?: SyncShouldUpdateRecord, conflictResolver?: SyncConflictResolver, _unsafeBatchPerCollection?: boolean, }> @@ -263,7 +265,7 @@ function prepareApplyRemoteChangesToCollection( collection: Collection, context: ApplyRemoteChangesContext, ): Array { - const { db, sendCreatedAsUpdated, log, conflictResolver } = context + const { db, sendCreatedAsUpdated, log, shouldUpdateRecord, conflictResolver } = context const { table } = collection const { created, @@ -292,7 +294,14 @@ function prepareApplyRemoteChangesToCollection( `[Sync] Server wants client to create record ${table}#${raw.id}, but it already exists locally. This may suggest last sync partially executed, and then failed; or it could be a serious bug. Will update existing record instead.`, ) recordsToBatch.push( - prepareUpdateFromRaw(currentRecord, raw, collection, log, conflictResolver), + prepareUpdateFromRaw( + currentRecord, + raw, + collection, + log, + shouldUpdateRecord, + conflictResolver, + ), ) } else if (locallyDeletedIds.includes(raw.id)) { logError( @@ -312,7 +321,14 @@ function prepareApplyRemoteChangesToCollection( if (currentRecord) { recordsToBatch.push( - prepareUpdateFromRaw(currentRecord, raw, collection, log, conflictResolver), + prepareUpdateFromRaw( + currentRecord, + raw, + collection, + log, + shouldUpdateRecord, + conflictResolver, + ), ) } else if (locallyDeletedIds.includes(raw.id)) { // Nothing to do, record was locally deleted, deletion will be pushed later diff --git a/src/sync/impl/helpers.d.ts b/src/sync/impl/helpers.d.ts index 746efbc87..373d37fa1 100644 --- a/src/sync/impl/helpers.d.ts +++ b/src/sync/impl/helpers.d.ts @@ -1,6 +1,11 @@ import type { Model, Collection, Database } from '../..' import type { RawRecord, DirtyRaw } from '../../RawRecord' -import type { SyncLog, SyncDatabaseChangeSet, SyncConflictResolver } from '../index' +import type { + SyncLog, + SyncDatabaseChangeSet, + SyncShouldUpdateRecord, + SyncConflictResolver, +} from '../index' // Returns raw record with naive solution to a conflict based on local `_changed` field // This is a per-column resolution algorithm. All columns that were changed locally win @@ -16,6 +21,7 @@ export function prepareUpdateFromRaw( record: T, updatedDirtyRaw: DirtyRaw, log?: SyncLog, + shouldUpdateRecord?: SyncShouldUpdateRecord, conflictResolver?: SyncConflictResolver, ): T diff --git a/src/sync/impl/helpers.js b/src/sync/impl/helpers.js index df9d32d19..cbb3f94d5 100644 --- a/src/sync/impl/helpers.js +++ b/src/sync/impl/helpers.js @@ -6,7 +6,12 @@ import { invariant } from '../../utils/common' import type { Model, Collection, Database } from '../..' import { type RawRecord, type DirtyRaw, sanitizedRaw } from '../../RawRecord' -import type { SyncLog, SyncDatabaseChangeSet, SyncConflictResolver } from '../index' +import type { + SyncLog, + SyncDatabaseChangeSet, + SyncShouldUpdateRecord, + SyncConflictResolver, +} from '../index' // Returns raw record with naive solution to a conflict based on local `_changed` field // This is a per-column resolution algorithm. All columns that were changed locally win @@ -59,7 +64,14 @@ export function requiresUpdate( collection: Collection, local: RawRecord, dirtyRemote: DirtyRaw, + shouldUpdateRecord?: SyncShouldUpdateRecord, ): boolean { + if (shouldUpdateRecord) { + if (!shouldUpdateRecord(collection.table, local, dirtyRemote)) { + return false + } + } + if (local._status !== 'synced') { return true } @@ -79,9 +91,10 @@ export function prepareUpdateFromRaw( remoteDirtyRaw: DirtyRaw, collection: Collection, log: ?SyncLog, + shouldUpdateRecord?: SyncShouldUpdateRecord, conflictResolver?: SyncConflictResolver, ): ?T { - if (!requiresUpdate(collection, localRaw, remoteDirtyRaw)) { + if (!requiresUpdate(collection, localRaw, remoteDirtyRaw, shouldUpdateRecord)) { return null } diff --git a/src/sync/impl/synchronize.d.ts b/src/sync/impl/synchronize.d.ts index f19c96957..ccf0751c1 100644 --- a/src/sync/impl/synchronize.d.ts +++ b/src/sync/impl/synchronize.d.ts @@ -8,6 +8,7 @@ export default function synchronize({ sendCreatedAsUpdated, migrationsEnabledAtVersion, log, + shouldUpdateRecord, conflictResolver, _unsafeBatchPerCollection, unsafeTurbo, diff --git a/src/sync/impl/synchronize.js b/src/sync/impl/synchronize.js index eef1f6e81..4755c879d 100644 --- a/src/sync/impl/synchronize.js +++ b/src/sync/impl/synchronize.js @@ -23,6 +23,7 @@ export default async function synchronize({ sendCreatedAsUpdated = false, migrationsEnabledAtVersion, log, + shouldUpdateRecord, conflictResolver, _unsafeBatchPerCollection, unsafeTurbo, @@ -105,6 +106,7 @@ export default async function synchronize({ strategy: ((pullResult: any).experimentalStrategy: ?SyncPullStrategy), sendCreatedAsUpdated, log, + shouldUpdateRecord, conflictResolver, _unsafeBatchPerCollection, }) diff --git a/src/sync/index.d.ts b/src/sync/index.d.ts index b4449d31f..acdc274e2 100644 --- a/src/sync/index.d.ts +++ b/src/sync/index.d.ts @@ -49,6 +49,12 @@ export type SyncLog = { error?: Error; } +export type SyncShouldUpdateRecord = ( + table: TableName, + local: DirtyRaw, + remote: DirtyRaw, +) => boolean + export type SyncConflictResolver = ( table: TableName, local: DirtyRaw, @@ -64,6 +70,9 @@ export type SyncArgs = $Exact<{ migrationsEnabledAtVersion?: SchemaVersion; sendCreatedAsUpdated?: boolean; log?: SyncLog; + // Advanced (unsafe) customization point. Useful when doing per record conflict resolution and can + // determine directly from remote and local if we can keep local. + shouldUpdateRecord?: SyncShouldUpdateRecord; // Advanced (unsafe) customization point. Useful when you have subtle invariants between multiple // columns and want to have them updated consistently, or to implement partial sync // It's called for every record being updated locally, so be sure that this function is FAST. diff --git a/src/sync/index.js b/src/sync/index.js index fcce9b80d..6bb8142e6 100644 --- a/src/sync/index.js +++ b/src/sync/index.js @@ -75,6 +75,12 @@ export type SyncLog = { error?: Error, } +export type SyncShouldUpdateRecord = ( + table: TableName, + local: DirtyRaw, + remote: DirtyRaw, +) => boolean + export type SyncConflictResolver = ( table: TableName, local: DirtyRaw, @@ -91,6 +97,9 @@ export type SyncArgs = $Exact<{ migrationsEnabledAtVersion?: SchemaVersion, sendCreatedAsUpdated?: boolean, log?: SyncLog, + // Advanced (unsafe) customization point. Useful when doing per record conflict resolution and can + // determine directly from remote and local if we can keep local. + shouldUpdateRecord?: SyncShouldUpdateRecord, // Advanced (unsafe) customization point. Useful when you have subtle invariants between multiple // columns and want to have them updated consistently, or to implement partial sync // It's called for every record being updated locally, so be sure that this function is FAST.