Skip to content

Commit 052bf66

Browse files
marcstraubeclaude
andauthored
feat(storage): add custom serializer hook for BaseStorageManager (#58)
## Summary - Add configurable `serializer`/`deserializer` options to `StorageConfig` (defaults to `JSON.stringify`/`JSON.parse` for backwards compatibility) - Replace hardcoded JSON calls in `BaseStorageManager` and `StorageManager` with config hooks - Add `withSerializer()` fluent method to `StorageConfig` Closes #6 ## Test plan - [x] Default behavior unchanged (JSON.stringify/JSON.parse) - [x] Custom serializer/deserializer roundtrip via set/get - [x] Custom serializer works with entries() and getResult() - [x] Date revival with custom deserializer reviver - [x] Fluent chain preserves serializer through all with* methods - [x] 100% line coverage maintained 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d0c9204 commit 052bf66

5 files changed

Lines changed: 207 additions & 21 deletions

File tree

src/storage/BaseStorageManager.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ export interface BaseStorageStats {
5757
* Implements common logic for localStorage and sessionStorage wrappers.
5858
*
5959
* @remarks
60-
* Values are serialized via `JSON.stringify` and deserialized via `JSON.parse`.
61-
* Types without native JSON representation (Date, Map, Set, RegExp, etc.)
62-
* will lose their type information. Use plain objects and primitives, or
63-
* convert values before storing and after retrieval.
60+
* Values are serialized and deserialized using configurable hooks
61+
* (defaults to `JSON.stringify` / `JSON.parse`). To preserve non-JSON-native
62+
* types (Date, Map, Set, RegExp, etc.), provide custom `serializer` and
63+
* `deserializer` functions via {@link StorageConfig}.
6464
*/
6565
export abstract class BaseStorageManager<T = unknown> {
6666
protected readonly config: StorageConfig;
@@ -141,7 +141,7 @@ export abstract class BaseStorageManager<T = unknown> {
141141
const storage = this.getNativeStorage();
142142

143143
try {
144-
const serialized = JSON.stringify(entry);
144+
const serialized = this.config.serializer(entry);
145145
storage.setItem(fullKey, serialized);
146146
this.enforceLimits();
147147
this.config.logger.debug('Stored:', key);
@@ -152,7 +152,7 @@ export abstract class BaseStorageManager<T = unknown> {
152152

153153
// Retry once
154154
try {
155-
storage.setItem(fullKey, JSON.stringify(entry));
155+
storage.setItem(fullKey, this.config.serializer(entry));
156156
this.config.logger.debug('Stored after eviction:', key);
157157
} catch (retryError) {
158158
throw StorageError.quotaExceeded(key, retryError);
@@ -184,7 +184,7 @@ export abstract class BaseStorageManager<T = unknown> {
184184
}
185185

186186
try {
187-
const parsed: unknown = JSON.parse(raw);
187+
const parsed: unknown = this.config.deserializer(raw);
188188

189189
if (!isStorageEntry<T>(parsed)) {
190190
this.config.logger.error('Invalid storage entry structure:', key);
@@ -221,7 +221,7 @@ export abstract class BaseStorageManager<T = unknown> {
221221
}
222222

223223
try {
224-
const parsed: unknown = JSON.parse(raw);
224+
const parsed: unknown = this.config.deserializer(raw);
225225

226226
if (!isStorageEntry<T>(parsed)) {
227227
return Result.err(StorageError.corrupted(key, 'invalid storage entry structure'));
@@ -303,7 +303,7 @@ export abstract class BaseStorageManager<T = unknown> {
303303
try {
304304
const raw = storage.getItem(this.getFullKey(key));
305305
if (raw !== null) {
306-
const parsed: unknown = JSON.parse(raw);
306+
const parsed: unknown = this.config.deserializer(raw);
307307

308308
if (!isStorageEntry<T>(parsed)) {
309309
// Skip entries with invalid structure

src/storage/StorageConfig.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ export interface StorageConfigOptions {
5454
* @default true
5555
*/
5656
readonly useMemoryFallback?: boolean;
57+
58+
/**
59+
* Custom serializer function to convert values to strings for storage.
60+
* Must produce output that the corresponding `deserializer` can parse.
61+
* @default JSON.stringify
62+
*/
63+
readonly serializer?: (value: unknown) => string;
64+
65+
/**
66+
* Custom deserializer function to parse stored strings back to values.
67+
* Must handle output produced by the corresponding `serializer`.
68+
* @default JSON.parse
69+
*/
70+
readonly deserializer?: (raw: string) => unknown;
5771
}
5872

5973
export class StorageConfig {
@@ -62,19 +76,25 @@ export class StorageConfig {
6276
readonly minSafeEntries: number;
6377
readonly logger: LoggerLike;
6478
readonly useMemoryFallback: boolean;
79+
readonly serializer: (value: unknown) => string;
80+
readonly deserializer: (raw: string) => unknown;
6581

6682
private constructor(
6783
prefix: string,
6884
maxEntries: number,
6985
minSafeEntries: number,
7086
logger: LoggerLike,
71-
useMemoryFallback: boolean
87+
useMemoryFallback: boolean,
88+
serializer: (value: unknown) => string,
89+
deserializer: (raw: string) => unknown
7290
) {
7391
this.prefix = prefix;
7492
this.maxEntries = maxEntries;
7593
this.minSafeEntries = minSafeEntries;
7694
this.logger = logger;
7795
this.useMemoryFallback = useMemoryFallback;
96+
this.serializer = serializer;
97+
this.deserializer = deserializer;
7898
}
7999

80100
// =========================================================================
@@ -99,7 +119,18 @@ export class StorageConfig {
99119

100120
const useMemoryFallback = options.useMemoryFallback ?? true;
101121

102-
return new StorageConfig(prefix, maxEntries, minSafeEntries, logger, useMemoryFallback);
122+
const serializer = options.serializer ?? JSON.stringify;
123+
const deserializer = options.deserializer ?? JSON.parse;
124+
125+
return new StorageConfig(
126+
prefix,
127+
maxEntries,
128+
minSafeEntries,
129+
logger,
130+
useMemoryFallback,
131+
serializer,
132+
deserializer
133+
);
103134
}
104135

105136
/**
@@ -141,7 +172,9 @@ export class StorageConfig {
141172
this.maxEntries,
142173
this.minSafeEntries,
143174
this.logger,
144-
this.useMemoryFallback
175+
this.useMemoryFallback,
176+
this.serializer,
177+
this.deserializer
145178
);
146179
}
147180

@@ -155,7 +188,9 @@ export class StorageConfig {
155188
maxEntries,
156189
Math.min(this.minSafeEntries, maxEntries),
157190
this.logger,
158-
this.useMemoryFallback
191+
this.useMemoryFallback,
192+
this.serializer,
193+
this.deserializer
159194
);
160195
}
161196

@@ -169,7 +204,9 @@ export class StorageConfig {
169204
this.maxEntries,
170205
minSafeEntries,
171206
this.logger,
172-
this.useMemoryFallback
207+
this.useMemoryFallback,
208+
this.serializer,
209+
this.deserializer
173210
);
174211
}
175212

@@ -182,7 +219,9 @@ export class StorageConfig {
182219
this.maxEntries,
183220
this.minSafeEntries,
184221
logger,
185-
this.useMemoryFallback
222+
this.useMemoryFallback,
223+
this.serializer,
224+
this.deserializer
186225
);
187226
}
188227

@@ -195,7 +234,28 @@ export class StorageConfig {
195234
this.maxEntries,
196235
this.minSafeEntries,
197236
this.logger,
198-
enabled
237+
enabled,
238+
this.serializer,
239+
this.deserializer
240+
);
241+
}
242+
243+
/**
244+
* Create new config with custom serializer and deserializer.
245+
* Both must be provided together to ensure roundtrip compatibility.
246+
*/
247+
withSerializer(
248+
serializer: (value: unknown) => string,
249+
deserializer: (raw: string) => unknown
250+
): StorageConfig {
251+
return new StorageConfig(
252+
this.prefix,
253+
this.maxEntries,
254+
this.minSafeEntries,
255+
this.logger,
256+
this.useMemoryFallback,
257+
serializer,
258+
deserializer
199259
);
200260
}
201261
}

src/storage/StorageManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class StorageManager<T = unknown> extends BaseStorageManager<T> {
126126
const parseValue = (raw: string | null): T | null => {
127127
if (raw === null) return null;
128128
try {
129-
const parsed: unknown = JSON.parse(raw);
129+
const parsed: unknown = this.config.deserializer(raw);
130130
return isStorageEntry<T>(parsed) ? parsed.data : null;
131131
} catch {
132132
return null;

tests/storage/BaseStorageManager.test.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,7 @@ describe('BaseStorageManager', () => {
259259
const origSetItem = mockStorage.setItem.bind(mockStorage);
260260
mockStorage.setItem = (key: string, value: string) => {
261261
if (key === 'test.big' && callCount++ === 0) {
262-
const err = new DOMException('', 'QuotaExceededError');
263-
throw err;
262+
throw new DOMException('', 'QuotaExceededError');
264263
}
265264
origSetItem(key, value);
266265
};
@@ -270,6 +269,69 @@ describe('BaseStorageManager', () => {
270269
});
271270
});
272271

272+
describe('custom serializer', () => {
273+
it('should use custom serializer for set and deserializer for get', () => {
274+
const serializer = vi.fn((value: unknown): string => `custom:${JSON.stringify(value)}`);
275+
const deserializer = vi.fn((raw: string): unknown => JSON.parse(raw.replace('custom:', '')));
276+
const config = StorageConfig.create({ prefix: 'test', serializer, deserializer });
277+
const manager = StorageManager.create(config);
278+
279+
manager.set('key', 'value');
280+
expect(serializer).toHaveBeenCalled();
281+
282+
const result = manager.get('key');
283+
expect(deserializer).toHaveBeenCalled();
284+
expect(result).toBe('value');
285+
});
286+
287+
it('should use custom serializer for entries()', () => {
288+
const serializer = (value: unknown): string => `custom:${JSON.stringify(value)}`;
289+
const deserializer = (raw: string): unknown => JSON.parse(raw.replace('custom:', ''));
290+
const config = StorageConfig.create({ prefix: 'test', serializer, deserializer });
291+
const manager = StorageManager.create(config);
292+
293+
manager.set('key', 'value');
294+
const entries = manager.entries();
295+
expect(entries).toHaveLength(1);
296+
expect(entries[0]!.value).toBe('value');
297+
});
298+
299+
it('should use custom serializer with getResult()', () => {
300+
const serializer = (value: unknown): string => `custom:${JSON.stringify(value)}`;
301+
const deserializer = (raw: string): unknown => JSON.parse(raw.replace('custom:', ''));
302+
const config = StorageConfig.create({ prefix: 'test', serializer, deserializer });
303+
const manager = StorageManager.create(config);
304+
305+
manager.set('key', 'value');
306+
const result = manager.getResult('key');
307+
expect(Result.isOk(result)).toBe(true);
308+
if (Result.isOk(result)) {
309+
expect(result.value).toBe('value');
310+
}
311+
});
312+
313+
it('should preserve Date objects with custom serializer using reviver', () => {
314+
// JSON.stringify calls Date.toJSON() before the replacer sees the value,
315+
// so a reviver-based approach is needed to restore Date instances.
316+
const ISO_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/;
317+
const serializer = JSON.stringify;
318+
const deserializer = (raw: string): unknown =>
319+
JSON.parse(raw, (_key, val: unknown) =>
320+
typeof val === 'string' && ISO_REGEX.test(val) ? new Date(val) : val
321+
);
322+
const config = StorageConfig.create({ prefix: 'test', serializer, deserializer });
323+
const manager = StorageManager.create<{ createdAt: Date }>(config);
324+
325+
const date = new Date('2024-06-15T12:00:00Z');
326+
manager.set('item', { createdAt: date });
327+
328+
const retrieved = manager.get('item');
329+
expect(retrieved).not.toBeNull();
330+
expect(retrieved!.createdAt).toBeInstanceOf(Date);
331+
expect(retrieved!.createdAt.toISOString()).toBe('2024-06-15T12:00:00.000Z');
332+
});
333+
});
334+
273335
describe('isQuotaError', () => {
274336
it('should detect QuotaExceededError by name', () => {
275337
const config = StorageConfig.create({ prefix: 'test', minSafeEntries: 0 });
@@ -279,8 +341,7 @@ describe('BaseStorageManager', () => {
279341
const origSetItem = mockStorage.setItem.bind(mockStorage);
280342
mockStorage.setItem = (key: string, value: string) => {
281343
if (key === 'test.item' && callCount++ === 0) {
282-
const err = new DOMException('', 'QuotaExceededError');
283-
throw err;
344+
throw new DOMException('', 'QuotaExceededError');
284345
}
285346
origSetItem(key, value);
286347
};

tests/storage/StorageConfig.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,71 @@ describe('StorageConfig', () => {
267267
});
268268
});
269269

270+
describe('create with custom serializer', () => {
271+
it('should use JSON.stringify and JSON.parse by default', () => {
272+
const config = StorageConfig.create();
273+
274+
expect(config.serializer).toBe(JSON.stringify);
275+
expect(config.deserializer).toBe(JSON.parse);
276+
});
277+
278+
it('should accept custom serializer and deserializer', () => {
279+
const serializer = (value: unknown): string => `custom:${JSON.stringify(value)}`;
280+
const deserializer = (raw: string): unknown => JSON.parse(raw.replace('custom:', ''));
281+
const config = StorageConfig.create({ serializer, deserializer });
282+
283+
expect(config.serializer).toBe(serializer);
284+
expect(config.deserializer).toBe(deserializer);
285+
});
286+
});
287+
288+
describe('withSerializer', () => {
289+
it('should create new config with custom serializer and deserializer', () => {
290+
const original = StorageConfig.create();
291+
const serializer = (value: unknown): string => `custom:${JSON.stringify(value)}`;
292+
const deserializer = (raw: string): unknown => JSON.parse(raw.replace('custom:', ''));
293+
const modified = original.withSerializer(serializer, deserializer);
294+
295+
expect(modified.serializer).toBe(serializer);
296+
expect(modified.deserializer).toBe(deserializer);
297+
expect(original.serializer).toBe(JSON.stringify);
298+
expect(original.deserializer).toBe(JSON.parse);
299+
});
300+
301+
it('should preserve other settings', () => {
302+
const original = StorageConfig.create({
303+
prefix: 'myApp',
304+
maxEntries: 100,
305+
useMemoryFallback: false,
306+
});
307+
const serializer = JSON.stringify;
308+
const deserializer = JSON.parse;
309+
const modified = original.withSerializer(serializer, deserializer);
310+
311+
expect(modified.prefix).toBe('myApp');
312+
expect(modified.maxEntries).toBe(100);
313+
expect(modified.useMemoryFallback).toBe(false);
314+
});
315+
});
316+
317+
describe('fluent methods preserve serializer', () => {
318+
it('should preserve custom serializer through fluent chain', () => {
319+
const serializer = (value: unknown): string => `custom:${JSON.stringify(value)}`;
320+
const deserializer = (raw: string): unknown => JSON.parse(raw.replace('custom:', ''));
321+
const config = StorageConfig.create({ serializer, deserializer });
322+
323+
const chained = config
324+
.withPrefix('newApp')
325+
.withMaxEntries(200)
326+
.withMinSafeEntries(10)
327+
.withLogger(Logger.silent())
328+
.withMemoryFallback(false);
329+
330+
expect(chained.serializer).toBe(serializer);
331+
expect(chained.deserializer).toBe(deserializer);
332+
});
333+
});
334+
270335
describe('immutability', () => {
271336
it('should not modify original config', () => {
272337
const original = StorageConfig.create({

0 commit comments

Comments
 (0)