@interop/was-react 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/auth/chapi.d.ts +3 -1
- package/dist/auth/chapi.d.ts.map +1 -1
- package/dist/auth/chapi.js +6 -2
- package/dist/auth/chapi.js.map +1 -1
- package/dist/auth/loginFlow.d.ts +30 -10
- package/dist/auth/loginFlow.d.ts.map +1 -1
- package/dist/auth/loginFlow.js +3 -1
- package/dist/auth/loginFlow.js.map +1 -1
- package/dist/auth/loginRequest.d.ts +9 -3
- package/dist/auth/loginRequest.d.ts.map +1 -1
- package/dist/auth/loginRequest.js +9 -3
- package/dist/auth/loginRequest.js.map +1 -1
- package/dist/auth/verifyResponse.d.ts +9 -3
- package/dist/auth/verifyResponse.d.ts.map +1 -1
- package/dist/auth/verifyResponse.js +6 -2
- package/dist/auth/verifyResponse.js.map +1 -1
- package/dist/config.d.ts +60 -20
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -4
- package/dist/config.js.map +1 -1
- package/dist/dev/cli.d.ts.map +1 -1
- package/dist/dev/cli.js +4 -7
- package/dist/dev/cli.js.map +1 -1
- package/dist/dev/provisionDevGrants.d.ts +15 -5
- package/dist/dev/provisionDevGrants.d.ts.map +1 -1
- package/dist/dev/provisionDevGrants.js +2 -2
- package/dist/dev/provisionDevGrants.js.map +1 -1
- package/dist/grants.d.ts +7 -19
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +28 -7
- package/dist/grants.js.map +1 -1
- package/dist/identity/appSession.d.ts +9 -3
- package/dist/identity/appSession.d.ts.map +1 -1
- package/dist/identity/appSession.js +3 -1
- package/dist/identity/appSession.js.map +1 -1
- package/dist/identity/documentLoader.d.ts +3 -1
- package/dist/identity/documentLoader.d.ts.map +1 -1
- package/dist/identity/documentLoader.js +6 -2
- package/dist/identity/documentLoader.js.map +1 -1
- package/dist/identity/seedCredential.d.ts +9 -3
- package/dist/identity/seedCredential.d.ts.map +1 -1
- package/dist/identity/seedCredential.js +9 -15
- package/dist/identity/seedCredential.js.map +1 -1
- package/dist/identity/seedStore.d.ts +18 -6
- package/dist/identity/seedStore.d.ts.map +1 -1
- package/dist/identity/seedStore.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/react/WasSessionProvider.d.ts.map +1 -1
- package/dist/react/WasSessionProvider.js +10 -1
- package/dist/react/WasSessionProvider.js.map +1 -1
- package/dist/react/hooks.d.ts +10 -5
- package/dist/react/hooks.d.ts.map +1 -1
- package/dist/react/hooks.js +6 -30
- package/dist/react/hooks.js.map +1 -1
- package/dist/session/authStore.d.ts +29 -7
- package/dist/session/authStore.d.ts.map +1 -1
- package/dist/session/authStore.js +125 -64
- package/dist/session/authStore.js.map +1 -1
- package/dist/storage/entityStore.d.ts +32 -9
- package/dist/storage/entityStore.d.ts.map +1 -1
- package/dist/storage/entityStore.js +88 -48
- package/dist/storage/entityStore.js.map +1 -1
- package/dist/storage/localStore.d.ts +53 -0
- package/dist/storage/localStore.d.ts.map +1 -1
- package/dist/storage/localStore.js +140 -12
- package/dist/storage/localStore.js.map +1 -1
- package/dist/storage/rehydrate.d.ts +8 -0
- package/dist/storage/rehydrate.d.ts.map +1 -1
- package/dist/storage/rehydrate.js +31 -2
- package/dist/storage/rehydrate.js.map +1 -1
- package/dist/storage/syncController.d.ts +7 -3
- package/dist/storage/syncController.d.ts.map +1 -1
- package/dist/storage/syncController.js +28 -15
- package/dist/storage/syncController.js.map +1 -1
- package/dist/storage/syncStatusStore.d.ts +26 -1
- package/dist/storage/syncStatusStore.d.ts.map +1 -1
- package/dist/storage/syncStatusStore.js +40 -0
- package/dist/storage/syncStatusStore.js.map +1 -1
- package/dist/storage/wasRemoteStore.d.ts +3 -1
- package/dist/storage/wasRemoteStore.d.ts.map +1 -1
- package/dist/storage/wasRemoteStore.js +3 -3
- package/dist/storage/wasRemoteStore.js.map +1 -1
- package/dist/sync/feedMasterPort.d.ts +21 -19
- package/dist/sync/feedMasterPort.d.ts.map +1 -1
- package/dist/sync/feedMasterPort.js +18 -5
- package/dist/sync/feedMasterPort.js.map +1 -1
- package/dist/sync/index.d.ts +3 -3
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +2 -2
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/lwwConflictHandler.d.ts +35 -2
- package/dist/sync/lwwConflictHandler.d.ts.map +1 -1
- package/dist/sync/lwwConflictHandler.js +47 -9
- package/dist/sync/lwwConflictHandler.js.map +1 -1
- package/dist/sync/pushWrites.d.ts +27 -8
- package/dist/sync/pushWrites.d.ts.map +1 -1
- package/dist/sync/pushWrites.js +43 -15
- package/dist/sync/pushWrites.js.map +1 -1
- package/dist/sync/types.d.ts +49 -16
- package/dist/sync/types.d.ts.map +1 -1
- package/dist/sync/types.js +17 -0
- package/dist/sync/types.js.map +1 -1
- package/dist/sync/wasReplication.d.ts.map +1 -1
- package/dist/sync/wasReplication.js +38 -1
- package/dist/sync/wasReplication.js.map +1 -1
- package/dist/sync/wasSyncPort.d.ts +29 -6
- package/dist/sync/wasSyncPort.d.ts.map +1 -1
- package/dist/sync/wasSyncPort.js +105 -101
- package/dist/sync/wasSyncPort.js.map +1 -1
- package/package.json +4 -3
- package/dist/auth/verifyResponse.test.d.ts +0 -2
- package/dist/auth/verifyResponse.test.d.ts.map +0 -1
- package/dist/auth/verifyResponse.test.js +0 -282
- package/dist/auth/verifyResponse.test.js.map +0 -1
- package/dist/dev/cli.test.d.ts +0 -2
- package/dist/dev/cli.test.d.ts.map +0 -1
- package/dist/dev/cli.test.js +0 -39
- package/dist/dev/cli.test.js.map +0 -1
- package/dist/grants.test.d.ts +0 -2
- package/dist/grants.test.d.ts.map +0 -1
- package/dist/grants.test.js +0 -79
- package/dist/grants.test.js.map +0 -1
- package/dist/identity/seedCredential.test.d.ts +0 -2
- package/dist/identity/seedCredential.test.d.ts.map +0 -1
- package/dist/identity/seedCredential.test.js +0 -205
- package/dist/identity/seedCredential.test.js.map +0 -1
- package/dist/storage/rehydrate.test.d.ts +0 -2
- package/dist/storage/rehydrate.test.d.ts.map +0 -1
- package/dist/storage/rehydrate.test.js +0 -172
- package/dist/storage/rehydrate.test.js.map +0 -1
- package/dist/sync/changesQuery.test.d.ts +0 -2
- package/dist/sync/changesQuery.test.d.ts.map +0 -1
- package/dist/sync/changesQuery.test.js +0 -145
- package/dist/sync/changesQuery.test.js.map +0 -1
- package/dist/sync/lww.test.d.ts +0 -2
- package/dist/sync/lww.test.d.ts.map +0 -1
- package/dist/sync/lww.test.js +0 -28
- package/dist/sync/lww.test.js.map +0 -1
- package/dist/sync/lwwConflictHandler.test.d.ts +0 -2
- package/dist/sync/lwwConflictHandler.test.d.ts.map +0 -1
- package/dist/sync/lwwConflictHandler.test.js +0 -78
- package/dist/sync/lwwConflictHandler.test.js.map +0 -1
- package/dist/sync/pushWrites.test.d.ts +0 -2
- package/dist/sync/pushWrites.test.d.ts.map +0 -1
- package/dist/sync/pushWrites.test.js +0 -287
- package/dist/sync/pushWrites.test.js.map +0 -1
|
@@ -16,4 +16,44 @@ export const useSyncStatusStore = create()(set => ({
|
|
|
16
16
|
})),
|
|
17
17
|
reset: () => set({ statuses: {} })
|
|
18
18
|
}));
|
|
19
|
+
/**
|
|
20
|
+
* Rolls the per-collection replication statuses up to a single aggregate plus
|
|
21
|
+
* its display copy. With no collections registered it reports `offline`
|
|
22
|
+
* (local-only, no sync running); otherwise it applies the
|
|
23
|
+
* error > syncing > synced precedence (`idle` counts as syncing -- a collection
|
|
24
|
+
* configured but not yet cycled). Kept beside the status vocabulary so the
|
|
25
|
+
* precedence lives with the store rather than the view; `useSyncStatus` is a
|
|
26
|
+
* thin subscription over it.
|
|
27
|
+
*
|
|
28
|
+
* @param statuses {SyncStatus[]} the per-collection statuses (store values)
|
|
29
|
+
* @returns {{ state: SyncRollup, label: string, title: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function deriveSyncRollup(statuses) {
|
|
32
|
+
if (statuses.length === 0) {
|
|
33
|
+
return {
|
|
34
|
+
state: 'offline',
|
|
35
|
+
label: 'Offline',
|
|
36
|
+
title: 'Local-only mode -- no storage sync running'
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (statuses.includes('error')) {
|
|
40
|
+
return {
|
|
41
|
+
state: 'error',
|
|
42
|
+
label: 'Sync error',
|
|
43
|
+
title: 'A collection failed to sync; retrying'
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (statuses.includes('syncing') || statuses.includes('idle')) {
|
|
47
|
+
return {
|
|
48
|
+
state: 'syncing',
|
|
49
|
+
label: 'Syncing',
|
|
50
|
+
title: 'Replicating with your wallet storage'
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
state: 'synced',
|
|
55
|
+
label: 'Synced',
|
|
56
|
+
title: 'All collections replicated'
|
|
57
|
+
};
|
|
58
|
+
}
|
|
19
59
|
//# sourceMappingURL=syncStatusStore.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"syncStatusStore.js","sourceRoot":"","sources":["../../src/storage/syncStatusStore.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;GAMG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"syncStatusStore.js","sourceRoot":"","sources":["../../src/storage/syncStatusStore.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;GAMG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAoBhC,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,EAAmB,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAClE,QAAQ,EAAE,EAAE;IACZ,SAAS,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,EAAE,CAClC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACZ,QAAQ,EAAE,EAAE,GAAG,KAAK,CAAC,QAAQ,EAAE,CAAC,YAAY,CAAC,EAAE,MAAM,EAAE;KACxD,CAAC,CAAC;IACL,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;CACnC,CAAC,CAAC,CAAA;AASH;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAAsB;IAKrD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO;YACL,KAAK,EAAE,SAAS;YAChB,KAAK,EAAE,SAAS;YAChB,KAAK,EAAE,4CAA4C;SACpD,CAAA;IACH,CAAC;IACD,IAAI,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;QAC/B,OAAO;YACL,KAAK,EAAE,OAAO;YACd,KAAK,EAAE,YAAY;YACnB,KAAK,EAAE,uCAAuC;SAC/C,CAAA;IACH,CAAC;IACD,IAAI,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9D,OAAO;YACL,KAAK,EAAE,SAAS;YAChB,KAAK,EAAE,SAAS;YAChB,KAAK,EAAE,sCAAsC;SAC9C,CAAA;IACH,CAAC;IACD,OAAO;QACL,KAAK,EAAE,QAAQ;QACf,KAAK,EAAE,QAAQ;QACf,KAAK,EAAE,4BAA4B;KACpC,CAAA;AACH,CAAC"}
|
|
@@ -21,7 +21,9 @@ import type { ZcapClient } from '@interop/ezcap';
|
|
|
21
21
|
import type { IZcap } from '@interop/data-integrity-core';
|
|
22
22
|
import { WasClient } from '@interop/was-client';
|
|
23
23
|
import type { ParsedGrants } from '../grants.js';
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The outcome of a best-effort encryption-marker PUT, for diagnostics.
|
|
26
|
+
*/
|
|
25
27
|
export interface MarkerResult {
|
|
26
28
|
collectionId: string;
|
|
27
29
|
ok: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wasRemoteStore.d.ts","sourceRoot":"","sources":["../../src/storage/wasRemoteStore.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,8BAA8B,CAAA;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"wasRemoteStore.d.ts","sourceRoot":"","sources":["../../src/storage/wasRemoteStore.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,8BAA8B,CAAA;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAG/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAEhD;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,CAAA;IACpB,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,qBAAa,cAAc;IACzB,SAAgB,GAAG,EAAE,SAAS,CAAA;IAC9B,SAAgB,SAAS,EAAE,MAAM,CAAA;IACjC,SAAgB,OAAO,EAAE,MAAM,CAAA;IAC/B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAuB;IAEvD,OAAO;IAaP;;;;;;;;;;OAUG;IACH,MAAM,CAAC,UAAU,CAAC,EAChB,MAAM,EACN,UAAU,EACX,EAAE;QACD,MAAM,EAAE,YAAY,CAAA;QACpB,UAAU,EAAE,UAAU,CAAA;KACvB,GAAG,cAAc;IASlB;;;;;;;OAOG;IACH,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,KAAK,GAAG,SAAS;IAI7D;;;;;;;;;OASG;IACG,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;CAmB3E"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WasClient } from '@interop/was-client';
|
|
2
2
|
import { createEdvEncryption } from '@interop/was-client/edv';
|
|
3
|
+
import { errorStatus, errorMessage } from '../sync/index.js';
|
|
3
4
|
export class WasRemoteStore {
|
|
4
5
|
was;
|
|
5
6
|
serverUrl;
|
|
@@ -66,9 +67,8 @@ export class WasRemoteStore {
|
|
|
66
67
|
return { collectionId, ok: true, status: response.status };
|
|
67
68
|
}
|
|
68
69
|
catch (err) {
|
|
69
|
-
const status = err
|
|
70
|
-
|
|
71
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
70
|
+
const status = errorStatus(err);
|
|
71
|
+
const message = errorMessage(err);
|
|
72
72
|
return { collectionId, ok: false, status, error: message };
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wasRemoteStore.js","sourceRoot":"","sources":["../../src/storage/wasRemoteStore.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"wasRemoteStore.js","sourceRoot":"","sources":["../../src/storage/wasRemoteStore.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAA;AAC7D,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAa5D,MAAM,OAAO,cAAc;IACT,GAAG,CAAW;IACd,SAAS,CAAQ;IACjB,OAAO,CAAQ;IACd,eAAe,CAAuB;IAEvD,YAAoB,EAClB,GAAG,EACH,MAAM,EAIP;QACC,IAAI,CAAC,GAAG,GAAG,GAAG,CAAA;QACd,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,CAAA;QACjC,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;QAC7B,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,cAAc,CAAA;IAC9C,CAAC;IAED;;;;;;;;;;OAUG;IACH,MAAM,CAAC,UAAU,CAAC,EAChB,MAAM,EACN,UAAU,EAIX;QACC,MAAM,GAAG,GAAG,IAAI,SAAS,CAAC;YACxB,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,UAAU;YACV,UAAU,EAAE,mBAAmB,CAAC,EAAE,WAAW,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC;SACnE,CAAC,CAAA;QACF,OAAO,IAAI,cAAc,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC,CAAA;IAC5C,CAAC;IAED;;;;;;;OAOG;IACH,oBAAoB,CAAC,YAAoB;QACvC,OAAO,IAAI,CAAC,eAAe,CAAC,YAAY,CAAC,CAAA;IAC3C,CAAC;IAED;;;;;;;;;OASG;IACH,KAAK,CAAC,uBAAuB,CAAC,YAAoB;QAChD,MAAM,UAAU,GAAG,IAAI,CAAC,oBAAoB,CAAC,YAAY,CAAC,CAAA;QAC1D,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,OAAO,EAAE,YAAY,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,eAAe,EAAE,CAAA;QAC5D,CAAC;QACD,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC;gBACtC,UAAU;gBACV,IAAI,EAAE,UAAU,IAAI,CAAC,OAAO,IAAI,YAAY,EAAE;gBAC9C,MAAM,EAAE,KAAK;gBACb,IAAI,EAAE,EAAE,EAAE,EAAE,YAAY,EAAE,UAAU,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE;aAC1D,CAAC,CAAA;YACF,OAAO,EAAE,YAAY,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAA;QAC5D,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,CAAC,CAAA;YAC/B,MAAM,OAAO,GAAG,YAAY,CAAC,GAAG,CAAC,CAAA;YACjC,OAAO,EAAE,YAAY,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,CAAA;QAC5D,CAAC;IACH,CAAC;CACF"}
|
|
@@ -2,29 +2,31 @@
|
|
|
2
2
|
* Copyright (c) 2026 Interop Alliance. All rights reserved.
|
|
3
3
|
*/
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The `get` half of a {@link WasSyncPort}: resolves a resource's current master
|
|
6
|
+
* state (for the 412 conflict re-read) from the CHANGES-FEED BODY. Wraps a
|
|
7
|
+
* {@link WasSyncBasePort} (query + conditional writes) and adds the `get` the
|
|
8
|
+
* conflict assembler needs, producing a full {@link WasSyncPort}.
|
|
8
9
|
*
|
|
9
|
-
* Why
|
|
10
|
-
* resource's current `version` and comparing
|
|
11
|
-
*
|
|
12
|
-
* which a browser hides on a CROSS-ORIGIN response unless the server
|
|
13
|
-
* `Access-Control-Expose-Headers: etag`. A server that does not expose it
|
|
14
|
-
* cross-origin
|
|
15
|
-
* re-push forever with a stale `If-Match` and never converge. The
|
|
16
|
-
* carries `version` (and `data`, `_deleted`, ...) in the JSON
|
|
17
|
-
* never strips, so resolving the master from the feed is both
|
|
18
|
-
* origin-independent. Normal pull/push are unaffected (they already
|
|
19
|
-
* `version` from the feed body)
|
|
10
|
+
* Why the feed body rather than a `GET` ETag header: the mutable-head model
|
|
11
|
+
* settles a 412 by re-reading the resource's current `version` and comparing
|
|
12
|
+
* payloads (LWW). A raw `GET` reads that `version` from the response's `etag`
|
|
13
|
+
* header -- which a browser hides on a CROSS-ORIGIN response unless the server
|
|
14
|
+
* sends `Access-Control-Expose-Headers: etag`. A server that does not expose it
|
|
15
|
+
* would make a cross-origin re-read report `version: 0`, so the loser of a push
|
|
16
|
+
* race would re-push forever with a stale `If-Match` and never converge. The
|
|
17
|
+
* `changes` feed carries `version` (and `data`, `_deleted`, ...) in the JSON
|
|
18
|
+
* BODY, which CORS never strips, so resolving the master from the feed is both
|
|
19
|
+
* correct and origin-independent. Normal pull/push are unaffected (they already
|
|
20
|
+
* read `version` from the feed body).
|
|
20
21
|
*/
|
|
21
|
-
import type { WasSyncPort } from './types.js';
|
|
22
|
+
import type { WasSyncBasePort, WasSyncPort } from './types.js';
|
|
22
23
|
/**
|
|
23
|
-
* Wraps a base port
|
|
24
|
-
* `query`, `putContent`, `deleteContent`, and `putMeta` pass straight
|
|
24
|
+
* Wraps a base port with a `get` that resolves the master state from the changes
|
|
25
|
+
* feed. `query`, `putContent`, `deleteContent`, and `putMeta` pass straight
|
|
26
|
+
* through.
|
|
25
27
|
*
|
|
26
|
-
* @param base {
|
|
28
|
+
* @param base {WasSyncBasePort}
|
|
27
29
|
* @returns {WasSyncPort}
|
|
28
30
|
*/
|
|
29
|
-
export declare function withFeedMasterRead(base:
|
|
31
|
+
export declare function withFeedMasterRead(base: WasSyncBasePort): WasSyncPort;
|
|
30
32
|
//# sourceMappingURL=feedMasterPort.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feedMasterPort.d.ts","sourceRoot":"","sources":["../../src/sync/feedMasterPort.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH
|
|
1
|
+
{"version":3,"file":"feedMasterPort.d.ts","sourceRoot":"","sources":["../../src/sync/feedMasterPort.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAIV,eAAe,EACf,WAAW,EAEZ,MAAM,YAAY,CAAA;AA4BnB;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,CAuCrE"}
|
|
@@ -23,10 +23,11 @@ function toMasterState(doc) {
|
|
|
23
23
|
return master;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* Wraps a base port
|
|
27
|
-
* `query`, `putContent`, `deleteContent`, and `putMeta` pass straight
|
|
26
|
+
* Wraps a base port with a `get` that resolves the master state from the changes
|
|
27
|
+
* feed. `query`, `putContent`, `deleteContent`, and `putMeta` pass straight
|
|
28
|
+
* through.
|
|
28
29
|
*
|
|
29
|
-
* @param base {
|
|
30
|
+
* @param base {WasSyncBasePort}
|
|
30
31
|
* @returns {WasSyncPort}
|
|
31
32
|
*/
|
|
32
33
|
export function withFeedMasterRead(base) {
|
|
@@ -46,12 +47,24 @@ export function withFeedMasterRead(base) {
|
|
|
46
47
|
if (found) {
|
|
47
48
|
return toMasterState(found);
|
|
48
49
|
}
|
|
50
|
+
// Reached the end of the feed without finding it: the resource is
|
|
51
|
+
// genuinely absent (a delete/delete race), so report `null` and let the
|
|
52
|
+
// conflict assembler synthesize a tombstone.
|
|
49
53
|
if (next === null || documents.length === 0) {
|
|
50
|
-
|
|
54
|
+
return null;
|
|
51
55
|
}
|
|
52
56
|
checkpoint = next;
|
|
53
57
|
}
|
|
54
|
-
|
|
58
|
+
// The page budget ran out before the feed's end. The feed is keyset-
|
|
59
|
+
// ordered ascending on `updatedAt`, so a just-conflicted resource sits at
|
|
60
|
+
// the tail -- the least-reachable position -- and may well be past the
|
|
61
|
+
// cap. Reporting `null` here would fabricate a false tombstone in the
|
|
62
|
+
// conflict assembler and drop the winner's payload. Throw instead: RxDB
|
|
63
|
+
// treats a thrown push-handler error as retryable, so the whole
|
|
64
|
+
// replication cycle retries rather than diverging.
|
|
65
|
+
throw new Error(`Feed master re-read for resource "${id}" exhausted its ` +
|
|
66
|
+
`${MAX_PAGES}-page scan budget without reaching the end of the ` +
|
|
67
|
+
`changes feed; retrying.`);
|
|
55
68
|
}
|
|
56
69
|
};
|
|
57
70
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feedMasterPort.js","sourceRoot":"","sources":["../../src/sync/feedMasterPort.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"feedMasterPort.js","sourceRoot":"","sources":["../../src/sync/feedMasterPort.ts"],"names":[],"mappings":"AA8BA,gFAAgF;AAChF,oEAAoE;AACpE,MAAM,SAAS,GAAG,EAAE,CAAA;AACpB,MAAM,SAAS,GAAG,GAAG,CAAA;AAErB;;GAEG;AACH,SAAS,aAAa,CAAC,GAAY;IACjC,MAAM,MAAM,GAAgB;QAC1B,OAAO,EAAE,GAAG,CAAC,OAAO;QACpB,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,OAAO,EAAE,GAAG,CAAC,QAAQ;KACtB,CAAA;IACD,IAAI,GAAG,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC3B,MAAM,CAAC,IAAI,GAAG,GAAG,CAAC,IAAY,CAAA;IAChC,CAAC;IACD,IAAI,GAAG,CAAC,WAAW,KAAK,SAAS,EAAE,CAAC;QAClC,MAAM,CAAC,WAAW,GAAG,GAAG,CAAC,WAAW,CAAA;IACtC,CAAC;IACD,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAC7B,MAAM,CAAC,MAAM,GAAG,GAAG,CAAC,MAAc,CAAA;IACpC,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAqB;IACtD,OAAO;QACL,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;QAC5B,UAAU,EAAE,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC;QACtC,aAAa,EAAE,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC;QAC5C,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC;QAChC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE;YACd,IAAI,UAAsC,CAAA;YAC1C,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC;gBAC5C,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,IAAI,EAAE,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC;oBACvD,GAAG,CAAC,UAAU,KAAK,SAAS,IAAI,EAAE,UAAU,EAAE,CAAC;oBAC/C,KAAK,EAAE,SAAS;iBACjB,CAAC,CAAA;gBACF,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,CAAA;gBAClD,IAAI,KAAK,EAAE,CAAC;oBACV,OAAO,aAAa,CAAC,KAAK,CAAC,CAAA;gBAC7B,CAAC;gBACD,kEAAkE;gBAClE,wEAAwE;gBACxE,6CAA6C;gBAC7C,IAAI,IAAI,KAAK,IAAI,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBAC5C,OAAO,IAAI,CAAA;gBACb,CAAC;gBACD,UAAU,GAAG,IAAI,CAAA;YACnB,CAAC;YACD,qEAAqE;YACrE,0EAA0E;YAC1E,uEAAuE;YACvE,sEAAsE;YACtE,wEAAwE;YACxE,gEAAgE;YAChE,mDAAmD;YACnD,MAAM,IAAI,KAAK,CACb,qCAAqC,EAAE,kBAAkB;gBACvD,GAAG,SAAS,oDAAoD;gBAChE,yBAAyB,CAC5B,CAAA;QACH,CAAC;KACF,CAAA;AACH,CAAC"}
|
package/dist/sync/index.d.ts
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
export { createWasReplication } from './wasReplication.js';
|
|
12
12
|
export { syncedDocSchema } from './syncedDocSchema.js';
|
|
13
13
|
export { createPullHandler, wireDocToRxDoc } from './changesQuery.js';
|
|
14
|
-
export { createPushHandler, formatEtag } from './pushWrites.js';
|
|
15
|
-
export { WasSyncConflictError, type Json, type SyncCheckpoint, type WireDoc, type SyncedDoc, type MasterState, type WasSyncPort } from './types.js';
|
|
16
|
-
export { createWasSyncPort } from './wasSyncPort.js';
|
|
14
|
+
export { createPushHandler, formatEtag, type PushWriteAck } from './pushWrites.js';
|
|
15
|
+
export { WasSyncConflictError, WasSyncAuthError, type Json, type SyncCheckpoint, type WireDoc, type SyncedDoc, type MasterState, type WasSyncBasePort, type WasSyncPort } from './types.js';
|
|
16
|
+
export { createWasSyncPort, errorStatus, errorMessage } from './wasSyncPort.js';
|
|
17
17
|
export { withFeedMasterRead } from './feedMasterPort.js';
|
|
18
18
|
export { createDocCipher, isEncryptedEnvelope, type DocCipher } from './docCipher.js';
|
|
19
19
|
export { makeLwwConflictHandler } from './lwwConflictHandler.js';
|
package/dist/sync/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sync/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;GAMG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACrE,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sync/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;GAMG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACrE,OAAO,EACL,iBAAiB,EACjB,UAAU,EACV,KAAK,YAAY,EAClB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,KAAK,IAAI,EACT,KAAK,cAAc,EACnB,KAAK,OAAO,EACZ,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,WAAW,EACjB,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/E,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACxD,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,KAAK,SAAS,EACf,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAA;AAChE,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA"}
|
package/dist/sync/index.js
CHANGED
|
@@ -12,8 +12,8 @@ export { createWasReplication } from './wasReplication.js';
|
|
|
12
12
|
export { syncedDocSchema } from './syncedDocSchema.js';
|
|
13
13
|
export { createPullHandler, wireDocToRxDoc } from './changesQuery.js';
|
|
14
14
|
export { createPushHandler, formatEtag } from './pushWrites.js';
|
|
15
|
-
export { WasSyncConflictError } from './types.js';
|
|
16
|
-
export { createWasSyncPort } from './wasSyncPort.js';
|
|
15
|
+
export { WasSyncConflictError, WasSyncAuthError } from './types.js';
|
|
16
|
+
export { createWasSyncPort, errorStatus, errorMessage } from './wasSyncPort.js';
|
|
17
17
|
export { withFeedMasterRead } from './feedMasterPort.js';
|
|
18
18
|
export { createDocCipher, isEncryptedEnvelope } from './docCipher.js';
|
|
19
19
|
export { makeLwwConflictHandler } from './lwwConflictHandler.js';
|
package/dist/sync/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sync/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;GAMG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACrE,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sync/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;GAMG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACrE,OAAO,EACL,iBAAiB,EACjB,UAAU,EAEX,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAQjB,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/E,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACxD,OAAO,EACL,eAAe,EACf,mBAAmB,EAEpB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAA;AAChE,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA"}
|
|
@@ -19,10 +19,42 @@
|
|
|
19
19
|
* every replica compares that same master against its own local edit, and the
|
|
20
20
|
* `payloadWins` comparator is a total order over `(updatedAt, deviceId)`, so the
|
|
21
21
|
* globally-latest payload wins on every replica with no coordination.
|
|
22
|
+
*
|
|
23
|
+
* The resolution rules, in order:
|
|
24
|
+
*
|
|
25
|
+
* 1. Version-only conflict: when the real master's whole content (`data` +
|
|
26
|
+
* `custom` + `_deleted`) still equals the assumed master's, the server holds
|
|
27
|
+
* nothing newer than what this replica last synced -- the 412 came from a
|
|
28
|
+
* stale `If-Match` (typically our own earlier write racing its feed echo).
|
|
29
|
+
* The local state (edit or tombstone) is re-asserted and re-pushed against
|
|
30
|
+
* the corrected version. Without this rule a local delete would be dropped by
|
|
31
|
+
* rule 5 and the entity would silently resurrect. `custom` MUST be part of
|
|
32
|
+
* this comparison, or a concurrent metadata-only edit committed on the server
|
|
33
|
+
* would be misclassified here and clobbered (rule 2 is what settles it).
|
|
34
|
+
* 2. Metadata conflict: `data` and `_deleted` are unchanged from the assumed
|
|
35
|
+
* master but `custom` differs -- a metadata-only edit committed on the server
|
|
36
|
+
* since this replica last synced (another device won the `/meta` race).
|
|
37
|
+
* Metadata carries no LWW timestamp of its own (the payload `updatedAt` lives
|
|
38
|
+
* in the encrypted `data`, which is equal on both sides here), so there is no
|
|
39
|
+
* payload to compare; the sound, replica-independent default is that the
|
|
40
|
+
* server-committed state wins -- the real master is adopted for `custom`.
|
|
41
|
+
* Without this rule the equal-`data` case would fall through to rule 3, where
|
|
42
|
+
* the two payloads compare equal and the tie keeps the local (stale) `custom`,
|
|
43
|
+
* silently clobbering the committed metadata.
|
|
44
|
+
* 3. Both sides carry an LWW payload: pure payload LWW via `payloadWins`.
|
|
45
|
+
* 4. A live local edit vs an incomparable remote (e.g. a remote tombstone):
|
|
46
|
+
* the edit wins and is re-pushed (resurrection).
|
|
47
|
+
* 5. Everything else -- a local tombstone vs a REAL remote content change, or
|
|
48
|
+
* both sides incomparable: the master wins. Together with rule 4 this makes
|
|
49
|
+
* the delete-vs-concurrent-edit rule "the edit wins" on every replica: a
|
|
50
|
+
* tombstone carries no LWW payload of its own, so a genuine racing edit
|
|
51
|
+
* deterministically survives, whichever write reached the server first.
|
|
22
52
|
*/
|
|
23
53
|
import type { WithDeleted } from 'rxdb/plugins/core';
|
|
24
54
|
import type { Json, SyncedDoc } from './types.js';
|
|
25
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The LWW fields read out of a decrypted entity payload.
|
|
57
|
+
*/
|
|
26
58
|
interface LwwPayload {
|
|
27
59
|
updatedAt: string;
|
|
28
60
|
deviceId: string;
|
|
@@ -40,9 +72,10 @@ interface LwwPayload {
|
|
|
40
72
|
*/
|
|
41
73
|
export declare function makeLwwConflictHandler(decrypt: (envelope: Json) => Promise<Json>, payloadWins?: (remote: LwwPayload, local: LwwPayload) => boolean): {
|
|
42
74
|
isEqual(a: WithDeleted<SyncedDoc>, b: WithDeleted<SyncedDoc>): boolean;
|
|
43
|
-
resolve({ realMasterState, newDocumentState }: {
|
|
75
|
+
resolve({ realMasterState, newDocumentState, assumedMasterState }: {
|
|
44
76
|
realMasterState: WithDeleted<SyncedDoc>;
|
|
45
77
|
newDocumentState: WithDeleted<SyncedDoc>;
|
|
78
|
+
assumedMasterState?: WithDeleted<SyncedDoc>;
|
|
46
79
|
}): Promise<WithDeleted<SyncedDoc>>;
|
|
47
80
|
};
|
|
48
81
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lwwConflictHandler.d.ts","sourceRoot":"","sources":["../../src/sync/lwwConflictHandler.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH
|
|
1
|
+
{"version":3,"file":"lwwConflictHandler.d.ts","sourceRoot":"","sources":["../../src/sync/lwwConflictHandler.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAGjD;;GAEG;AACH,UAAU,UAAU;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAmCD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,CAAC,QAAQ,EAAE,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,EAC1C,WAAW,GAAE,CACX,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,UAAU,KACd,OAA2B;eAQnB,WAAW,CAAC,SAAS,CAAC,KAAK,WAAW,CAAC,SAAS,CAAC,GAAG,OAAO;uEAcnE;QACD,eAAe,EAAE,WAAW,CAAC,SAAS,CAAC,CAAA;QACvC,gBAAgB,EAAE,WAAW,CAAC,SAAS,CAAC,CAAA;QACxC,kBAAkB,CAAC,EAAE,WAAW,CAAC,SAAS,CAAC,CAAA;KAC5C,GAAG,OAAO,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC;EAoDtC"}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { remotePayloadWins } from './lww.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Structural equality of two opaque bodies (used for the fast `isEqual`).
|
|
4
|
+
*/
|
|
3
5
|
function bodiesEqual(a, b) {
|
|
4
6
|
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
5
7
|
}
|
|
@@ -37,27 +39,63 @@ async function lwwFieldsOf(doc, decrypt) {
|
|
|
37
39
|
*/
|
|
38
40
|
export function makeLwwConflictHandler(decrypt, payloadWins = remotePayloadWins) {
|
|
39
41
|
return {
|
|
40
|
-
// Non-async and fast, as RxDB requires
|
|
41
|
-
//
|
|
42
|
+
// Non-async and fast, as RxDB requires. The server revisions (`version` /
|
|
43
|
+
// `metaVersion`) participate deliberately: our own write's feed echo comes
|
|
44
|
+
// back byte-identical but one revision ahead, and it must NOT compare
|
|
45
|
+
// equal, or the higher version is never adopted and every later
|
|
46
|
+
// conditional write sends a stale `If-Match` (a guaranteed 412).
|
|
42
47
|
isEqual(a, b) {
|
|
43
|
-
return a._deleted === b._deleted &&
|
|
48
|
+
return (a._deleted === b._deleted &&
|
|
49
|
+
a.version === b.version &&
|
|
50
|
+
a.metaVersion === b.metaVersion &&
|
|
51
|
+
bodiesEqual(a.data, b.data) &&
|
|
52
|
+
bodiesEqual(a.custom, b.custom));
|
|
44
53
|
},
|
|
45
|
-
async resolve({ realMasterState, newDocumentState }) {
|
|
54
|
+
async resolve({ realMasterState, newDocumentState, assumedMasterState }) {
|
|
55
|
+
// Rule 1 -- version-only conflict: the master's whole content (`data` +
|
|
56
|
+
// `custom`) is exactly what this replica last synced (only the revision
|
|
57
|
+
// moved, e.g. our own write racing its feed echo), so nothing remote is
|
|
58
|
+
// actually newer. Re-assert the local state -- crucially including a local
|
|
59
|
+
// TOMBSTONE, which rule 5 would otherwise drop (the silent-resurrection
|
|
60
|
+
// bug). `custom` is part of this equality so a concurrent metadata-only
|
|
61
|
+
// edit is NOT misclassified here (rule 2 handles it).
|
|
62
|
+
if (assumedMasterState !== undefined &&
|
|
63
|
+
realMasterState._deleted === assumedMasterState._deleted &&
|
|
64
|
+
bodiesEqual(realMasterState.data, assumedMasterState.data) &&
|
|
65
|
+
bodiesEqual(realMasterState.custom, assumedMasterState.custom)) {
|
|
66
|
+
return newDocumentState;
|
|
67
|
+
}
|
|
68
|
+
// Rule 2 -- metadata conflict: `data` and `_deleted` are unchanged from
|
|
69
|
+
// the assumed master, but `custom` moved on the server (a metadata-only
|
|
70
|
+
// edit that won the `/meta` race on another device). Metadata has no LWW
|
|
71
|
+
// timestamp of its own -- the payload `updatedAt` lives in `data`, equal
|
|
72
|
+
// on both sides here -- so the sound, replica-independent default is that
|
|
73
|
+
// the server-committed state wins: adopt the real master for `custom`.
|
|
74
|
+
// Without this rule the equal-`data` case would reach rule 3 with two
|
|
75
|
+
// equal payloads and the tie would keep the local (stale) metadata.
|
|
76
|
+
if (assumedMasterState !== undefined &&
|
|
77
|
+
realMasterState._deleted === assumedMasterState._deleted &&
|
|
78
|
+
bodiesEqual(realMasterState.data, assumedMasterState.data) &&
|
|
79
|
+
!bodiesEqual(realMasterState.custom, assumedMasterState.custom)) {
|
|
80
|
+
return realMasterState;
|
|
81
|
+
}
|
|
46
82
|
const [remote, local] = await Promise.all([
|
|
47
83
|
lwwFieldsOf(realMasterState, decrypt),
|
|
48
84
|
lwwFieldsOf(newDocumentState, decrypt)
|
|
49
85
|
]);
|
|
50
|
-
//
|
|
86
|
+
// Rule 3 -- both sides comparable: pure payload LWW.
|
|
51
87
|
if (remote && local) {
|
|
52
88
|
return payloadWins(remote, local) ? realMasterState : newDocumentState;
|
|
53
89
|
}
|
|
54
|
-
//
|
|
90
|
+
// Rule 4 -- a live local edit against a remote tombstone (or otherwise
|
|
55
91
|
// incomparable remote): keep the edit (resurrect / re-assert the write).
|
|
56
92
|
if (local && !remote) {
|
|
57
93
|
return newDocumentState;
|
|
58
94
|
}
|
|
59
|
-
//
|
|
60
|
-
//
|
|
95
|
+
// Rule 5 -- everything else (a local tombstone racing a REAL remote
|
|
96
|
+
// content change, or both incomparable): the master wins, which is
|
|
97
|
+
// deterministic and convergent across replicas (the edit survives the
|
|
98
|
+
// delete on every device).
|
|
61
99
|
return realMasterState;
|
|
62
100
|
}
|
|
63
101
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lwwConflictHandler.js","sourceRoot":"","sources":["../../src/sync/lwwConflictHandler.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"lwwConflictHandler.js","sourceRoot":"","sources":["../../src/sync/lwwConflictHandler.ts"],"names":[],"mappings":"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"}
|
|
@@ -20,16 +20,29 @@
|
|
|
20
20
|
* `/meta` write to a resource that does not yet exist.
|
|
21
21
|
*
|
|
22
22
|
* RxDB's push contract asks only for *conflicts* back (the current master state
|
|
23
|
-
* of each rejected row)
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
23
|
+
* of each rejected row), so a successful write's new `version` / `metaVersion`
|
|
24
|
+
* is reported out-of-band: the response ETag of each accepted write is captured
|
|
25
|
+
* and handed to the optional `onWriteAccepted` callback, which writes the acked
|
|
26
|
+
* revision back into the local row (see `createWasReplication`). Without that
|
|
27
|
+
* write-back the local `version` would stay one revision behind the server and
|
|
28
|
+
* every subsequent conditional write would send a stale `If-Match` and 412. The
|
|
29
|
+
* write-back only touches the revision fields (never `data` / `updatedAt`), so
|
|
30
|
+
* the follow-up push cycle it triggers finds nothing changed to write and
|
|
31
|
+
* settles -- no re-push loop.
|
|
30
32
|
*/
|
|
31
33
|
import type { WithDeleted } from 'rxdb/plugins/core';
|
|
32
34
|
import type { SyncedDoc, WasSyncPort } from './types.js';
|
|
35
|
+
/**
|
|
36
|
+
* The acked server revisions of one row's accepted writes: the new content
|
|
37
|
+
* `version` (from a `PUT /:id` or `DELETE /:id` response ETag) and/or the new
|
|
38
|
+
* `metaVersion` (from a `PUT /:id/meta` response ETag). Absent fields mean the
|
|
39
|
+
* corresponding write did not run or its response carried no ETag.
|
|
40
|
+
*/
|
|
41
|
+
export interface PushWriteAck {
|
|
42
|
+
id: string;
|
|
43
|
+
version?: number;
|
|
44
|
+
metaVersion?: number;
|
|
45
|
+
}
|
|
33
46
|
/**
|
|
34
47
|
* Formats a master revision (`version` or `metaVersion`) as the quoted strong
|
|
35
48
|
* ETag the server compares `If-Match` against (revision `3` becomes `"3"`).
|
|
@@ -45,12 +58,18 @@ export declare function formatEtag(revision: number): string;
|
|
|
45
58
|
* Rows are pushed concurrently; if any non-conflict error is thrown the whole
|
|
46
59
|
* batch rejects (RxDB re-sends it later), matching RxDB's all-or-nothing retry.
|
|
47
60
|
*
|
|
61
|
+
* Each accepted write's acked server revision(s) are handed to
|
|
62
|
+
* `onWriteAccepted` (when supplied) as soon as that row's writes settle, so the
|
|
63
|
+
* caller can write the new `version` / `metaVersion` back into the local row
|
|
64
|
+
* and keep subsequent conditional writes' `If-Match` in step with the server.
|
|
65
|
+
*
|
|
48
66
|
* @param port {WasSyncPort}
|
|
67
|
+
* @param [onWriteAccepted] {(ack: PushWriteAck) => Promise<void>}
|
|
49
68
|
* @returns {(rows: Array<{ newDocumentState: WithDeleted<SyncedDoc>,
|
|
50
69
|
* assumedMasterState?: WithDeleted<SyncedDoc> }>) =>
|
|
51
70
|
* Promise<WithDeleted<SyncedDoc>[]>}
|
|
52
71
|
*/
|
|
53
|
-
export declare function createPushHandler(port: WasSyncPort): (rows: Array<{
|
|
72
|
+
export declare function createPushHandler(port: WasSyncPort, onWriteAccepted?: (ack: PushWriteAck) => Promise<void>): (rows: Array<{
|
|
54
73
|
newDocumentState: WithDeleted<SyncedDoc>;
|
|
55
74
|
assumedMasterState?: WithDeleted<SyncedDoc>;
|
|
56
75
|
}>) => Promise<WithDeleted<SyncedDoc>[]>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pushWrites.d.ts","sourceRoot":"","sources":["../../src/sync/pushWrites.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH
|
|
1
|
+
{"version":3,"file":"pushWrites.d.ts","sourceRoot":"","sources":["../../src/sync/pushWrites.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,KAAK,EAAqB,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAG3E;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEnD;AA4KD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,WAAW,EACjB,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,CAAC,IAGpD,MAAM,KAAK,CAAC;IACV,gBAAgB,EAAE,WAAW,CAAC,SAAS,CAAC,CAAA;IACxC,kBAAkB,CAAC,EAAE,WAAW,CAAC,SAAS,CAAC,CAAA;CAC5C,CAAC,KACD,OAAO,CAAC,WAAW,CAAC,SAAS,CAAC,EAAE,CAAC,CAoBrC"}
|
package/dist/sync/pushWrites.js
CHANGED
|
@@ -67,35 +67,41 @@ async function assembleConflict({ port, id, fallbackUpdatedAt, fallbackVersion }
|
|
|
67
67
|
/**
|
|
68
68
|
* Sends one local change to the remote Collection as up to two conditional
|
|
69
69
|
* writes (content, then metadata). Returns the master-state conflict entry on a
|
|
70
|
-
* `412` at either step
|
|
70
|
+
* `412` at either step (with `ack: null`), or the accepted writes' acked
|
|
71
|
+
* revisions on success (`ack: null` when no response carried a revision).
|
|
71
72
|
*
|
|
72
73
|
* @param options {object}
|
|
73
74
|
* @param options.port {WasSyncPort}
|
|
74
75
|
* @param options.newDocumentState {WithDeleted<SyncedDoc>}
|
|
75
76
|
* @param [options.assumedMasterState] {WithDeleted<SyncedDoc>}
|
|
76
|
-
* @returns {Promise<WithDeleted<SyncedDoc> | null
|
|
77
|
+
* @returns {Promise<{ conflict: WithDeleted<SyncedDoc> | null,
|
|
78
|
+
* ack: PushWriteAck | null }>}
|
|
77
79
|
*/
|
|
78
80
|
async function pushRow({ port, newDocumentState, assumedMasterState }) {
|
|
79
81
|
const { id } = newDocumentState;
|
|
80
82
|
const assumedVersion = assumedMasterState?.version;
|
|
81
83
|
const isCreate = assumedMasterState === undefined;
|
|
82
84
|
try {
|
|
85
|
+
const ack = { id };
|
|
83
86
|
if (newDocumentState._deleted) {
|
|
84
87
|
// Delete supersedes any metadata write: drop the content, tombstone wins.
|
|
85
|
-
await port.deleteContent({
|
|
88
|
+
const ackedVersion = await port.deleteContent({
|
|
86
89
|
id,
|
|
87
90
|
...(assumedVersion !== undefined && {
|
|
88
91
|
ifMatch: formatEtag(assumedVersion)
|
|
89
92
|
})
|
|
90
93
|
});
|
|
91
|
-
|
|
94
|
+
if (ackedVersion !== undefined) {
|
|
95
|
+
ack.version = ackedVersion;
|
|
96
|
+
}
|
|
97
|
+
return { conflict: null, ack: ack.version !== undefined ? ack : null };
|
|
92
98
|
}
|
|
93
99
|
// Content half: write on create, or when the content body changed. For a
|
|
94
100
|
// content-addressed collection the update case never fires (an immutable
|
|
95
101
|
// body for a stable id), but it is handled for generality.
|
|
96
102
|
const contentChanged = isCreate || !bodiesEqual(newDocumentState.data, assumedMasterState?.data);
|
|
97
103
|
if (contentChanged) {
|
|
98
|
-
await port.putContent({
|
|
104
|
+
const ackedVersion = await port.putContent({
|
|
99
105
|
id,
|
|
100
106
|
data: newDocumentState.data ?? null,
|
|
101
107
|
...(isCreate
|
|
@@ -104,30 +110,38 @@ async function pushRow({ port, newDocumentState, assumedMasterState }) {
|
|
|
104
110
|
ifMatch: formatEtag(assumedVersion)
|
|
105
111
|
})
|
|
106
112
|
});
|
|
113
|
+
if (ackedVersion !== undefined) {
|
|
114
|
+
ack.version = ackedVersion;
|
|
115
|
+
}
|
|
107
116
|
}
|
|
108
117
|
// Metadata half: write when the resource has metadata and it changed. On a
|
|
109
118
|
// create this runs after the content write (the resource must exist first).
|
|
110
119
|
const metadataChanged = !bodiesEqual(newDocumentState.custom, assumedMasterState?.custom);
|
|
111
120
|
if (newDocumentState.custom !== undefined && metadataChanged) {
|
|
112
121
|
const assumedMetaVersion = assumedMasterState?.metaVersion;
|
|
113
|
-
await port.putMeta({
|
|
122
|
+
const ackedMetaVersion = await port.putMeta({
|
|
114
123
|
id,
|
|
115
124
|
custom: newDocumentState.custom,
|
|
116
125
|
...(assumedMetaVersion !== undefined
|
|
117
126
|
? { ifMatch: formatEtag(assumedMetaVersion) }
|
|
118
127
|
: { ifNoneMatch: true })
|
|
119
128
|
});
|
|
129
|
+
if (ackedMetaVersion !== undefined) {
|
|
130
|
+
ack.metaVersion = ackedMetaVersion;
|
|
131
|
+
}
|
|
120
132
|
}
|
|
121
|
-
|
|
133
|
+
const hasAck = ack.version !== undefined || ack.metaVersion !== undefined;
|
|
134
|
+
return { conflict: null, ack: hasAck ? ack : null };
|
|
122
135
|
}
|
|
123
136
|
catch (err) {
|
|
124
137
|
if (err instanceof WasSyncConflictError) {
|
|
125
|
-
|
|
138
|
+
const conflict = await assembleConflict({
|
|
126
139
|
port,
|
|
127
140
|
id,
|
|
128
141
|
fallbackUpdatedAt: newDocumentState.updatedAt,
|
|
129
142
|
fallbackVersion: assumedVersion ?? newDocumentState.version
|
|
130
143
|
});
|
|
144
|
+
return { conflict, ack: null };
|
|
131
145
|
}
|
|
132
146
|
// Any non-conflict error (network, 5xx, auth) propagates so RxDB retries
|
|
133
147
|
// the whole batch with backoff.
|
|
@@ -141,19 +155,33 @@ async function pushRow({ port, newDocumentState, assumedMasterState }) {
|
|
|
141
155
|
* Rows are pushed concurrently; if any non-conflict error is thrown the whole
|
|
142
156
|
* batch rejects (RxDB re-sends it later), matching RxDB's all-or-nothing retry.
|
|
143
157
|
*
|
|
158
|
+
* Each accepted write's acked server revision(s) are handed to
|
|
159
|
+
* `onWriteAccepted` (when supplied) as soon as that row's writes settle, so the
|
|
160
|
+
* caller can write the new `version` / `metaVersion` back into the local row
|
|
161
|
+
* and keep subsequent conditional writes' `If-Match` in step with the server.
|
|
162
|
+
*
|
|
144
163
|
* @param port {WasSyncPort}
|
|
164
|
+
* @param [onWriteAccepted] {(ack: PushWriteAck) => Promise<void>}
|
|
145
165
|
* @returns {(rows: Array<{ newDocumentState: WithDeleted<SyncedDoc>,
|
|
146
166
|
* assumedMasterState?: WithDeleted<SyncedDoc> }>) =>
|
|
147
167
|
* Promise<WithDeleted<SyncedDoc>[]>}
|
|
148
168
|
*/
|
|
149
|
-
export function createPushHandler(port) {
|
|
169
|
+
export function createPushHandler(port, onWriteAccepted) {
|
|
150
170
|
return async function push(rows) {
|
|
151
|
-
const results = await Promise.all(rows.map(row =>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
171
|
+
const results = await Promise.all(rows.map(async (row) => {
|
|
172
|
+
const result = await pushRow({
|
|
173
|
+
port,
|
|
174
|
+
newDocumentState: row.newDocumentState,
|
|
175
|
+
assumedMasterState: row.assumedMasterState
|
|
176
|
+
});
|
|
177
|
+
if (result.ack !== null && onWriteAccepted !== undefined) {
|
|
178
|
+
await onWriteAccepted(result.ack);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}));
|
|
182
|
+
return results
|
|
183
|
+
.map(result => result.conflict)
|
|
184
|
+
.filter((conflict) => conflict !== null);
|
|
157
185
|
};
|
|
158
186
|
}
|
|
159
187
|
//# sourceMappingURL=pushWrites.js.map
|