Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/cached-adapter-store/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/cached-adapter-store/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 13 additions & 1 deletion packages/cached-adapter-store/src/cached-adapter-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,19 @@ export const createCachedAdapterStoreModule = <

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

const initialPersistedHash = storageService.get<string | null>(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<string>(hashStorageKey, '');
Comment thread
jasperboerhof marked this conversation as resolved.
const initialPersistedHash: string | null = rawPersistedHash === '' ? null : rawPersistedHash;
const localHash: Ref<string | null> = ref(initialPersistedHash);
const currentServerHash: Ref<string | null> = ref(null);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<TestItem[]>);
const store = createCachedAdapterStoreModule<TestItem, TestAdapted, TestNewAdapted>(
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();
});
});
});