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:
Johannes Millan 2026-03-05 19:19:51 +01:00 committed by GitHub
parent 0ea0ba143f
commit e92eb79db7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 862 additions and 127 deletions

558
package-lock.json generated
View file

@ -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"
}

View file

@ -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,
});
});
});

View file

@ -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(

View file

@ -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 () => {

View file

@ -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

View file

@ -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 &&

View file

@ -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)) {

View file

@ -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
});
});
});