Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Push only support #1739

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG-Unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

### BREAKING CHANGES

#### 2024-01-17

- SyncPushArgs was renamed to SyncPushChangesArgs to free SyncPushArgs which is now used for push sync.
- lastPulletAt in pushChanges is no longer forced to be defined

### Deprecations

### New features
Expand Down
2 changes: 2 additions & 0 deletions src/RawRecord/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type DirtyRaw = { [key: string]: any }
type _RawRecord = {
id: RecordId
_status: SyncStatus
// _changed is used by default pull conflict resolution and determines columns for which local
// changes will override remote changes
_changed: string
}

Expand Down
69 changes: 67 additions & 2 deletions src/sync/impl/__tests__/markAsSynced.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ describe('markLocalChangesAsSynced', () => {
})

// test that second push will mark all as synced
await markLocalChangesAsSynced(database, localChanges2)
await markLocalChangesAsSynced(database, localChanges2, false)
expect(destroyDeletedRecordsSpy).toHaveBeenCalledTimes(2)
expect(await fetchLocalChanges(database)).toEqual(emptyLocalChanges)

Expand All @@ -146,7 +146,7 @@ describe('markLocalChangesAsSynced', () => {
const localChanges = await fetchLocalChanges(database)

// mark as synced
await markLocalChangesAsSynced(database, localChanges, {
await markLocalChangesAsSynced(database, localChanges, false, {
mock_projects: ['pCreated1', 'pUpdated'],
mock_comments: ['cDeleted'],
})
Expand All @@ -161,6 +161,30 @@ describe('markLocalChangesAsSynced', () => {
)
expect(await allDeletedRecords([comments])).toEqual(['cDeleted'])
})
it(`marks only acceptedIds as synced`, async () => {
const { database, comments } = makeDatabase()

const { pCreated1, pUpdated } = await makeLocalChanges(database)
const localChanges = await fetchLocalChanges(database)

// mark as synced
await markLocalChangesAsSynced(database, localChanges, true, {}, {
// probably better solution exists (we essentially list all but expected in verify)
mock_projects: ['pCreated2', 'pDeleted'],
mock_comments: ['cCreated', 'cUpdated'],
mock_tasks: ['tCreated', 'tUpdated', 'tDeleted'],
})

// verify
const localChanges2 = await fetchLocalChanges(database)
expect(localChanges2.changes).toEqual(
makeChangeSet({
mock_projects: { created: [pCreated1._raw], updated: [pUpdated._raw] },
mock_comments: { deleted: ['cDeleted'] },
}),
)
expect(await allDeletedRecords([comments])).toEqual(['cDeleted'])
})
it(`can mark records as synced when ids are per-table not globally unique`, async () => {
const { database, projects, tasks, comments } = makeDatabase()

Expand Down Expand Up @@ -202,4 +226,45 @@ describe('markLocalChangesAsSynced', () => {
it.skip('only returns changed fields', async () => {
// TODO: Possible future improvement?
})
describe('pushConflictResolver', () => {
it('marks local changes as synced', async () => {
const { database, tasks } = makeDatabase()

await makeLocalChanges(database)

await markLocalChangesAsSynced(database, await fetchLocalChanges(database), false, null, null,
(_table, local, remote, resolved) => {
if (local.id !== 'tCreated' || (remote && remote.changeMe !== true)) {
return resolved
}
resolved.name = remote.name
resolved._status = 'updated'
return resolved
},
{
mock_tasks: [
{
id: 'tCreated',
name: 'I shall prevail',
changeMe: true,
},
],
})

await expectSyncedAndMatches(tasks, 'tCreated', {
_status: 'updated',
name: 'I shall prevail', // concat of remote and local change
})

// should be untouched
await expectSyncedAndMatches(tasks, 'tUpdated', {
_status: 'synced',
name: 'local',
position: 100,
description: 'orig',
project_id: 'orig',
})

})
})
})
67 changes: 67 additions & 0 deletions src/sync/impl/__tests__/synchronize-partialRejections.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,71 @@ describe('synchronize - partial push rejections', () => {
}),
)
})
it(`can partially accept a push`, async () => {
const { database } = makeDatabase()

const { tCreated, tUpdated } = await makeLocalChanges(database)

const acceptedIds = Object.freeze({
// probably better solution exists (we essentially list all but expected in expect below)
mock_projects: ['pCreated1', 'pCreated2', 'pDeleted', 'pUpdated'],
mock_comments: ['cCreated', 'cUpdated'],
mock_tasks: ['tDeleted'],
})
const rejectedIds = Object.freeze({
mock_tasks: ['tCreated', 'tUpdated'],
mock_comments: ['cDeleted'],
})
const log = {}
await synchronize({
database,
pullChanges: jest.fn(emptyPull()),
pushChanges: jest.fn(() => ({ experimentalAcceptedIds: acceptedIds })),
pushShouldConfirmOnlyAccepted: true,
log,
})
expect((await fetchLocalChanges(database)).changes).toEqual(
makeChangeSet({
mock_tasks: { created: [tCreated._raw], updated: [tUpdated._raw] },
mock_comments: { deleted: ['cDeleted'] },
}),
)
expect(log.rejectedIds).toStrictEqual(rejectedIds)
})
it(`can partially accept a push and make changes during push`, async () => {
const { database, comments } = makeDatabase()

const { pCreated1, tUpdated } = await makeLocalChanges(database)
const pCreated1Raw = { ...pCreated1._raw }
let newComment
await synchronize({
database,
pullChanges: jest.fn(emptyPull()),
pushChanges: jest.fn(async () => {
await database.write(async () => {
await pCreated1.update((p) => {
p.name = 'updated!'
})
newComment = await comments.create((c) => {
c.body = 'bazinga'
})
})
return {
experimentalAcceptedIds: {
mock_projects: ['pCreated1', 'pCreated2', 'pDeleted', 'pUpdated'],
mock_comments: ['cCreated', 'cUpdated'],
mock_tasks: ['tCreated', 'tDeleted'],
},
}
}),
pushShouldConfirmOnlyAccepted: true,
})
expect((await fetchLocalChanges(database)).changes).toEqual(
makeChangeSet({
mock_projects: { created: [{ ...pCreated1Raw, _changed: 'name', name: 'updated!' }] },
mock_tasks: { updated: [tUpdated._raw] },
mock_comments: { created: [newComment._raw], deleted: ['cDeleted'] },
}),
)
})
})
50 changes: 49 additions & 1 deletion src/sync/impl/__tests__/synchronize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
emptyPull,
} from './helpers'

import { synchronize, hasUnsyncedChanges } from '../../index'
import { synchronize, optimisticSyncPush, hasUnsyncedChanges } from '../../index'
import { fetchLocalChanges, getLastPulledAt } from '../index'

const observeDatabase = (database) => {
Expand Down Expand Up @@ -151,6 +151,20 @@ describe('synchronize', () => {
expect(await fetchLocalChanges(database)).toEqual(emptyLocalChanges)
expect(log.localChangeCount).toBe(10)
})
it('can do push-only sync', async () => {
const { database } = makeDatabase()

await makeLocalChanges(database)
const localChanges = await fetchLocalChanges(database)

const pushChanges = jest.fn()
const log = {}
await optimisticSyncPush({ database, pushChanges, log })

expect(pushChanges).toHaveBeenCalledWith({ changes: localChanges.changes, lastPulledAt: null })
expect(await fetchLocalChanges(database)).toEqual(emptyLocalChanges)
expect(log.localChangeCount).toBe(10)
})
it('can pull changes', async () => {
const { database, projects, tasks } = makeDatabase()

Expand Down Expand Up @@ -571,4 +585,38 @@ describe('synchronize', () => {
it.skip(`only emits one collection batch change`, async () => {
// TODO: unskip when batch change emissions are implemented
})
it(`allows push conflict resolution to be customized`, async () => {
const { database, tasks } = makeDatabase()
const task = tasks.prepareCreateFromDirtyRaw({
id: 't1',
name: 'Task name',
position: 1,
is_completed: false,
project_id: 'p1',
})
await database.write(() => database.batch(task))

const pushConflictResolver = jest.fn((_table, local, remote, resolved) => {
return resolved
})

await synchronize({
database,
pullChanges: () => ({
timestamp: 1500,
}),
pushChanges: async () => {
return {
pushResultSet: {
mock_tasks: [
{id: 't1'},
],
},
}
},
pushConflictResolver,
})

expect(pushConflictResolver).toHaveBeenCalledTimes(1)
})
})
12 changes: 1 addition & 11 deletions src/sync/impl/applyRemote.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type {
Collection,
Model,
TableName,
DirtyRaw,
Query,
RawRecord,
} from '../..'
Expand All @@ -25,7 +24,7 @@ import type {
SyncConflictResolver,
SyncPullStrategy,
} from '../index'
import { prepareCreateFromRaw, prepareUpdateFromRaw, recordFromRaw } from './helpers'
import { prepareCreateFromRaw, prepareUpdateFromRaw, recordFromRaw, validateRemoteRaw } from './helpers'

type ApplyRemoteChangesContext = $Exact<{
db: Database,
Expand Down Expand Up @@ -249,15 +248,6 @@ const getAllRecordsToApply = (
)
}

function validateRemoteRaw(raw: DirtyRaw): void {
// TODO: I think other code is actually resilient enough to handle illegal _status and _changed
// would be best to change that part to a warning - but tests are needed
invariant(
raw && typeof raw === 'object' && 'id' in raw && !('_status' in raw || '_changed' in raw),
`[Sync] Invalid raw record supplied to Sync. Records must be objects, must have an 'id' field, and must NOT have a '_status' or '_changed' fields`,
)
}

function prepareApplyRemoteChangesToCollection<T: Model>(
recordsToApply: RecordsToApplyRemoteChangesTo<T>,
collection: Collection<T>,
Expand Down
16 changes: 14 additions & 2 deletions src/sync/impl/helpers.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
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,
SyncPushResultSet,
} 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
// and will be applied on top of the remote version.
export function resolveConflict(local: RawRecord, remote: DirtyRaw): DirtyRaw

export function validateRemoteRaw(raw: DirtyRaw): void

export function prepareCreateFromRaw<T extends Model = Model>(
collection: Collection<T>,
dirtyRaw: DirtyRaw,
Expand All @@ -19,7 +27,11 @@ export function prepareUpdateFromRaw<T extends Model = Model>(
conflictResolver?: SyncConflictResolver,
): T

export function prepareMarkAsSynced<T extends Model = Model>(record: T): T
export function prepareMarkAsSynced<T extends Model = Model>(
record: T,
pushConflictResolver?: SyncConflictResolver,
remoteDirtyRaw?: DirtyRaw,
): T

export function ensureSameDatabase(database: Database, initialResetCount: number): void

Expand Down
Loading