Skip to content

Commit cb05c1b

Browse files
authored
feat: add TTL expiration and related features to persistence utility (#3)
- Introduced `expireIn` option for time-to-live (TTL) on persisted data. - Added `clearOnExpire` to auto-remove expired data during hydration. - Enhanced `PersistHandle` with `isExpired` flag for expiration checks. - Updated docs and tests to reflect TTL handling and cross-tab sync behavior.
1 parent 4d371f4 commit cb05c1b

File tree

7 files changed

+320
-32
lines changed

7 files changed

+320
-32
lines changed

.changeset/good-pugs-sleep.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@codebelt/classy-store": patch
3+
---
4+
5+
add TTL expiration and related features to persistence utility

src/utils/persist/persist.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,192 @@ describe('persist()', () => {
750750
});
751751
});
752752

753+
// ── expireIn / TTL ─────────────────────────────────────────────────────
754+
755+
describe('expireIn / TTL', () => {
756+
it('hydrates normally when TTL has not elapsed', async () => {
757+
const storage = createMockStorage();
758+
storage.data.set(
759+
'test',
760+
JSON.stringify({
761+
version: 0,
762+
state: {count: 42},
763+
expiresAt: Date.now() + 60_000,
764+
}),
765+
);
766+
767+
const s = store({count: 0});
768+
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
769+
await handle.hydrated;
770+
771+
expect(s.count).toBe(42);
772+
expect(handle.isExpired).toBe(false);
773+
});
774+
775+
it('skips hydration when TTL has elapsed and sets isExpired', async () => {
776+
const storage = createMockStorage();
777+
storage.data.set(
778+
'test',
779+
JSON.stringify({
780+
version: 0,
781+
state: {count: 42},
782+
expiresAt: Date.now() - 1000,
783+
}),
784+
);
785+
786+
const s = store({count: 0});
787+
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
788+
await handle.hydrated;
789+
790+
expect(s.count).toBe(0); // not hydrated
791+
expect(handle.isExpired).toBe(true);
792+
});
793+
794+
it('clearOnExpire: true removes the key from storage', async () => {
795+
const storage = createMockStorage();
796+
storage.data.set(
797+
'test',
798+
JSON.stringify({
799+
version: 0,
800+
state: {count: 42},
801+
expiresAt: Date.now() - 1000,
802+
}),
803+
);
804+
805+
const s = store({count: 0});
806+
const handle = persist(s, {
807+
name: 'test',
808+
storage,
809+
expireIn: 60_000,
810+
clearOnExpire: true,
811+
});
812+
await handle.hydrated;
813+
await tick();
814+
815+
expect(storage.data.has('test')).toBe(false);
816+
});
817+
818+
it('clearOnExpire: false (default) leaves the key in storage', async () => {
819+
const storage = createMockStorage();
820+
storage.data.set(
821+
'test',
822+
JSON.stringify({
823+
version: 0,
824+
state: {count: 42},
825+
expiresAt: Date.now() - 1000,
826+
}),
827+
);
828+
829+
const s = store({count: 0});
830+
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
831+
await handle.hydrated;
832+
await tick();
833+
834+
expect(storage.data.has('test')).toBe(true);
835+
});
836+
837+
it('cross-tab sync rejects expired envelopes', async () => {
838+
const storage = createMockStorage();
839+
const s = store({count: 0});
840+
const handle = persist(s, {
841+
name: 'test',
842+
storage,
843+
expireIn: 60_000,
844+
syncTabs: true,
845+
});
846+
await handle.hydrated;
847+
848+
const event = new StorageEvent('storage', {
849+
key: 'test',
850+
newValue: JSON.stringify({
851+
version: 0,
852+
state: {count: 999},
853+
expiresAt: Date.now() - 1000,
854+
}),
855+
});
856+
globalThis.dispatchEvent(event);
857+
858+
expect(s.count).toBe(0); // expired — rejected
859+
expect(handle.isExpired).toBe(true);
860+
861+
handle.unsubscribe();
862+
});
863+
864+
it('data without expiresAt hydrates normally when expireIn is set', async () => {
865+
const storage = createMockStorage();
866+
storage.data.set(
867+
'test',
868+
JSON.stringify({version: 0, state: {count: 77}}),
869+
);
870+
871+
const s = store({count: 0});
872+
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
873+
await handle.hydrated;
874+
875+
expect(s.count).toBe(77);
876+
expect(handle.isExpired).toBe(false);
877+
});
878+
879+
it('TTL resets on every write (envelope timestamp refreshes)', async () => {
880+
const storage = createMockStorage();
881+
const s = store({count: 0});
882+
persist(s, {name: 'test', storage, expireIn: 30_000});
883+
884+
const before = Date.now();
885+
s.count = 1;
886+
await tick();
887+
888+
const stored1 = parseStored(storage, 'test') as unknown as {
889+
expiresAt: number;
890+
};
891+
expect(stored1.expiresAt).toBeGreaterThanOrEqual(before + 30_000);
892+
893+
// Second write should bump the timestamp.
894+
const betweenWrites = Date.now();
895+
s.count = 2;
896+
await tick();
897+
898+
const stored2 = parseStored(storage, 'test') as unknown as {
899+
expiresAt: number;
900+
};
901+
expect(stored2.expiresAt).toBeGreaterThanOrEqual(betweenWrites + 30_000);
902+
expect(stored2.expiresAt).toBeGreaterThanOrEqual(stored1.expiresAt);
903+
});
904+
905+
it('rehydrate() re-checks expiry', async () => {
906+
const storage = createMockStorage();
907+
// Start with valid data.
908+
storage.data.set(
909+
'test',
910+
JSON.stringify({
911+
version: 0,
912+
state: {count: 42},
913+
expiresAt: Date.now() + 60_000,
914+
}),
915+
);
916+
917+
const s = store({count: 0});
918+
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
919+
await handle.hydrated;
920+
expect(s.count).toBe(42);
921+
expect(handle.isExpired).toBe(false);
922+
923+
// Simulate data becoming expired.
924+
storage.data.set(
925+
'test',
926+
JSON.stringify({
927+
version: 0,
928+
state: {count: 99},
929+
expiresAt: Date.now() - 1000,
930+
}),
931+
);
932+
933+
await handle.rehydrate();
934+
expect(s.count).toBe(42); // unchanged — expired data not applied
935+
expect(handle.isExpired).toBe(true);
936+
});
937+
});
938+
753939
// ── Edge cases ──────────────────────────────────────────────────────────
754940

755941
describe('edge cases', () => {

src/utils/persist/persist.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,20 @@ export type PersistOptions<T extends object> = {
122122
* Default: `true` when storage is `localStorage`, `false` otherwise.
123123
*/
124124
syncTabs?: boolean;
125+
126+
/**
127+
* Time-to-live in milliseconds. After this duration, stored data is
128+
* considered expired and skipped during hydration. The TTL resets on
129+
* every write (active sessions stay fresh as long as mutations happen).
130+
*/
131+
expireIn?: number;
132+
133+
/**
134+
* When `true`, automatically remove the storage key if data is found
135+
* expired during hydration. Default: `false` (expired data is skipped
136+
* but left in storage).
137+
*/
138+
clearOnExpire?: boolean;
125139
};
126140

127141
/**
@@ -145,6 +159,9 @@ export type PersistHandle = {
145159

146160
/** Manually re-hydrate the store from storage. */
147161
rehydrate: () => Promise<void>;
162+
163+
/** True if the last hydration found expired data (requires `expireIn`). */
164+
isExpired: boolean;
148165
};
149166

150167
// ── Storage envelope ─────────────────────────────────────────────────────────
@@ -153,6 +170,7 @@ export type PersistHandle = {
153170
type PersistEnvelope = {
154171
version: number;
155172
state: Record<string, unknown>;
173+
expiresAt?: number;
156174
};
157175

158176
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -262,6 +280,8 @@ export function persist<T extends object>(
262280
merge = 'shallow',
263281
skipHydration = false,
264282
syncTabs: syncTabsOption,
283+
expireIn,
284+
clearOnExpire = false,
265285
} = options;
266286

267287
const maybeStorage = options.storage ?? getDefaultStorage();
@@ -290,6 +310,7 @@ export function persist<T extends object>(
290310
let disposed = false;
291311
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
292312
let hydratedFlag = false;
313+
let expiredFlag = false;
293314

294315
// Hydration promise + resolver.
295316
let resolveHydrated: () => void;
@@ -316,6 +337,9 @@ export function persist<T extends object>(
316337
}
317338

318339
const envelope: PersistEnvelope = {version, state};
340+
if (expireIn != null) {
341+
envelope.expiresAt = Date.now() + expireIn;
342+
}
319343
return JSON.stringify(envelope);
320344
}
321345

@@ -367,6 +391,16 @@ export function persist<T extends object>(
367391
return;
368392
}
369393

394+
// Expiry check — skip hydration if data has expired.
395+
if (
396+
typeof envelope.expiresAt === 'number' &&
397+
Date.now() >= envelope.expiresAt
398+
) {
399+
expiredFlag = true;
400+
if (clearOnExpire) void storage.removeItem(name);
401+
return;
402+
}
403+
370404
let {state} = envelope;
371405

372406
// Version migration.
@@ -465,6 +499,10 @@ export function persist<T extends object>(
465499
return hydratedFlag;
466500
},
467501

502+
get isExpired() {
503+
return expiredFlag;
504+
},
505+
468506
hydrated: hydratedPromise,
469507

470508
unsubscribe() {

0 commit comments

Comments
 (0)