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
5 changes: 5 additions & 0 deletions .changeset/good-pugs-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codebelt/classy-store": patch
---

add TTL expiration and related features to persistence utility
186 changes: 186 additions & 0 deletions src/utils/persist/persist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,192 @@ describe('persist()', () => {
});
});

// ── expireIn / TTL ─────────────────────────────────────────────────────

describe('expireIn / TTL', () => {
it('hydrates normally when TTL has not elapsed', async () => {
const storage = createMockStorage();
storage.data.set(
'test',
JSON.stringify({
version: 0,
state: {count: 42},
expiresAt: Date.now() + 60_000,
}),
);

const s = store({count: 0});
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
await handle.hydrated;

expect(s.count).toBe(42);
expect(handle.isExpired).toBe(false);
});

it('skips hydration when TTL has elapsed and sets isExpired', async () => {
const storage = createMockStorage();
storage.data.set(
'test',
JSON.stringify({
version: 0,
state: {count: 42},
expiresAt: Date.now() - 1000,
}),
);

const s = store({count: 0});
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
await handle.hydrated;

expect(s.count).toBe(0); // not hydrated
expect(handle.isExpired).toBe(true);
});

it('clearOnExpire: true removes the key from storage', async () => {
const storage = createMockStorage();
storage.data.set(
'test',
JSON.stringify({
version: 0,
state: {count: 42},
expiresAt: Date.now() - 1000,
}),
);

const s = store({count: 0});
const handle = persist(s, {
name: 'test',
storage,
expireIn: 60_000,
clearOnExpire: true,
});
await handle.hydrated;
await tick();

expect(storage.data.has('test')).toBe(false);
});

it('clearOnExpire: false (default) leaves the key in storage', async () => {
const storage = createMockStorage();
storage.data.set(
'test',
JSON.stringify({
version: 0,
state: {count: 42},
expiresAt: Date.now() - 1000,
}),
);

const s = store({count: 0});
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
await handle.hydrated;
await tick();

expect(storage.data.has('test')).toBe(true);
});

it('cross-tab sync rejects expired envelopes', async () => {
const storage = createMockStorage();
const s = store({count: 0});
const handle = persist(s, {
name: 'test',
storage,
expireIn: 60_000,
syncTabs: true,
});
await handle.hydrated;

const event = new StorageEvent('storage', {
key: 'test',
newValue: JSON.stringify({
version: 0,
state: {count: 999},
expiresAt: Date.now() - 1000,
}),
});
globalThis.dispatchEvent(event);

expect(s.count).toBe(0); // expired — rejected
expect(handle.isExpired).toBe(true);

handle.unsubscribe();
});

it('data without expiresAt hydrates normally when expireIn is set', async () => {
const storage = createMockStorage();
storage.data.set(
'test',
JSON.stringify({version: 0, state: {count: 77}}),
);

const s = store({count: 0});
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
await handle.hydrated;

expect(s.count).toBe(77);
expect(handle.isExpired).toBe(false);
});

it('TTL resets on every write (envelope timestamp refreshes)', async () => {
const storage = createMockStorage();
const s = store({count: 0});
persist(s, {name: 'test', storage, expireIn: 30_000});

const before = Date.now();
s.count = 1;
await tick();

const stored1 = parseStored(storage, 'test') as unknown as {
expiresAt: number;
};
expect(stored1.expiresAt).toBeGreaterThanOrEqual(before + 30_000);

// Second write should bump the timestamp.
const betweenWrites = Date.now();
s.count = 2;
await tick();

const stored2 = parseStored(storage, 'test') as unknown as {
expiresAt: number;
};
expect(stored2.expiresAt).toBeGreaterThanOrEqual(betweenWrites + 30_000);
expect(stored2.expiresAt).toBeGreaterThanOrEqual(stored1.expiresAt);
});

it('rehydrate() re-checks expiry', async () => {
const storage = createMockStorage();
// Start with valid data.
storage.data.set(
'test',
JSON.stringify({
version: 0,
state: {count: 42},
expiresAt: Date.now() + 60_000,
}),
);

const s = store({count: 0});
const handle = persist(s, {name: 'test', storage, expireIn: 60_000});
await handle.hydrated;
expect(s.count).toBe(42);
expect(handle.isExpired).toBe(false);

// Simulate data becoming expired.
storage.data.set(
'test',
JSON.stringify({
version: 0,
state: {count: 99},
expiresAt: Date.now() - 1000,
}),
);

await handle.rehydrate();
expect(s.count).toBe(42); // unchanged — expired data not applied
expect(handle.isExpired).toBe(true);
});
});

// ── Edge cases ──────────────────────────────────────────────────────────

describe('edge cases', () => {
Expand Down
38 changes: 38 additions & 0 deletions src/utils/persist/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ export type PersistOptions<T extends object> = {
* Default: `true` when storage is `localStorage`, `false` otherwise.
*/
syncTabs?: boolean;

/**
* Time-to-live in milliseconds. After this duration, stored data is
* considered expired and skipped during hydration. The TTL resets on
* every write (active sessions stay fresh as long as mutations happen).
*/
expireIn?: number;

/**
* When `true`, automatically remove the storage key if data is found
* expired during hydration. Default: `false` (expired data is skipped
* but left in storage).
*/
clearOnExpire?: boolean;
};

/**
Expand All @@ -145,6 +159,9 @@ export type PersistHandle = {

/** Manually re-hydrate the store from storage. */
rehydrate: () => Promise<void>;

/** True if the last hydration found expired data (requires `expireIn`). */
isExpired: boolean;
};

// ── Storage envelope ─────────────────────────────────────────────────────────
Expand All @@ -153,6 +170,7 @@ export type PersistHandle = {
type PersistEnvelope = {
version: number;
state: Record<string, unknown>;
expiresAt?: number;
};

// ── Helpers ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -262,6 +280,8 @@ export function persist<T extends object>(
merge = 'shallow',
skipHydration = false,
syncTabs: syncTabsOption,
expireIn,
clearOnExpire = false,
} = options;

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

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

const envelope: PersistEnvelope = {version, state};
if (expireIn != null) {
envelope.expiresAt = Date.now() + expireIn;
}
return JSON.stringify(envelope);
}

Expand Down Expand Up @@ -367,6 +391,16 @@ export function persist<T extends object>(
return;
}

// Expiry check — skip hydration if data has expired.
if (
typeof envelope.expiresAt === 'number' &&
Date.now() >= envelope.expiresAt
) {
expiredFlag = true;
if (clearOnExpire) void storage.removeItem(name);
return;
}

let {state} = envelope;

// Version migration.
Expand Down Expand Up @@ -465,6 +499,10 @@ export function persist<T extends object>(
return hydratedFlag;
},

get isExpired() {
return expiredFlag;
},

hydrated: hydratedPromise,

unsubscribe() {
Expand Down
Loading