cms-api: Replace lodash.uniqWith with custom content scope deduplication - #6112
cms-api: Replace lodash.uniqWith with custom content scope deduplication#6112fraxachun wants to merge 4 commits into
Conversation
Deduplicating content scopes via lodash.uniqWith relies on a deep-equal comparison against every already-seen element, which is O(n^2) and becomes very slow for large content scope lists (e.g. ~1.3 s for 4000 scopes). Content scopes are flat objects of primitive values, so they can be keyed by their sorted entries and deduplicated in a single O(n) pass using a Map. This is 40-490x faster in benchmarks while producing identical results, and lets us drop the lodash.uniqwith dependency.
| // Dedupes by scope content without lodash's deep-equal uniqWith, which is O(n^2) and too slow for large scope lists. | ||
| // Scopes are flat objects of primitive values, so a sorted-entries string key is a safe stand-in for deep equality. | ||
| function dedupeByContentScope<T>(items: T[], getScope: (item: T) => ContentScope): T[] { | ||
| const seen = new Map<string, T>(); | ||
| for (const item of items) { | ||
| const key = JSON.stringify(Object.entries(getScope(item)).sort(([a], [b]) => a.localeCompare(b))); | ||
| if (!seen.has(key)) { | ||
| seen.set(key, item); | ||
| } | ||
| } | ||
| return [...seen.values()]; | ||
| } |
There was a problem hiding this comment.
Should we add unit tests for this?
Rename the content scope deduplication helper to deduplicateByContentScope for clarity and move it into its own pure-function file so it can be unit tested in isolation. Add unit tests that pin the behavior it must preserve from the replaced lodash.uniqWith(isEqual): remove scopes that are deep-equal to an earlier one, keep the first occurrence, and preserve input order. Each test also asserts parity against a local uniqWith(isEqual) mirror.
There was a problem hiding this comment.
Pull request overview
This PR improves performance in @comet/cms-api by replacing lodash.uniqWith-based deep-equality deduplication of content scopes (O(n²)) with a custom Map-based deduplication helper keyed by a stable scope representation (O(n)).
Changes:
- Replaced three
lodash.uniqWith(..., isEqual)call sites inUserPermissionsServicewith a newdeduplicateByContentScope(...)helper. - Added a dedicated util (
deduplicate-by-content-scope.ts) plus unit tests to pin behavior and parity with the prior implementation. - Removed
lodash.uniqwithand@types/lodash.uniqwithdependencies and updated the lockfile; included a patch changeset for@comet/cms-api.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
pnpm-lock.yaml |
Removes lodash.uniqwith and its types from the workspace lockfile. |
packages/api/cms-api/src/user-permissions/user-permissions.service.ts |
Switches deduplication of content scopes/content-scope-with-label lists to the new Map-based helper. |
packages/api/cms-api/src/user-permissions/deduplicate-by-content-scope.ts |
Introduces the O(n) deduplication utility used by UserPermissionsService. |
packages/api/cms-api/src/user-permissions/deduplicate-by-content-scope.spec.ts |
Adds Vitest coverage to preserve previous uniqWith(isEqual) behavior. |
packages/api/cms-api/package.json |
Drops lodash.uniqwith and @types/lodash.uniqwith from dependencies/devDependencies. |
.changeset/replace-lodash-uniqwith-content-scope-dedupe.md |
Adds a patch changeset describing the perf improvement and dependency removal. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
| // Replaces lodash's deep-equal uniqWith, which compares every candidate against every kept element (O(n^2)) and is too | ||
| // slow for large scope lists. Content scopes are flat objects of primitive values, so their sorted entries form a stable | ||
| // string key that stands in for deep equality, allowing deduplication in a single O(n) pass. | ||
| // Like uniqWith, the first occurrence of each scope is kept and the input order is preserved. |
There was a problem hiding this comment.
Adding comments that reference a previous implementation is odd and useless.
| // Replaces lodash's deep-equal uniqWith, which compares every candidate against every kept element (O(n^2)) and is too | |
| // slow for large scope lists. Content scopes are flat objects of primitive values, so their sorted entries form a stable | |
| // string key that stands in for deep equality, allowing deduplication in a single O(n) pass. | |
| // Like uniqWith, the first occurrence of each scope is kept and the input order is preserved. |
There was a problem hiding this comment.
Good point. Reworded in 70ac8db — dropped the references to the previous implementation and kept only the note on why the sorted-entries string key is a valid stand-in for equality.
Generated by Claude Code
|
Taking one step back: what is the use case where this de-duplication is needed? |
Drop the references to the previous lodash.uniqWith implementation, which are meaningless without that context, and keep only the non-obvious reason the string key is a valid stand-in for equality.
For example if a user has content scopes by rule and additionally added content scopes manually. I don't think we can remove this mechanism. |
does it hurt if there are duplicate scopes? If it is for presentation to the user, can we deduplicate on the client side? |
Problem
UserPermissionsServicededuplicates content scopes in several places usinglodash.uniqWithwith a deep-equal comparator.uniqWithcompares every candidate against every element already kept, so it runs in O(n²) and does a full deep-equal per comparison. With large content-scope lists this becomes a serious bottleneck (the original observation was ~15 s for 2000 scopes when resolving users).Solution
Content scopes are flat objects of primitive values, so deep equality isn't actually required — a stable string key built from the scope's sorted entries uniquely identifies a scope. This lets us deduplicate in a single O(n) pass with a
Map, extracted into a pure, unit-tested util (deduplicate-by-content-scope.ts):All three
uniqWithcall sites inuser-permissions.service.tsnow use this helper. Thelodash.uniqwithdependency (and its@typespackage) is removed.lodash.isequalstays — it's still used for scope membership checks elsewhere.Like
uniqWith, the helper keeps the first occurrence of each scope and preserves input order.Tests
deduplicate-by-content-scope.spec.tspins the behavior that must be preserved fromlodash.uniqWith(scopes, isEqual). Each case asserts both the exact expected result and parity against a localuniqWith(isEqual)mirror, covering:{domain, language}=={language, domain})1vs"1"vstrue)label)Speed comparison
Benchmark deduplicating a list where half the entries are duplicates, best of 3 runs. Both algorithms return the identical set of deduplicated scopes.
lodash.uniqWithdeduplicateByContentScopeThe
uniqWithtimings roughly quadruple as the input doubles (O(n²)), while theMap-based approach scales linearly — so the gap widens with list size.Changeset
Included (
@comet/cms-apipatch).