From e92eb79db7fe09d2ac978ae1dc06b472d9f2a314 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 5 Mar 2026 19:19:51 +0100 Subject: [PATCH] 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 --- package-lock.json | 558 +++++++++++++++++- .../operation-log-store.service.spec.ts | 9 +- .../operation-log-store.service.ts | 44 +- .../sync-hydration.service.spec.ts | 8 +- .../persistence/sync-hydration.service.ts | 25 +- .../op-log/sync/sync-import-filter.service.ts | 29 + .../helpers/simulated-client.helper.ts | 43 +- ...tor-clock-import-reset.integration.spec.ts | 273 ++++++--- 8 files changed, 862 insertions(+), 127 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a4a2bc0ad..18ed8c9424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/src/app/op-log/persistence/operation-log-store.service.spec.ts b/src/app/op-log/persistence/operation-log-store.service.spec.ts index 43a5cdf87f..37db87fec0 100644 --- a/src/app/op-log/persistence/operation-log-store.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-store.service.spec.ts @@ -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, }); }); }); diff --git a/src/app/op-log/persistence/operation-log-store.service.ts b/src/app/op-log/persistence/operation-log-store.service.ts index 5d9426ee46..e14d1a5151 100644 --- a/src/app/op-log/persistence/operation-log-store.service.ts +++ b/src/app/op-log/persistence/operation-log-store.service.ts @@ -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; + + 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( diff --git a/src/app/op-log/persistence/sync-hydration.service.spec.ts b/src/app/op-log/persistence/sync-hydration.service.spec.ts index 1e73c4a3f0..b0d15138fe 100644 --- a/src/app/op-log/persistence/sync-hydration.service.spec.ts +++ b/src/app/op-log/persistence/sync-hydration.service.spec.ts @@ -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 () => { diff --git a/src/app/op-log/persistence/sync-hydration.service.ts b/src/app/op-log/persistence/sync-hydration.service.ts index b112a2ba30..2d80a597ba 100644 --- a/src/app/op-log/persistence/sync-hydration.service.ts +++ b/src/app/op-log/persistence/sync-hydration.service.ts @@ -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; + 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 diff --git a/src/app/op-log/sync/sync-import-filter.service.ts b/src/app/op-log/sync/sync-import-filter.service.ts index a6d8f2f328..5fe8ce4965 100644 --- a/src/app/op-log/sync/sync-import-filter.service.ts +++ b/src/app/op-log/sync/sync-import-filter.service.ts @@ -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 && diff --git a/src/app/op-log/testing/integration/helpers/simulated-client.helper.ts b/src/app/op-log/testing/integration/helpers/simulated-client.helper.ts index ae18c63763..0e4bf0149d 100644 --- a/src/app/op-log/testing/integration/helpers/simulated-client.helper.ts +++ b/src/app/op-log/testing/integration/helpers/simulated-client.helper.ts @@ -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 = {}; + 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 { + // 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 = {}; + 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)) { diff --git a/src/app/op-log/testing/integration/vector-clock-import-reset.integration.spec.ts b/src/app/op-log/testing/integration/vector-clock-import-reset.integration.spec.ts index bd944c9168..00faebd54a 100644 --- a/src/app/op-log/testing/integration/vector-clock-import-reset.integration.spec.ts +++ b/src/app/op-log/testing/integration/vector-clock-import-reset.integration.spec.ts @@ -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 }); }); });