Skip to content

Commit 098962d

Browse files
committed
Fix local itemsMap being inconsistent after changes on both ends
This can cause documents to either not be deleted at all when they should be, but at the least for the parent folder's `local` itemsMap to become inconsistent and potentially persist forever, because it can never be the same as the remote itemsMap again (which is the criterium for removing it).
1 parent fb6bba8 commit 098962d

File tree

2 files changed

+188
-7
lines changed

2 files changed

+188
-7
lines changed

src/sync.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -469,14 +469,28 @@ export class Sync {
469469
if (node.common.itemsMap) {
470470
for (const itemName in node.common.itemsMap) {
471471
if (!node.local.itemsMap[itemName]) {
472-
// Indicates the node is either newly being fetched
473-
// has been deleted locally (whether or not leading to conflict);
474-
// before listing it in local listings, check if a local deletion
475-
// exists.
472+
// Indicates the node is either newly being fetched, or
473+
// has been deleted locally (whether or not leading to
474+
// conflict); before listing it in local listings, check
475+
// if a local deletion exists.
476476
node.local.itemsMap[itemName] = false;
477477
}
478478
}
479479

480+
for (const itemName in node.local.itemsMap) {
481+
if (!node.common.itemsMap[itemName]) {
482+
// When an item appears in a folder's local itemsMap, but
483+
// not in remote/common, it may or may not have been
484+
// changed or deleted locally. The local itemsMap may
485+
// only contain it, beause the item existed when
486+
// *another* local item was changed, so we need to make
487+
// sure that it's checked/processed again, so it will be
488+
// deleted if there's no local change waiting to be
489+
// pushed out.
490+
this.addTask(node.path+itemName);
491+
}
492+
}
493+
480494
if (equal(node.local.itemsMap, node.common.itemsMap)) {
481495
delete node.local;
482496
}
@@ -745,10 +759,10 @@ export class Sync {
745759
}
746760

747761
const nodes = await this.rs.local.getNodes(paths);
762+
const parentNode: RSNode = nodes[parentPath];
763+
const missingChildren = {};
748764
let node: RSNode = nodes[path];
749-
let parentNode: RSNode = nodes[parentPath];
750765
let itemName: string;
751-
const missingChildren = {};
752766

753767
function collectMissingChildren (folder): void {
754768
if (folder && folder.itemsMap) {
@@ -787,7 +801,15 @@ export class Sync {
787801
if (parentNode && parentNode.local && parentNode.local.itemsMap) {
788802
itemName = path.substring(parentPath.length);
789803

790-
parentNode.local.itemsMap[itemName] = true;
804+
if (bodyOrItemsMap !== false) {
805+
parentNode.local.itemsMap[itemName] = true;
806+
} else {
807+
if (parentNode.local.itemsMap[itemName]) {
808+
// node is 404 on remote, can safely be removed from
809+
// parent's local itemsMap now
810+
delete parentNode.local.itemsMap[itemName];
811+
}
812+
}
791813

792814
if (equal(parentNode.local.itemsMap, parentNode.common.itemsMap)) {
793815
delete parentNode.local;

test/unit/sync.test.mjs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,85 @@ describe("Sync", function() {
689689
});
690690
});
691691

692+
describe("Documents deleted both locally and on remote", function() {
693+
beforeEach(async function() {
694+
// three and four deleted on remote, three also deleted locally
695+
696+
const newRemoteMap = {
697+
"one": { "ETag": "one" },
698+
"two": { "ETag": "two" }
699+
};
700+
701+
this.rs.local.setNodes({
702+
"/foo/": {
703+
path: "/foo/",
704+
common: {
705+
itemsMap: { "one": true, "two": true, "three": true, "four": true },
706+
revision: "remotefolderrevision",
707+
timestamp: 1397210425598,
708+
},
709+
local: {
710+
itemsMap: { "one": true, "two": true, "four": true },
711+
revision: "localfolderrevision",
712+
timestamp: 1397210425612
713+
}
714+
},
715+
"/foo/one": {
716+
path: "/foo/one",
717+
common: {
718+
body: { foo: "one" },
719+
contentType: "application/json",
720+
revision: "one",
721+
timestamp: 1234567891000
722+
}
723+
},
724+
"/foo/two": {
725+
path: "/foo/two",
726+
local: {
727+
body: { foo: "two" },
728+
contentType: "application/json",
729+
revision: "two",
730+
timestamp: 1234567891000
731+
}
732+
},
733+
"/foo/four": {
734+
path: "/foo/four",
735+
common: {
736+
body: { foo: "four" },
737+
contentType: "application/json",
738+
revision: "four",
739+
timestamp: 1234567891000
740+
}
741+
}
742+
});
743+
744+
await this.rs.sync.handleResponse('/foo/', 'get', {
745+
statusCode: 200, body: newRemoteMap,
746+
contentType: 'application/ld+json',
747+
revision: 'newfolderrevision'
748+
});
749+
750+
this.nodes = await this.rs.local.getNodes([
751+
"/foo/", "/foo/one", "/foo/two", "/foo/three", "/foo/four"
752+
]);
753+
});
754+
755+
it("removes the document from the parent node's common itemsMap", async function() {
756+
expect(this.nodes["/foo/"].common.itemsMap).to.deep.equal({
757+
"one": true, "two": true
758+
});
759+
});
760+
761+
it("removes the parent node's local and remote itemsMap", function() {
762+
expect(this.nodes["/foo/"].local).to.be.undefined;
763+
expect(this.nodes["/foo/"].remote).to.be.undefined;
764+
});
765+
766+
it("removes the deleted node from the cache", function() {
767+
expect(this.nodes["/foo/four"]).to.be.undefined;
768+
});
769+
});
770+
692771
describe("PUT without conflict", function() {
693772
beforeEach(async function() {
694773
this.rs.local.setNodes({
@@ -872,6 +951,86 @@ describe("Sync", function() {
872951
});
873952
});
874953

954+
describe("#autoMergeFolder", function() {
955+
describe("documents updated on both sides", function() {
956+
beforeEach(function() {
957+
this.addTask = sinon.spy(this.rs.sync, "addTask");
958+
959+
this.nodeBefore = {
960+
"path": "/foo/",
961+
"remote": {
962+
"revision": "incomingrevision",
963+
"timestamp": 1750232323004,
964+
"itemsMap": {
965+
"1750232100702": true, // added remotely
966+
"1750232294620": true, // no change
967+
}
968+
},
969+
"common": {
970+
"revision": "oldrevision",
971+
"timestamp": 1750232313004,
972+
"itemsMap": {
973+
"1750232294620": true,
974+
}
975+
},
976+
"local": {
977+
"revision": "localrevision",
978+
"timestamp": 1750232095577,
979+
"itemsMap": {
980+
"1750232294620": true,
981+
"1750232283970": true, // uncertain state
982+
"1750233526039": true, // added locally
983+
}
984+
}
985+
};
986+
987+
this.nodeAfter = this.rs.sync.autoMergeFolder(this.nodeBefore);
988+
});
989+
990+
it("adds new documents to the local itemsMap as uncached", function() {
991+
expect(this.nodeAfter.local.itemsMap).to.deep.equal({
992+
"1750232100702": false, // marked for fetch
993+
"1750232294620": true,
994+
"1750232283970": true,
995+
"1750233526039": true
996+
});
997+
});
998+
999+
it("adds sync tasks for local items missing in incoming itemsMap", function() {
1000+
expect(this.addTask.callCount).to.equal(2);
1001+
expect(this.addTask.getCall(0).args[0]).to.equal("/foo/1750232283970");
1002+
expect(this.addTask.getCall(1).args[0]).to.equal("/foo/1750233526039");
1003+
});
1004+
});
1005+
1006+
describe("on an empty node", function() {
1007+
it("removes a remote version if it has a null revision", async function() {
1008+
const node = {
1009+
path: "foo", common: {}, remote: { revision: null }
1010+
};
1011+
1012+
expect(this.rs.sync.autoMergeDocument(node)).to
1013+
.deep.equal({ path: "foo", common: {} });
1014+
});
1015+
});
1016+
1017+
it("merges mutual deletions", function() {
1018+
const node = {
1019+
"path": "/myfavoritedrinks/b",
1020+
"common": { "timestamp": 1405488508303 },
1021+
"local": { "body": false, "timestamp": 1405488515881 },
1022+
"remote": { "body": false, "timestamp": 1405488740722 }
1023+
};
1024+
const localAndRemoteRemoved = {
1025+
"path": "/myfavoritedrinks/b",
1026+
"common": { "timestamp": 1405488508303 }
1027+
};
1028+
1029+
expect(this.rs.sync.autoMergeDocument(node)).to
1030+
.deep.equal(localAndRemoteRemoved);
1031+
});
1032+
});
1033+
8751034
describe("#autoMerge", function() {
8761035
describe("new node", function() {
8771036
beforeEach(function() {

0 commit comments

Comments
 (0)