From cfe5070afb58bfe330017f8ab6b19bc67efe00d2 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 29 Jun 2026 14:55:42 +0200 Subject: [PATCH] fix(cached-adapter-store): read persisted hash with string default to avoid numeric coercion fs-storage `get(key, default)` returns the raw stored string only for a string default; a non-string default (e.g. `null`) drives the `JSON.parse` branch. `put` stores strings raw, so an all-numeric, no-leading-zero hash (the `crc32b(uuid())` shape kendo's backend emits, e.g. `'55776784'`) round-tripped back as a Number. `localHash` then never strict-equaled the string server hash, so skip-when-equal never matched and a spurious `retrieveAll()` fired on every affected cold page-load. Read the persisted hash with a string default (raw value returned verbatim) and normalize the empty-string sentinel back to `null` so the `localHash !== null` cold-start guard is preserved. Behavior is unchanged for the null and non-numeric-string cases; only the all-numeric case is corrected. Regression test exercises the REAL fs-storage round-trip (the existing stub ignores the default and is blind to this bug): an all-numeric persisted hash equal to the server hash now skips the inner fetch. Verified failing against the old `null`-default line and passing against the fix. Bumps to 0.2.3 + adds the package CHANGELOG. Closes enforcement queue #130; mirrors wijs PR #122. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K1C8cLed2opuG5H6MFVssS --- packages/cached-adapter-store/CHANGELOG.md | 7 +++ packages/cached-adapter-store/package.json | 2 +- .../src/cached-adapter-store.ts | 14 +++++- .../tests/cached-adapter-store.spec.ts | 45 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/cached-adapter-store/CHANGELOG.md diff --git a/packages/cached-adapter-store/CHANGELOG.md b/packages/cached-adapter-store/CHANGELOG.md new file mode 100644 index 0000000..d26bfde --- /dev/null +++ b/packages/cached-adapter-store/CHANGELOG.md @@ -0,0 +1,7 @@ +# @script-development/fs-cached-adapter-store + +## 0.2.3 — 2026-06-29 + +### Patch Changes + +- **Fix: all-numeric persisted cache hash coerced to Number under non-string storage default → spurious cold-load refetch.** The persisted-hash read used a non-string default (`storageService.get(hashStorageKey, null)`), which sends fs-storage down its `JSON.parse` branch — fs-storage only returns the raw stored string verbatim for a _string_ default. An all-numeric, no-leading-zero hash (exactly the `crc32b(uuid())` shape kendo's backend emits, e.g. `'55776784'`) round-tripped back as a Number, so `localHash` never strict-equaled the string server hash, the skip-when-equal guard never matched, and a redundant `retrieveAll()` fired on every affected cold page-load. The read now uses a string default (raw value returned verbatim) and normalizes the empty-string sentinel back to `null` so the `localHash !== null` cold-start guard is preserved. Consumers pick this up on the version bump; kendo is the live-exposed consumer. Closes enforcement queue #130; mirrors wijs PR #122. diff --git a/packages/cached-adapter-store/package.json b/packages/cached-adapter-store/package.json index 1d02d84..d6780ba 100644 --- a/packages/cached-adapter-store/package.json +++ b/packages/cached-adapter-store/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-cached-adapter-store", - "version": "0.2.2", + "version": "0.2.3", "description": "Higher-order factory wrapping @script-development/fs-adapter-store with hash-bumping cache-check that suppresses redundant retrieveAll GETs at source", "homepage": "https://packages.script.nl/packages/cached-adapter-store", "license": "MIT", diff --git a/packages/cached-adapter-store/src/cached-adapter-store.ts b/packages/cached-adapter-store/src/cached-adapter-store.ts index a54c9d6..e7165bb 100644 --- a/packages/cached-adapter-store/src/cached-adapter-store.ts +++ b/packages/cached-adapter-store/src/cached-adapter-store.ts @@ -211,7 +211,19 @@ export const createCachedAdapterStoreModule = < const inner = createAdapterStoreModule(config); - const initialPersistedHash = storageService.get(hashStorageKey, null); + // Read the persisted hash with a STRING default so fs-storage returns the + // raw stored value verbatim. fs-storage's `get` only returns the raw string + // for a *string* default; any non-string default (e.g. `null`) sends it + // down the `JSON.parse` branch, which coerces an all-numeric, no-leading- + // zero hash — exactly the `crc32b(uuid())` shape kendo's backend emits, + // e.g. `'55776784'` — into a Number. A numeric `localHash` then never + // strict-equals the string server hash, defeating skip-when-equal and + // forcing a spurious cold-load `retrieveAll()`. We cannot use `''` itself + // as the persisted sentinel ('' would masquerade as "I have a hash" and + // defeat the `localHash !== null` cold-start guard below), so we read as a + // string and normalize empty→null. (enforcement queue #130; mirrors wijs #122) + const rawPersistedHash = storageService.get(hashStorageKey, ''); + const initialPersistedHash: string | null = rawPersistedHash === '' ? null : rawPersistedHash; const localHash: Ref = ref(initialPersistedHash); const currentServerHash: Ref = ref(null); diff --git a/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts b/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts index 039ad03..9769963 100644 --- a/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts +++ b/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts @@ -19,6 +19,7 @@ import type {StorageService} from '@script-development/fs-storage'; import type {AxiosResponse, InternalAxiosRequestConfig} from 'axios'; import type {Ref} from 'vue'; +import {createStorageService} from '@script-development/fs-storage'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {ref} from 'vue'; @@ -1209,4 +1210,48 @@ describe('createCachedAdapterStoreModule', () => { expect(encodeSubscribeHeader(['x']).startsWith('v1.')).toBe(true); }); }); + + describe('numeric-hash coercion regression (enforcement queue #130; mirrors wijs #122)', () => { + it('an all-numeric persisted hash round-trips through REAL fs-storage as a STRING → an equal server hash skips inner fetch (no spurious cold-load)', async () => { + // Exercises the real fs-storage put(raw) → get(string-default → raw) + // contract rather than the stub above (which ignores the default and + // is therefore blind to this bug). A `crc32b(uuid())` hash like + // '55776784' is all-numeric with no leading zero. Under the OLD + // `get(hashStorageKey, null)` read, the non-string default drove + // fs-storage's JSON.parse branch, coercing '55776784' → Number + // 55776784. `localHash` (Number) then never strict-equals the string + // server hash, so skip-when-equal never matched → a redundant + // retrieveAll() on every cold load. kendo is live-exposed (its + // backend emits crc32b(uuid()) hashes). The fix reads with a string + // default so the raw string is returned verbatim. + localStorage.clear(); + const numericHash = '55776784'; + const cacheKey = 'lanes'; + // Real fs-storage, persisted via the SAME service the wrapper reads. + const storageService = createStorageService('fs-cas-numeric-test'); + storageService.put(`${cacheKey}.cache-hash`, numericHash); + + const httpService = makeFakeHttpService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService, cacheKey), + {cacheKey}, + ); + + // Server reports the SAME all-numeric hash (as a JSON string in the header). + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({[cacheKey]: numericHash})})); + await store.prime(); + // Drain the fire-and-forget middleware trigger. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Equal string hashes → skip-when-equal short-circuits BOTH the + // middleware-triggered fetch and prime(). Under the old numeric + // coercion, localHash would be Number 55776784 !== String + // '55776784' → fetch fires and this assertion fails. + expect(httpService.getRequest).not.toHaveBeenCalled(); + + localStorage.clear(); + }); + }); });