Conversation
Readonly option will auto apply Object.freeze on cache results, and will have an logic to avoid updating cache when nonBlocking is true and the cache hasn't change comparing to previous one. In this version, no deep comparison is made, so it'll only work with object with flat native values properties
There was a problem hiding this comment.
Pull request overview
Adds a new resultType: 'readonly' option to Remembered to freeze cached results (via Object.freeze) and introduces a non-blocking optimization to avoid updating the cache when the new result appears unchanged.
Changes:
- Introduces
resultTypeconfiguration and types it throughRememberedgenerics to affect return types. - Freezes cached results when
resultTypeisreadonly, with special handling innonBlockingmode to reuse the cached reference when “unchanged”. - Updates CI workflows and adds a comprehensive unit test suite covering readonly behavior.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/remembered.ts |
Implements readonly freezing + nonBlocking “unchanged” reuse logic; updates method typings to reflect readonly results. |
src/remembered-config.ts |
Adds ResultType and resultType option to the configuration type. |
test/unit/remembered-readonly.spec.ts |
Adds tests for resultType: 'readonly' across get/getSync/wrap/nonBlocking/TTL/error scenarios. |
test/unit/index.spec.ts |
Adjusts an existing unit test assertion. |
.github/workflows/build.yml |
Updates GitHub Actions dependencies but leaves an invalid matrix reference. |
.github/workflows/test.yml |
Updates GitHub Actions dependencies and simplifies the test step. |
.github/workflows/lint.yml |
Updates GitHub Actions dependencies. |
.github/workflows/semantic.yml |
Refactors semantic-release workflow; changes coverage upload and install approach. |
bitbucket-pipelines.yml |
Removes the Bitbucket pipeline configuration. |
Comments suppressed due to low confidence (1)
src/remembered.ts:108
wrap’s return type is declared asTResultType extends 'readonly' ? Readonly<R> : R, butRis aPromise. Freezing happens on the resolved value, not on the Promise object, soReadonly<R>is misleading and doesn’t reflect the actual return type. Consider typingwrapin terms of the awaited result (e.g.,Promise<TResultType extends 'readonly' ? Readonly<Awaited<R>> : Awaited<R>>) so callers get correct types.
wrap<T extends any[], K extends T, R extends Promise<any>>(
callback: (...args: T) => R,
getKey: (...args: K) => TKey,
noCacheIf?: (result: R extends Promise<infer TR> ? TR : never) => boolean,
): (...args: T) => TResultType extends 'readonly' ? Readonly<R> : R {
return (...args: T): R => {
const key = getKey(...(args as K));
return this.get(key, () => callback(...args), noCacheIf) as R;
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (isObj(cached) && isObj(result)) { | ||
| changed = false; | ||
| for (const prop in result) { | ||
| if (result[prop] !== cached[prop]) { | ||
| changed = true; | ||
| break; | ||
| } | ||
| } | ||
| if (!changed) return (result = cached); | ||
| } |
There was a problem hiding this comment.
The nonBlocking/readonly change-detection compares only enumerable properties from the new result (for...in result) and doesn’t verify that the cached value doesn’t have extra keys (or, for arrays, different length). This can incorrectly treat a changed value as unchanged (e.g., cached has an extra property, or cached array is longer), causing the cache to be reused when it shouldn’t. Consider comparing Object.keys on both objects (and lengths) and only comparing own-properties, or restricting this optimization to plain objects with stable prototypes.
| export interface RememberedConfig< | ||
| TResponse = unknown, | ||
| TKey = string, | ||
| TResultType extends ResultType = 'mutable' | 'readonly', |
There was a problem hiding this comment.
The default for TResultType is 'mutable' | 'readonly', which makes RememberedConfig default to a union and causes conditional types to distribute (often resulting in R | Readonly<R> for callers that don’t specify the generic). Defaulting this to 'mutable' (and keeping resultType?) usually yields better inference and avoids widening return types unexpectedly.
| TResultType extends ResultType = 'mutable' | 'readonly', | |
| TResultType extends ResultType = 'mutable', |
| - uses: actions/checkout@v5 | ||
| - name: Use Node.js ${{ matrix.node-version }} | ||
| uses: actions/setup-node@v3 | ||
| with: | ||
| node-version: ${{ matrix.node-version }} | ||
| uses: actions/setup-node@v6 |
There was a problem hiding this comment.
This workflow still references matrix.node-version in the step name, but the matrix strategy was removed. Referencing matrix.* without a matrix will fail workflow parsing/execution. Either restore the matrix strategy or remove the ${{ matrix.node-version }} reference (and optionally set an explicit node-version).
| - run: previousVersion=$(sed 's/.*"version": "\(.*\)".*/\1/;t;d' ./package.json) | ||
| - run: npm i -g @semantic-release/changelog @semantic-release/commit-analyzer @semantic-release/git @semantic-release/github @semantic-release/exec @semantic-release/npm @semantic-release/release-notes-generator semantic-release | ||
| - run: | |
There was a problem hiding this comment.
The sed pattern is looking for "version": (HTML entity) instead of the actual JSON "version":. As written, previousVersion/finalVersion will likely be empty, which breaks the conditional git push logic. Update the pattern to match the literal colon (or use node -p "require('./package.json').version" / jq -r .version).
| semantic: | ||
| runs-on: ubuntu-latest | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} |
There was a problem hiding this comment.
This workflow still uses @semantic-release/npm (see .releaserc.json), but NPM_TOKEN is no longer provided anywhere. Unless you’ve switched to an alternative npm auth mechanism, semantic-release will fail when trying to publish. Either restore NPM_TOKEN (or the required env vars for npm auth) or remove/disable the npm plugin if publishing isn’t intended.
| GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} | |
| GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} |
| - run: npm ci | ||
| - run: npm run build --if-present | ||
| - name: Redis Server in GitHub Actions | ||
| uses: supercharge/redis-github-action@1.1.0 | ||
| - run: npm test | ||
| - uses: actions/setup-node@v3 | ||
| - name: Upload coverage to Qlty | ||
| uses: qltysh/qlty-action/coverage@v2 | ||
| continue-on-error: true | ||
| with: | ||
| node-version: "lts/*" | ||
| token: ${{ secrets.CC_TEST_REPORTER_ID }} | ||
| files: coverage/lcov.info |
There was a problem hiding this comment.
The release workflow no longer runs the test suite (previously npm test with Redis). Relying only on PR checks means direct pushes (or workflow_dispatch) can publish without verification. Consider running at least npm test/npm run test:coverage here (and starting Redis if needed) before invoking semantic-release.
| for (const prop in result) { | ||
| if (result[prop] !== cached[prop]) { | ||
| changed = true; |
There was a problem hiding this comment.
result[prop] / cached[prop] relies on indexing into values narrowed to {}; under strict this will not type-check (no index signature). If you intend this to work for plain objects, narrow to Record<string, unknown> (and ensure both sides are that type) before iterating/comparing.
| if (changed) result = Object.freeze(result); | ||
| } | ||
| this.nonBlockingMap.set(key, result); | ||
| } else if (isObj(result) && this.config.resultType === 'readonly') { | ||
| result = Object.freeze(result); |
There was a problem hiding this comment.
Assigning Object.freeze(result) back into a variable typed as R is not type-safe in a generic context: Object.freeze returns Readonly<R>, which is not assignable to R when R is an unconstrained type parameter (and it’s often not assignable even for object types). This is likely a TypeScript compile error under strict. Consider widening the local variable type (e.g., R | Readonly<R>) and/or casting at the assignment boundary, and make the method return type align with the runtime behavior.
| if (changed) result = Object.freeze(result); | |
| } | |
| this.nonBlockingMap.set(key, result); | |
| } else if (isObj(result) && this.config.resultType === 'readonly') { | |
| result = Object.freeze(result); | |
| if (changed) result = Object.freeze(result as R) as R; | |
| } | |
| this.nonBlockingMap.set(key, result); | |
| } else if (isObj(result) && this.config.resultType === 'readonly') { | |
| result = Object.freeze(result as R) as R; |
Readonly option will auto apply Object.freeze
on cache results, and will have an logic to
avoid updating cache when nonBlocking is true
and the cache hasn't change comparing to
previous one. In this version, no deep comparison
is made, so it'll only work with object with
flat native values properties