mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
Reset vector clocks to minimal after SYNC_IMPORT to drop dead clients (#6738)
* fix(sync): reset vector clock to minimal after SYNC_IMPORT After a SYNC_IMPORT, working clocks were carrying forward ALL accumulated client IDs (including dead ones from reinstalls/old browsers), keeping the clock permanently at MAX_VECTOR_CLOCK_SIZE. This caused unnecessary pruning and comparison issues on every sync. Three complementary changes: 1. Reset working clock to minimal after SYNC_IMPORT: When a client creates or receives a SYNC_IMPORT, its working clock is reset to only the import client's entry + own entry. Dead client IDs are dropped. The full clock is preserved in the stored SYNC_IMPORT operation for filtering. 2. Import-client-counter exception in SyncImportFilterService: Post-import ops with minimal clocks appear CONCURRENT with the import (missing old entries). A new exception recognizes them as post-import by checking if the op has the import client's counter >= the import's own counter value. 3. Updated SimulatedClient test helper to reset clocks on SYNC_IMPORT, matching the real behavior in OperationLogStoreService and SyncHydrationService. https://claude.ai/code/session_01T5K1kq8m6LPDc6dEGyXnEW * chore: update package-lock.json https://claude.ai/code/session_01T5K1kq8m6LPDc6dEGyXnEW * refactor(sync): use FULL_STATE_OP_TYPES constant and improve test assertions - Replace magic string casts `(opType as string) === 'REPAIR'` with `FULL_STATE_OP_TYPES.has(opType)` in simulated-client helper - Document transitive propagation assumption in import-client-counter exception comment - Improve test assertion to use toContain for better failure messages --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0ea0ba143f
commit
e92eb79db7
8 changed files with 862 additions and 127 deletions
558
package-lock.json
generated
558
package-lock.json
generated
|
|
@ -424,6 +424,57 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/architect/node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/architect/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/architect/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular": {
|
||||
"version": "21.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-21.2.0.tgz",
|
||||
|
|
@ -622,7 +673,7 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||
"yarn": ">= 1.13.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
|
@ -1215,6 +1266,119 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"chai": "^6.2.1",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/pretty-format": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/runner": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/snapshot": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/spy": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/utils": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
|
|
@ -1228,6 +1392,18 @@
|
|||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
|
|
@ -1241,6 +1417,24 @@
|
|||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/cli-spinners": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz",
|
||||
|
|
@ -1377,6 +1571,22 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -1423,6 +1633,18 @@
|
|||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
|
@ -1440,6 +1662,127 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/vitest": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"@vitest/runner": "4.0.18",
|
||||
"@vitest/snapshot": "4.0.18",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"expect-type": "^1.2.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^3.10.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/browser-preview": "4.0.18",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/vitest/node_modules/@vitest/mocker": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.0.18",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-webpack": {
|
||||
"version": "0.2102.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2102.0.tgz",
|
||||
|
|
@ -1533,6 +1876,24 @@
|
|||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/cli-spinners": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz",
|
||||
|
|
@ -1612,6 +1973,22 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/string-width": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
|
||||
|
|
@ -1914,6 +2291,24 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli/node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"dev": true,
|
||||
|
|
@ -1940,6 +2335,22 @@
|
|||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -5328,6 +5739,17 @@
|
|||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar/node_modules/commander": {
|
||||
"version": "5.1.0",
|
||||
"dev": true,
|
||||
|
|
@ -5356,19 +5778,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@electron/asar/node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/fuses": {
|
||||
|
|
@ -5688,6 +6107,7 @@
|
|||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5705,6 +6125,7 @@
|
|||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5722,6 +6143,7 @@
|
|||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5739,6 +6161,7 @@
|
|||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5756,6 +6179,7 @@
|
|||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5773,6 +6197,7 @@
|
|||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5790,6 +6215,7 @@
|
|||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5807,6 +6233,7 @@
|
|||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5824,6 +6251,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5841,6 +6269,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5858,6 +6287,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5875,6 +6305,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5892,6 +6323,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5909,6 +6341,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5926,6 +6359,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5943,6 +6377,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5958,6 +6393,7 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5975,6 +6411,7 @@
|
|||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -5992,6 +6429,7 @@
|
|||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -6009,6 +6447,7 @@
|
|||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -6026,6 +6465,7 @@
|
|||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -6043,6 +6483,7 @@
|
|||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -6060,6 +6501,7 @@
|
|||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -6077,6 +6519,7 @@
|
|||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -6094,6 +6537,7 @@
|
|||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -6111,6 +6555,7 @@
|
|||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -10494,6 +10939,57 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@schematics/angular/node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@schematics/angular/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@schematics/angular/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sigstore/bundle": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz",
|
||||
|
|
@ -17858,20 +18354,27 @@
|
|||
"minimatch": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
|
|
@ -22949,6 +23452,19 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"license": "MIT",
|
||||
|
|
@ -24066,6 +24582,7 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -28593,6 +29110,7 @@
|
|||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2309,13 +2309,12 @@ describe('OperationLogStoreService', () => {
|
|||
await service.mergeRemoteOpClocks([syncImportOp]);
|
||||
|
||||
const clock = await service.getVectorClock();
|
||||
// Old entries (clientB, clientC, localClient) should be gone.
|
||||
// clientA gets the import's value (80), not the old higher value (100),
|
||||
// because the import's clock replaces the old clock entirely.
|
||||
// After SYNC_IMPORT, the working clock is reset to minimal:
|
||||
// only the import client's entry + the receiving client's entry.
|
||||
// The full import clock is preserved in the stored operation for filtering.
|
||||
// Old entries (clientA, clientB, clientC, clientD, localClient) are dropped.
|
||||
expect(clock).toEqual({
|
||||
clientX: 1,
|
||||
clientA: 80,
|
||||
clientD: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1361,10 +1361,6 @@ export class OperationLogStoreService {
|
|||
}
|
||||
}
|
||||
|
||||
// Prune the merged clock to MAX_VECTOR_CLOCK_SIZE to break the
|
||||
// inflate/prune cycle: without this, the union of all downloaded ops'
|
||||
// clocks re-introduces pruned client IDs, exceeding the limit again.
|
||||
// The server already prunes with the same algorithm on upload.
|
||||
const currentClientId = await this.clientIdProvider.loadClientId();
|
||||
if (!currentClientId) {
|
||||
Log.warn(
|
||||
|
|
@ -1372,9 +1368,43 @@ export class OperationLogStoreService {
|
|||
'This is unexpected during sync and may indicate data corruption.',
|
||||
);
|
||||
}
|
||||
const clockToStore = currentClientId
|
||||
? limitVectorClockSize(mergedClock, currentClientId)
|
||||
: mergedClock;
|
||||
|
||||
let clockToStore: Record<string, number>;
|
||||
|
||||
if (fullStateOp && currentClientId) {
|
||||
// CLOCK RESET: After a full-state op (SYNC_IMPORT / BACKUP_IMPORT / REPAIR),
|
||||
// reset the working clock to minimal — only the import client's entry and our
|
||||
// own entry. This prevents dead client IDs from accumulating in the clock.
|
||||
//
|
||||
// The full import clock is preserved in the stored operation for
|
||||
// SyncImportFilterService to use when filtering pre-import ops.
|
||||
// Post-import ops are recognized by having the import client's counter
|
||||
// (see SyncImportFilterService's import-client-counter exception).
|
||||
clockToStore = {};
|
||||
const importClientId = fullStateOp.clientId;
|
||||
if (mergedClock[importClientId] !== undefined) {
|
||||
clockToStore[importClientId] = mergedClock[importClientId];
|
||||
}
|
||||
if (
|
||||
currentClientId !== importClientId &&
|
||||
mergedClock[currentClientId] !== undefined
|
||||
) {
|
||||
clockToStore[currentClientId] = mergedClock[currentClientId];
|
||||
}
|
||||
Log.log(
|
||||
`[OpLogStore] mergeRemoteOpClocks: RESET clock to minimal after ${fullStateOp.opType}\n` +
|
||||
` Full merged clock (${Object.keys(mergedClock).length} entries): ${vectorClockToString(mergedClock)}\n` +
|
||||
` Minimal clock (${Object.keys(clockToStore).length} entries): ${vectorClockToString(clockToStore)}`,
|
||||
);
|
||||
} else {
|
||||
// Normal case: prune the merged clock to MAX_VECTOR_CLOCK_SIZE to break the
|
||||
// inflate/prune cycle: without this, the union of all downloaded ops'
|
||||
// clocks re-introduces pruned client IDs, exceeding the limit again.
|
||||
// The server already prunes with the same algorithm on upload.
|
||||
clockToStore = currentClientId
|
||||
? limitVectorClockSize(mergedClock, currentClientId)
|
||||
: mergedClock;
|
||||
}
|
||||
|
||||
// DIAGNOSTIC LOGGING: Log merged clock after merge
|
||||
Log.debug(
|
||||
|
|
|
|||
|
|
@ -283,18 +283,22 @@ describe('SyncHydrationService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should update vector clock store after sync', async () => {
|
||||
it('should update vector clock store after sync with minimal clock', async () => {
|
||||
mockVectorClockService.getCurrentVectorClock.and.resolveTo({ localClient: 5 });
|
||||
mockOpLogStore.loadStateCache.and.resolveTo({ vectorClock: { remote: 3 } } as any);
|
||||
|
||||
await service.hydrateFromRemoteSync({});
|
||||
|
||||
// After SYNC_IMPORT, the working clock is reset to minimal (only own entry).
|
||||
// The full merged clock is stored in the SYNC_IMPORT operation for filtering.
|
||||
expect(mockOpLogStore.setVectorClock).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
localClient: 6,
|
||||
remote: 3,
|
||||
}),
|
||||
);
|
||||
// Remote entries should NOT be in the minimal working clock
|
||||
const setClockArg = mockOpLogStore.setVectorClock.calls.mostRecent().args[0];
|
||||
expect(setClockArg['remote']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should dispatch loadAllData with synced data', async () => {
|
||||
|
|
|
|||
|
|
@ -244,21 +244,38 @@ export class SyncHydrationService {
|
|||
};
|
||||
}
|
||||
|
||||
// 8. Save new state cache (snapshot) for crash safety
|
||||
// 8. Determine the working clock to use.
|
||||
// When a SYNC_IMPORT was created, reset to minimal (only importing client's entry)
|
||||
// to prevent dead client IDs from accumulating. The SYNC_IMPORT operation stores
|
||||
// the full merged clock for SyncImportFilterService to use when filtering.
|
||||
// When no SYNC_IMPORT (file-based bootstrap), keep the full merged clock.
|
||||
let clockForStorage: Record<string, number>;
|
||||
if (createSyncImportOp) {
|
||||
clockForStorage = {};
|
||||
clockForStorage[clientId] = newClock[clientId];
|
||||
OpLog.normal('SyncHydrationService: Reset working clock to minimal after sync', {
|
||||
fullClockSize: Object.keys(newClock).length,
|
||||
minimalClockSize: Object.keys(clockForStorage).length,
|
||||
});
|
||||
} else {
|
||||
clockForStorage = newClock;
|
||||
}
|
||||
|
||||
// 9. Save new state cache (snapshot) for crash safety
|
||||
await this.opLogStore.saveStateCache({
|
||||
state: dataToLoad,
|
||||
lastAppliedOpSeq: lastSeq,
|
||||
vectorClock: newClock,
|
||||
vectorClock: clockForStorage,
|
||||
compactedAt: Date.now(),
|
||||
});
|
||||
OpLog.normal('SyncHydrationService: Saved state cache after sync');
|
||||
|
||||
// 9. Update vector clock store to match the new clock
|
||||
// 10. Update vector clock store
|
||||
// This is critical because:
|
||||
// - The SYNC_IMPORT was appended with source='remote', so store wasn't updated
|
||||
// - If user creates new ops in this session, incrementAndStoreVectorClock reads from store
|
||||
// - Without this, new ops would have clocks missing entries from the SYNC_IMPORT
|
||||
await this.opLogStore.setVectorClock(newClock);
|
||||
await this.opLogStore.setVectorClock(clockForStorage);
|
||||
OpLog.normal('SyncHydrationService: Updated vector clock store after sync');
|
||||
|
||||
// 10. Dispatch loadAllData to update NgRx
|
||||
|
|
|
|||
|
|
@ -209,6 +209,35 @@ export class SyncImportFilterService {
|
|||
` Client ${op.clientId} counter: op=${op.vectorClock[op.clientId]} > import=${importClockForComparison[op.clientId]} (post-import op).`,
|
||||
);
|
||||
validOps.push(op);
|
||||
} else if (
|
||||
comparison === VectorClockComparison.CONCURRENT &&
|
||||
op.clientId !== latestImport.clientId &&
|
||||
(op.vectorClock[latestImport.clientId] ?? 0) >=
|
||||
(importClockForComparison[latestImport.clientId] ?? 0) &&
|
||||
(importClockForComparison[latestImport.clientId] ?? 0) > 0
|
||||
) {
|
||||
// Op was created by a DIFFERENT client with knowledge of the import.
|
||||
// The SYNC_IMPORT incremented the importing client's counter, so any op whose
|
||||
// clock includes that counter value (or higher) must have received the import
|
||||
// (directly or transitively). CONCURRENT here is a clock-reset artifact: after
|
||||
// receiving a SYNC_IMPORT, clients reset their working clock to minimal (only
|
||||
// the import client's entry + own entry), so post-import ops lack entries for
|
||||
// old/dead client IDs that are still in the import's stored clock.
|
||||
//
|
||||
// ASSUMPTION: This relies on the SYNC_IMPORT op persisting in the op log for
|
||||
// filtering. Transitive propagation (client C learns import counter from client B
|
||||
// without directly receiving the import) is safe because the filter only runs
|
||||
// against ops that coexist with the SYNC_IMPORT in the log.
|
||||
//
|
||||
// NOTE: Ops from the import client itself are handled by the same-client check
|
||||
// above (which requires strictly greater counter, not equal).
|
||||
OpLog.normal(
|
||||
`SyncImportFilterService: KEEPING op ${op.id} (${op.actionType}) despite CONCURRENT ` +
|
||||
`- op has import client ${latestImport.clientId} counter ` +
|
||||
`${op.vectorClock[latestImport.clientId]} >= import counter ` +
|
||||
`${importClockForComparison[latestImport.clientId]} (post-import via clock reset).`,
|
||||
);
|
||||
validOps.push(op);
|
||||
} else if (
|
||||
comparison === VectorClockComparison.CONCURRENT &&
|
||||
importClockForComparison[op.clientId] === undefined &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
ActionType,
|
||||
FULL_STATE_OP_TYPES,
|
||||
Operation,
|
||||
OperationLogEntry,
|
||||
OpType,
|
||||
|
|
@ -75,6 +76,16 @@ export class SimulatedClient {
|
|||
});
|
||||
|
||||
await this.store.append(op, 'local');
|
||||
|
||||
// After creating a SYNC_IMPORT locally, reset the working clock to minimal.
|
||||
// This matches the real behavior in SyncHydrationService.hydrateFromRemoteSync()
|
||||
// where the working clock is reset to only the importing client's entry.
|
||||
if (FULL_STATE_OP_TYPES.has(opType)) {
|
||||
const minimalClock: Record<string, number> = {};
|
||||
minimalClock[this.clientId] = op.vectorClock[this.clientId];
|
||||
this.testClient.setVectorClock(minimalClock);
|
||||
}
|
||||
|
||||
return op;
|
||||
}
|
||||
|
||||
|
|
@ -257,12 +268,34 @@ export class SimulatedClient {
|
|||
* clock with only its own entry.
|
||||
*/
|
||||
private async _applyRemoteOps(serverOps: ServerSyncOperation[]): Promise<void> {
|
||||
// Check if any op is a full-state operation (SYNC_IMPORT / BACKUP_IMPORT / REPAIR).
|
||||
// If so, reset the clock to minimal (import client + own client) instead of merging,
|
||||
// matching the real behavior in OperationLogStoreService.mergeRemoteOpClocks().
|
||||
const fullStateOp = serverOps.find((sop) =>
|
||||
FULL_STATE_OP_TYPES.has(sop.op.opType as OpType),
|
||||
);
|
||||
|
||||
if (fullStateOp) {
|
||||
// Reset to minimal: only import client's entry + own entry
|
||||
const minimalClock: Record<string, number> = {};
|
||||
const importClientId = fullStateOp.op.clientId;
|
||||
if (fullStateOp.op.vectorClock[importClientId] !== undefined) {
|
||||
minimalClock[importClientId] = fullStateOp.op.vectorClock[importClientId];
|
||||
}
|
||||
if (this.clientId !== importClientId) {
|
||||
// Preserve our own counter from the import's clock (or 0 if not present)
|
||||
const ownCounterInImport = fullStateOp.op.vectorClock[this.clientId] ?? 0;
|
||||
const ownCounterCurrent = this.testClient.getCurrentClock()[this.clientId] ?? 0;
|
||||
minimalClock[this.clientId] = Math.max(ownCounterInImport, ownCounterCurrent);
|
||||
}
|
||||
this.testClient.setVectorClock(minimalClock);
|
||||
}
|
||||
|
||||
for (const serverOp of serverOps) {
|
||||
// Always merge the remote clock into our local knowledge.
|
||||
// This must happen regardless of whether the op is already in the
|
||||
// shared store, because this client's TestClient needs to learn
|
||||
// about the remote causal history.
|
||||
this.testClient.mergeRemoteClock(serverOp.op.vectorClock);
|
||||
if (!fullStateOp) {
|
||||
// Normal case: merge the remote clock into our local knowledge.
|
||||
this.testClient.mergeRemoteClock(serverOp.op.vectorClock);
|
||||
}
|
||||
|
||||
// Skip store write if already have this op (shared DB between clients)
|
||||
if (await this.store.hasOp(serverOp.op.id)) {
|
||||
|
|
|
|||
|
|
@ -41,11 +41,10 @@ const isLikelyPruningArtifact = (
|
|||
* 3. All clients re-sync and then create new tasks
|
||||
* 4. New tasks from all clients should be accepted (not filtered as pre-import)
|
||||
*
|
||||
* This validates that:
|
||||
* - The import's vector clock carries forward the importing client's causal knowledge
|
||||
* - Post-import operations from all clients are recognized as post-import (GREATER_THAN)
|
||||
* - The SyncImportFilterService correctly keeps post-import ops even when clocks grow back to MAX
|
||||
* - Pruning artifacts are handled correctly for new clients joining after import
|
||||
* KEY BEHAVIOR: After a SYNC_IMPORT, working clocks are reset to minimal
|
||||
* (only import client + own client entries). Post-import ops are recognized
|
||||
* by the import-client-counter exception in SyncImportFilterService, not by
|
||||
* carrying forward the full accumulated clock.
|
||||
*/
|
||||
describe('Vector Clock Import Reset Integration', () => {
|
||||
let storeService: OperationLogStoreService;
|
||||
|
|
@ -77,18 +76,20 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
);
|
||||
};
|
||||
|
||||
describe('Vector clock grows back to MAX after import', () => {
|
||||
describe('Vector clock resets to minimal after import', () => {
|
||||
/**
|
||||
* Test: After a SYNC_IMPORT, the vector clock grows back to MAX as clients resume syncing.
|
||||
* Test: After a SYNC_IMPORT, client working clocks are reset to minimal.
|
||||
*
|
||||
* Demonstrates WHY the vector clock grows back:
|
||||
* 1. The import operation inherits the importing client's accumulated causal knowledge
|
||||
* 2. Each client that syncs after the import merges the import's clock and adds its own entry
|
||||
* 3. After MAX clients have synced, the clock is back at MAX_VECTOR_CLOCK_SIZE
|
||||
* Previously, clocks would carry forward ALL accumulated client IDs from the
|
||||
* import (including dead/stale ones). Now, working clocks are reset to only
|
||||
* contain the import client's entry + the receiving client's entry.
|
||||
*
|
||||
* Note: If all original clients remain active (creating ops and syncing),
|
||||
* clocks will naturally regrow to include all active clients. The key improvement
|
||||
* is that DEAD client IDs (from reinstalls, etc.) are dropped.
|
||||
*/
|
||||
it('should demonstrate vector clock growing back to MAX after import', async () => {
|
||||
// Use MAX_VECTOR_CLOCK_SIZE clients to fill the clock
|
||||
const numClients = MAX_VECTOR_CLOCK_SIZE;
|
||||
it('should reset working clocks to minimal immediately after receiving import', async () => {
|
||||
const numClients = 5; // Use a small number for clarity
|
||||
const clients = createClients(numClients, storeService);
|
||||
|
||||
// Phase 1: Each client creates a task and syncs, building up the clock
|
||||
|
|
@ -108,7 +109,7 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
await clients[i].sync(server);
|
||||
}
|
||||
|
||||
// Verify clocks have grown: client-000 should know about multiple clients
|
||||
// Verify clocks have grown: client-000 should know about all clients
|
||||
const preImportClock = clients[0].getCurrentClock();
|
||||
const preImportClockSize = Object.keys(preImportClock).length;
|
||||
expect(preImportClockSize).toBeGreaterThanOrEqual(numClients);
|
||||
|
|
@ -134,65 +135,150 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
);
|
||||
await clients[0].sync(server);
|
||||
|
||||
// The import's clock should carry the importing client's knowledge of all clients
|
||||
// The import's op clock should carry the importing client's knowledge of all clients
|
||||
const importClockSize = Object.keys(importOp.vectorClock).length;
|
||||
expect(importClockSize).toBeGreaterThanOrEqual(numClients);
|
||||
|
||||
// The importing client's working clock should be reset to minimal (just own entry)
|
||||
const importerClock = clients[0].getCurrentClock();
|
||||
expect(Object.keys(importerClock).length)
|
||||
.withContext('Importing client clock should be minimal (1 entry: own)')
|
||||
.toBe(1);
|
||||
|
||||
// Phase 3: All other clients sync to receive the import
|
||||
for (let i = 1; i < numClients; i++) {
|
||||
await clients[i].sync(server);
|
||||
}
|
||||
|
||||
// Phase 4: Each client creates a new task post-import and syncs
|
||||
for (let i = 0; i < numClients; i++) {
|
||||
await clients[i].createLocalOp(
|
||||
// After receiving the import, working clocks should be MINIMAL (reset)
|
||||
for (let i = 1; i < numClients; i++) {
|
||||
const postImportClock = clients[i].getCurrentClock();
|
||||
const clockSize = Object.keys(postImportClock).length;
|
||||
expect(clockSize)
|
||||
.withContext(
|
||||
`Client ${i} clock should be minimal (2 entries: import client + own), ` +
|
||||
`got ${clockSize}: ${JSON.stringify(postImportClock)}`,
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Dead client IDs are dropped after SYNC_IMPORT.
|
||||
*
|
||||
* Scenario: Several clients sync, then some go offline permanently (dead).
|
||||
* After SYNC_IMPORT, the dead clients' IDs should NOT appear in working clocks.
|
||||
*/
|
||||
it('should drop dead client IDs after import', async () => {
|
||||
// Create 5 clients: 2 "alive" + 3 "dead"
|
||||
const aliveClients = [
|
||||
new SimulatedClient('client-alive-a', storeService),
|
||||
new SimulatedClient('client-alive-b', storeService),
|
||||
];
|
||||
const deadClients = [
|
||||
new SimulatedClient('client-dead-1', storeService),
|
||||
new SimulatedClient('client-dead-2', storeService),
|
||||
new SimulatedClient('client-dead-3', storeService),
|
||||
];
|
||||
const allClients = [...aliveClients, ...deadClients];
|
||||
|
||||
// All clients create tasks and sync
|
||||
for (const client of allClients) {
|
||||
await client.createLocalOp(
|
||||
'TASK',
|
||||
`task-post-${i}`,
|
||||
`task-${client.clientId}`,
|
||||
OpType.Create,
|
||||
'[Task] Add Task',
|
||||
createMinimalTaskPayload(`task-post-${i}`, {
|
||||
title: `Post-import Task ${i}`,
|
||||
}),
|
||||
createMinimalTaskPayload(`task-${client.clientId}`),
|
||||
);
|
||||
await clients[i].sync(server);
|
||||
await client.sync(server);
|
||||
}
|
||||
// Convergence
|
||||
for (const client of allClients) {
|
||||
await client.sync(server);
|
||||
}
|
||||
|
||||
// Verify: post-import ops should have clocks >= the import's clock
|
||||
const serverOps = server.getAllOps();
|
||||
const postImportOps = serverOps.filter((sop) =>
|
||||
sop.op.entityId?.startsWith('task-post-'),
|
||||
// Verify all clocks have entries for all clients
|
||||
const preImportClock = aliveClients[0].getCurrentClock();
|
||||
expect(Object.keys(preImportClock).length).toBe(5);
|
||||
|
||||
// Client alive-a performs import
|
||||
await aliveClients[0].createLocalOp(
|
||||
'ALL',
|
||||
uuidv7(),
|
||||
OpType.SyncImport,
|
||||
'[SP_ALL] Load(import) all data',
|
||||
{
|
||||
appDataComplete: {
|
||||
task: {
|
||||
ids: ['import-task'],
|
||||
entities: { 'import-task': createMinimalTaskPayload('import-task') },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(postImportOps.length).toBe(numClients);
|
||||
await aliveClients[0].sync(server);
|
||||
|
||||
// Every post-import op should be GREATER_THAN or EQUAL to the import's clock
|
||||
for (const postOp of postImportOps) {
|
||||
const comparison = compareVectorClocks(
|
||||
postOp.op.vectorClock,
|
||||
importOp.vectorClock,
|
||||
);
|
||||
expect(comparison)
|
||||
.withContext(
|
||||
`Post-import op from ${postOp.op.clientId} should be GREATER_THAN import, got ${comparison}`,
|
||||
)
|
||||
.toBe(VectorClockComparison.GREATER_THAN);
|
||||
}
|
||||
// Only alive clients resync (dead clients are gone)
|
||||
await aliveClients[1].sync(server);
|
||||
|
||||
// The last client's clock should be back near MAX size
|
||||
const lastClientClock = clients[numClients - 1].getCurrentClock();
|
||||
const lastClockSize = Object.keys(lastClientClock).length;
|
||||
expect(lastClockSize).toBeGreaterThanOrEqual(numClients);
|
||||
// Alive clients create new ops and sync
|
||||
await aliveClients[0].createLocalOp(
|
||||
'TASK',
|
||||
'post-a',
|
||||
OpType.Create,
|
||||
'[Task] Add Task',
|
||||
createMinimalTaskPayload('post-a'),
|
||||
);
|
||||
await aliveClients[0].sync(server);
|
||||
|
||||
await aliveClients[1].createLocalOp(
|
||||
'TASK',
|
||||
'post-b',
|
||||
OpType.Create,
|
||||
'[Task] Add Task',
|
||||
createMinimalTaskPayload('post-b'),
|
||||
);
|
||||
await aliveClients[1].sync(server);
|
||||
|
||||
// Converge
|
||||
await aliveClients[0].sync(server);
|
||||
await aliveClients[1].sync(server);
|
||||
|
||||
// Working clocks should only contain alive client IDs (dead ones are dropped)
|
||||
const finalClockA = aliveClients[0].getCurrentClock();
|
||||
const finalClockB = aliveClients[1].getCurrentClock();
|
||||
|
||||
expect(Object.keys(finalClockA).length)
|
||||
.withContext(
|
||||
`Alive client A should have 2 entries (alive clients only), ` +
|
||||
`got: ${JSON.stringify(finalClockA)}`,
|
||||
)
|
||||
.toBe(2);
|
||||
expect(finalClockA['client-dead-1']).toBeUndefined();
|
||||
expect(finalClockA['client-dead-2']).toBeUndefined();
|
||||
expect(finalClockA['client-dead-3']).toBeUndefined();
|
||||
|
||||
expect(Object.keys(finalClockB).length)
|
||||
.withContext(
|
||||
`Alive client B should have 2 entries (alive clients only), ` +
|
||||
`got: ${JSON.stringify(finalClockB)}`,
|
||||
)
|
||||
.toBe(2);
|
||||
expect(finalClockB['client-dead-1']).toBeUndefined();
|
||||
expect(finalClockB['client-dead-2']).toBeUndefined();
|
||||
expect(finalClockB['client-dead-3']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncImportFilterService keeps post-import ops after clock regrowth', () => {
|
||||
describe('SyncImportFilterService keeps post-import ops with minimal clocks', () => {
|
||||
/**
|
||||
* Core test: Multiple clients at MAX vector clock → import on one client → resync →
|
||||
* new tasks from all clients should pass the SyncImportFilterService filter.
|
||||
*
|
||||
* This is the primary scenario the user asked about:
|
||||
* - Start with MAX_VECTOR_CLOCK_SIZE clients all synced
|
||||
* - One client imports a file
|
||||
* - After resync, new tasks added on ALL clients must survive filtering
|
||||
* After the clock reset fix, post-import ops have minimal clocks and appear
|
||||
* CONCURRENT with the import (missing entries for old clients). The
|
||||
* import-client-counter exception recognizes them as post-import.
|
||||
*/
|
||||
it('should keep new tasks from all clients after import and resync', async () => {
|
||||
const numClients = MAX_VECTOR_CLOCK_SIZE;
|
||||
|
|
@ -264,10 +350,7 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
}
|
||||
|
||||
// Phase 5: Verify using SyncImportFilterService
|
||||
// Set up the service with the import stored in the op log
|
||||
const filterService = TestBed.inject(SyncImportFilterService);
|
||||
|
||||
// The filter should keep all post-import ops
|
||||
const result = await filterService.filterOpsInvalidatedBySyncImport(postImportOps);
|
||||
|
||||
expect(result.invalidatedOps.length)
|
||||
|
|
@ -358,12 +441,20 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
);
|
||||
expect(comparisonC).toBe(VectorClockComparison.CONCURRENT);
|
||||
|
||||
// Verify: post-import op from B should be GREATER_THAN the import
|
||||
// Verify: post-import op from B should be CONCURRENT with the import
|
||||
// (B's clock was reset to minimal after receiving import, so it's missing
|
||||
// entries that the import has — but it has the import client's counter)
|
||||
const comparisonB = compareVectorClocks(
|
||||
postImportOpB.vectorClock,
|
||||
importOp.vectorClock,
|
||||
);
|
||||
expect(comparisonB).toBe(VectorClockComparison.GREATER_THAN);
|
||||
// With minimal clocks, B's op is CONCURRENT (missing old entries)
|
||||
// but should still be kept by the import-client-counter exception
|
||||
expect([VectorClockComparison.GREATER_THAN, VectorClockComparison.CONCURRENT])
|
||||
.withContext(
|
||||
`Post-import op from B should be GREATER_THAN or CONCURRENT, got ${comparisonB}`,
|
||||
)
|
||||
.toContain(comparisonB);
|
||||
|
||||
// Use SyncImportFilterService
|
||||
const filterService = TestBed.inject(SyncImportFilterService);
|
||||
|
|
@ -373,7 +464,7 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
]);
|
||||
|
||||
// Pre-import op from C should be KEPT (unknown client - import has no entry for C)
|
||||
// Post-import op from B should also be kept (GREATER_THAN)
|
||||
// Post-import op from B should also be kept (import-client-counter exception)
|
||||
expect(result.invalidatedOps.length).toBe(0);
|
||||
expect(result.validOps.length).toBe(2);
|
||||
expect(result.validOps.find((op) => op.entityId === 'taskC-pre')).toBeDefined();
|
||||
|
|
@ -497,22 +588,15 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
/**
|
||||
* End-to-end scenario: MAX clients → import → resync → new tasks from all clients.
|
||||
*
|
||||
* This is the complete integration test covering the full lifecycle:
|
||||
* 1. Create MAX_VECTOR_CLOCK_SIZE clients
|
||||
* 2. Each client creates multiple tasks and syncs (clocks grow to MAX)
|
||||
* 3. Client 0 imports a file (resets state)
|
||||
* 4. All clients resync to receive the import
|
||||
* 5. All clients create new tasks
|
||||
* 6. All clients sync new tasks
|
||||
* 7. Verify: all new tasks are on the server and their vector clocks
|
||||
* correctly show GREATER_THAN relative to the import
|
||||
* After the clock reset fix, post-import ops have minimal clocks but are
|
||||
* correctly recognized as post-import by SyncImportFilterService via the
|
||||
* import-client-counter exception.
|
||||
*/
|
||||
it('should handle full lifecycle: MAX clock → import → resync → new tasks sync correctly', async () => {
|
||||
const numClients = MAX_VECTOR_CLOCK_SIZE;
|
||||
const clients = createClients(numClients, storeService);
|
||||
|
||||
// === Phase 1: Build up clocks to MAX ===
|
||||
// Multiple rounds of create-and-sync to ensure clocks accumulate all client IDs
|
||||
for (let round = 0; round < 3; round++) {
|
||||
for (let i = 0; i < numClients; i++) {
|
||||
await clients[i].createLocalOp(
|
||||
|
|
@ -534,7 +618,7 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
|
||||
// Snapshot: count server ops before import
|
||||
const preImportServerOpCount = server.getAllOps().length;
|
||||
expect(preImportServerOpCount).toBe(numClients * 3); // 3 rounds × numClients
|
||||
expect(preImportServerOpCount).toBe(numClients * 3);
|
||||
|
||||
// === Phase 2: Client 0 imports ===
|
||||
const importOp = await clients[0].createLocalOp(
|
||||
|
|
@ -596,32 +680,39 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
.toBeDefined();
|
||||
}
|
||||
|
||||
// === Phase 7: Verify vector clock relationships ===
|
||||
// === Phase 7: Verify post-import ops have the import client's counter ===
|
||||
// Post-import ops should have the import client's counter value, which
|
||||
// allows SyncImportFilterService to recognize them as post-import.
|
||||
const newTaskOpsOnServer = allServerOps.filter((sop) =>
|
||||
sop.op.entityId?.startsWith('new-task-from-client-'),
|
||||
);
|
||||
expect(newTaskOpsOnServer.length).toBe(numClients);
|
||||
|
||||
const importClientId = importOp.clientId;
|
||||
const importClientCounter = importOp.vectorClock[importClientId];
|
||||
for (const serverOp of newTaskOpsOnServer) {
|
||||
const comparison = compareVectorClocks(
|
||||
serverOp.op.vectorClock,
|
||||
importOp.vectorClock,
|
||||
);
|
||||
expect(comparison)
|
||||
const opImportCounter = serverOp.op.vectorClock[importClientId] ?? 0;
|
||||
expect(opImportCounter)
|
||||
.withContext(
|
||||
`Op for ${serverOp.op.entityId} from ${serverOp.op.clientId} ` +
|
||||
`should be GREATER_THAN import. ` +
|
||||
`Op clock keys: ${Object.keys(serverOp.op.vectorClock).length}, ` +
|
||||
`Import clock keys: ${importClockEntries}`,
|
||||
`Op for ${serverOp.op.entityId} from ${serverOp.op.clientId} should have ` +
|
||||
`import client counter >= ${importClientCounter}, got ${opImportCounter}`,
|
||||
)
|
||||
.toBe(VectorClockComparison.GREATER_THAN);
|
||||
.toBeGreaterThanOrEqual(importClientCounter);
|
||||
}
|
||||
|
||||
// === Phase 8: Verify clocks have grown back toward MAX ===
|
||||
// The last client to sync should have a clock with entries for all clients
|
||||
// === Phase 8: Verify clocks are bounded ===
|
||||
// With all MAX clients still active, clocks will naturally regrow to include
|
||||
// all active client IDs. The key improvement is that DEAD client IDs
|
||||
// (from reinstalls, etc.) are dropped. With all clients active, the clock
|
||||
// size equals the number of active clients (bounded by MAX_VECTOR_CLOCK_SIZE).
|
||||
const finalClock = clients[numClients - 1].getCurrentClock();
|
||||
const finalClockSize = Object.keys(finalClock).length;
|
||||
expect(finalClockSize).toBeGreaterThanOrEqual(numClients);
|
||||
expect(finalClockSize)
|
||||
.withContext(
|
||||
`Final clock should be bounded at MAX (${MAX_VECTOR_CLOCK_SIZE}), ` +
|
||||
`got ${finalClockSize}`,
|
||||
)
|
||||
.toBeLessThanOrEqual(MAX_VECTOR_CLOCK_SIZE);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -747,16 +838,30 @@ describe('Vector Clock Import Reset Integration', () => {
|
|||
);
|
||||
await clientB.sync(server);
|
||||
|
||||
// Verify round 2 tasks are GREATER_THAN the import
|
||||
// Verify round 2 tasks are on the server and have the import client's counter
|
||||
const round2Ops = server
|
||||
.getAllOps()
|
||||
.filter((o) => o.op.entityId?.startsWith('round2-'));
|
||||
expect(round2Ops.length).toBe(2);
|
||||
|
||||
const importClientId = importOp.clientId;
|
||||
const importClientCounter = importOp.vectorClock[importClientId];
|
||||
for (const op of round2Ops) {
|
||||
const cmp = compareVectorClocks(op.op.vectorClock, importOp.vectorClock);
|
||||
expect(cmp).toBe(VectorClockComparison.GREATER_THAN);
|
||||
const opImportCounter = op.op.vectorClock[importClientId] ?? 0;
|
||||
expect(opImportCounter)
|
||||
.withContext(
|
||||
`Round 2 op from ${op.op.clientId} should have import client counter >= ${importClientCounter}`,
|
||||
)
|
||||
.toBeGreaterThanOrEqual(importClientCounter);
|
||||
}
|
||||
|
||||
// Verify clocks only contain active client IDs (3 clients)
|
||||
const finalClockA = clientA.getCurrentClock();
|
||||
expect(Object.keys(finalClockA).length)
|
||||
.withContext(
|
||||
'Client A clock should only have active clients after import + convergence',
|
||||
)
|
||||
.toBeLessThanOrEqual(3); // A + B + C
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue