Skip to content

Commit cfe5070

Browse files
Testclaude
authored andcommitted
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1C8cLed2opuG5H6MFVssS
1 parent 90a2284 commit cfe5070

4 files changed

Lines changed: 66 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# @script-development/fs-cached-adapter-store
2+
3+
## 0.2.3 — 2026-06-29
4+
5+
### Patch Changes
6+
7+
- **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.

packages/cached-adapter-store/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-cached-adapter-store",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "Higher-order factory wrapping @script-development/fs-adapter-store with hash-bumping cache-check that suppresses redundant retrieveAll GETs at source",
55
"homepage": "https://packages.script.nl/packages/cached-adapter-store",
66
"license": "MIT",

packages/cached-adapter-store/src/cached-adapter-store.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,19 @@ export const createCachedAdapterStoreModule = <
211211

212212
const inner = createAdapterStoreModule<T, E, N>(config);
213213

214-
const initialPersistedHash = storageService.get<string | null>(hashStorageKey, null);
214+
// Read the persisted hash with a STRING default so fs-storage returns the
215+
// raw stored value verbatim. fs-storage's `get` only returns the raw string
216+
// for a *string* default; any non-string default (e.g. `null`) sends it
217+
// down the `JSON.parse` branch, which coerces an all-numeric, no-leading-
218+
// zero hash — exactly the `crc32b(uuid())` shape kendo's backend emits,
219+
// e.g. `'55776784'` — into a Number. A numeric `localHash` then never
220+
// strict-equals the string server hash, defeating skip-when-equal and
221+
// forcing a spurious cold-load `retrieveAll()`. We cannot use `''` itself
222+
// as the persisted sentinel ('' would masquerade as "I have a hash" and
223+
// defeat the `localHash !== null` cold-start guard below), so we read as a
224+
// string and normalize empty→null. (enforcement queue #130; mirrors wijs #122)
225+
const rawPersistedHash = storageService.get<string>(hashStorageKey, '');
226+
const initialPersistedHash: string | null = rawPersistedHash === '' ? null : rawPersistedHash;
215227
const localHash: Ref<string | null> = ref(initialPersistedHash);
216228
const currentServerHash: Ref<string | null> = ref(null);
217229

packages/cached-adapter-store/tests/cached-adapter-store.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {StorageService} from '@script-development/fs-storage';
1919
import type {AxiosResponse, InternalAxiosRequestConfig} from 'axios';
2020
import type {Ref} from 'vue';
2121

22+
import {createStorageService} from '@script-development/fs-storage';
2223
import {beforeEach, describe, expect, it, vi} from 'vitest';
2324
import {ref} from 'vue';
2425

@@ -1209,4 +1210,48 @@ describe('createCachedAdapterStoreModule', () => {
12091210
expect(encodeSubscribeHeader(['x']).startsWith('v1.')).toBe(true);
12101211
});
12111212
});
1213+
1214+
describe('numeric-hash coercion regression (enforcement queue #130; mirrors wijs #122)', () => {
1215+
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 () => {
1216+
// Exercises the real fs-storage put(raw) → get(string-default → raw)
1217+
// contract rather than the stub above (which ignores the default and
1218+
// is therefore blind to this bug). A `crc32b(uuid())` hash like
1219+
// '55776784' is all-numeric with no leading zero. Under the OLD
1220+
// `get(hashStorageKey, null)` read, the non-string default drove
1221+
// fs-storage's JSON.parse branch, coercing '55776784' → Number
1222+
// 55776784. `localHash` (Number) then never strict-equals the string
1223+
// server hash, so skip-when-equal never matched → a redundant
1224+
// retrieveAll() on every cold load. kendo is live-exposed (its
1225+
// backend emits crc32b(uuid()) hashes). The fix reads with a string
1226+
// default so the raw string is returned verbatim.
1227+
localStorage.clear();
1228+
const numericHash = '55776784';
1229+
const cacheKey = 'lanes';
1230+
// Real fs-storage, persisted via the SAME service the wrapper reads.
1231+
const storageService = createStorageService('fs-cas-numeric-test');
1232+
storageService.put(`${cacheKey}.cache-hash`, numericHash);
1233+
1234+
const httpService = makeFakeHttpService();
1235+
const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)};
1236+
vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse<TestItem[]>);
1237+
const store = createCachedAdapterStoreModule<TestItem, TestAdapted, TestNewAdapted>(
1238+
makeConfig(httpService, storageService, loadingService, cacheKey),
1239+
{cacheKey},
1240+
);
1241+
1242+
// Server reports the SAME all-numeric hash (as a JSON string in the header).
1243+
httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({[cacheKey]: numericHash})}));
1244+
await store.prime();
1245+
// Drain the fire-and-forget middleware trigger.
1246+
await new Promise((resolve) => setTimeout(resolve, 0));
1247+
1248+
// Equal string hashes → skip-when-equal short-circuits BOTH the
1249+
// middleware-triggered fetch and prime(). Under the old numeric
1250+
// coercion, localHash would be Number 55776784 !== String
1251+
// '55776784' → fetch fires and this assertion fails.
1252+
expect(httpService.getRequest).not.toHaveBeenCalled();
1253+
1254+
localStorage.clear();
1255+
});
1256+
});
12121257
});

0 commit comments

Comments
 (0)