From 60c3999c6f51605aa3cace446799f21a14c90e2c Mon Sep 17 00:00:00 2001 From: mayrang Date: Thu, 28 May 2026 01:42:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(#2):=20autoAdapter=20=E2=80=94=20localStor?= =?UTF-8?q?age=20primary,=20IndexedDB=20fallback=20on=20size/quota?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2. Wraps localStorage and IndexedDB so users don't have to pre-pick storage by upfront-unknown size. Small payloads stay in fast synchronous localStorage; once a key's serialized size crosses the threshold (default 1 MB) the adapter routes that key's writes to IndexedDB and cleans up the stale localStorage entry. Reads are primary-first so v0.1 → v0.2 upgrades keep working without explicit migration. useFormDraft({ storage: autoAdapter({ thresholdBytes: 500_000, onMigration: (key, reason) => console.log(`migrated`, key, reason), }), // ... }); Also recovers from QuotaExceededError (other libs filling localStorage) by falling back to IndexedDB and reporting `reason: 'quota'`. 7 rounds of code review + adversarial audit in parallel each round. Bugs caught and fixed before merge: R1: Two concurrent same-key writes interleaved into "both succeeded / read returns null" data loss → per-key serialization queue. R2: clear() raced with writes started DURING its own pending-await → installed a clearing barrier that runSerialized chains on. R3: Two concurrent clear() calls — fast clear2 installs barrier, writes chain on it, slow clear1's still-pending adapter.clear() then wipes them → clear chains on prev clearing too. Also moved pending snapshot to the sync prefix to avoid deadlock (clear awaits write awaits clear). R4: read() interleaved with clear could see a mid-wipe state → read awaits clearing barrier. R5: onMigration fired even when primary.remove failed (telemetry lied) → only fires on full success. R6: write(undefined) threw a cryptic TypeError → guard with a formdraft-tagged TypeError. Plus tightened quota detection to name+code only (no /quota/i regex false positives), guarded primary===fallback at construction, and documented concurrent-read+clear ordering as undefined. 26 autoAdapter unit tests, 131 total in the package. Bundle 4.7 KB brotli (8 KB CI gate). --- README.md | 30 ++- src/index.ts | 2 + src/storage/__tests__/auto.test.ts | 393 +++++++++++++++++++++++++++++ src/storage/auto.ts | 228 +++++++++++++++++ 4 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 src/storage/__tests__/auto.test.ts create mode 100644 src/storage/auto.ts diff --git a/README.md b/README.md index c92a871..a8271c0 100644 --- a/README.md +++ b/README.md @@ -144,11 +144,39 @@ formdraft is that stack, packaged. With the 7 platform-quirk traps AI assistants ## Storage adapters ```tsx -import { localStorageAdapter, sessionStorageAdapter, indexedDBAdapter } from 'formdraft'; +import { localStorageAdapter, sessionStorageAdapter, indexedDBAdapter, autoAdapter } from 'formdraft'; useFormDraft({ ..., storage: indexedDBAdapter() }); // for big forms ``` +### `autoAdapter` — automatic localStorage → IndexedDB + +If you don't know upfront how large a draft will get (rich text editor, base64 image uploads, long markdown), use `autoAdapter`. It writes to localStorage for small payloads, and once a value crosses the size threshold, transparently routes that key to IndexedDB. Existing data is migrated; the stale localStorage entry is cleaned up so reads stay consistent. + +```tsx +import { autoAdapter } from 'formdraft'; + +useFormDraft({ + storage: autoAdapter({ + thresholdBytes: 500_000, // default 1_000_000 (1 MB) + onMigration: (key, reason) => { + console.log(`[formdraft] ${key} migrated to IndexedDB:`, reason); + }, + }), + // ... +}); +``` + +It also recovers from `QuotaExceededError` — if another library on the same origin fills localStorage and our write fails, autoAdapter falls back to IndexedDB transparently (and fires `onMigration` with `reason: 'quota'`). + +Notes: +- `thresholdBytes` is compared against `JSON.stringify(value).length`, i.e. UTF-16 code units; multi-byte chars (Korean, emoji) under-count by ~2× vs. real on-wire bytes. The 1 MB default leaves plenty of headroom for this. +- Strict inequality: a payload of exactly `thresholdBytes` stays in primary. Cross it (`>`) to trigger migration. +- After a key migrates to IndexedDB, reads become async (~10-50ms typical) instead of synchronous localStorage. Negligible for mount-restore but worth noting for very latency-sensitive flows. +- `primary` and `fallback` MUST be distinct adapter instances; the constructor throws otherwise. +- If a migration's `primary.remove` fails (rare; usually means storage was disabled mid-session), the stale localStorage entry remains and reads return the old value until the next successful `write()` or `remove()` resolves it. `onMigration` is suppressed in this case so telemetry doesn't lie. +- Concurrent `read()` + `clear()` ordering is undefined: a read started before clear may still resolve with pre-clear data. Sequence them in caller code if you need strict happens-before. + ## Reliable online detection `navigator.onLine === true` lies on captive portals (hotel/airport/coffee shop WiFi) and partially-online states (airplane mode disabled but data blocked). formdraft offers two ways to handle this: diff --git a/src/index.ts b/src/index.ts index a090642..924a4dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { zodAdapter } from './internal/schemaValidation'; export { localStorageAdapter } from './storage/localStorage'; export { sessionStorageAdapter } from './storage/sessionStorage'; export { indexedDBAdapter } from './storage/indexedDB'; +export { autoAdapter } from './storage/auto'; export { createHeartbeatDetector } from './internal/heartbeatDetector'; export type { @@ -15,6 +16,7 @@ export type { FormDraftOptions, FormDraftResult, } from './types'; +export type { AutoAdapterOptions } from './storage/auto'; export type { OnlineDetector, HeartbeatDetectorOptions, diff --git a/src/storage/__tests__/auto.test.ts b/src/storage/__tests__/auto.test.ts new file mode 100644 index 0000000..37e7270 --- /dev/null +++ b/src/storage/__tests__/auto.test.ts @@ -0,0 +1,393 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StorageAdapter } from '../../types'; +import { autoAdapter } from '../auto'; + +// Minimal in-memory adapter so tests don't depend on the localStorage / +// IndexedDB implementations (those have their own test suites). This isolates +// autoAdapter's routing logic. +function makeMockAdapter(name: string): StorageAdapter & { _state: Map; writeImpl: (key: string, value: unknown) => Promise } { + const state = new Map(); + const adapter = { + name, + _state: state, + async read(key: string) { + return state.has(key) ? state.get(key) : null; + }, + writeImpl: async (key: string, value: unknown): Promise => { + state.set(key, value); + }, + async write(key: string, value: unknown) { + await adapter.writeImpl(key, value); + }, + async remove(key: string) { + state.delete(key); + }, + async clear() { + state.clear(); + }, + }; + return adapter; +} + +describe('autoAdapter', () => { + let primary: ReturnType; + let fallback: ReturnType; + + beforeEach(() => { + primary = makeMockAdapter('primary'); + fallback = makeMockAdapter('fallback'); + }); + + it('writes small payloads to primary; reads from primary', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1000 }); + const small = { __v: 1, values: { name: 'Alice' } }; + await a.write('k', small); + expect(primary._state.get('k')).toEqual(small); + expect(fallback._state.has('k')).toBe(false); + expect(await a.read('k')).toEqual(small); + }); + + it('writes large payloads to fallback; reads from fallback', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + // JSON.stringify of this is well over 100 bytes + const big = { __v: 1, values: { content: 'x'.repeat(500) } }; + await a.write('k', big); + expect(primary._state.has('k')).toBe(false); + expect(fallback._state.get('k')).toEqual(big); + expect(await a.read('k')).toEqual(big); + }); + + it('cleans stale primary entry when a key crosses the threshold', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + // First write small → goes to primary + await a.write('k', { __v: 1, values: { x: 'hi' } }); + expect(primary._state.has('k')).toBe(true); + expect(fallback._state.has('k')).toBe(false); + // Now write large → moves to fallback, primary should be cleared so + // read doesn't return the stale small value. + const big = { __v: 1, values: { x: 'x'.repeat(500) } }; + await a.write('k', big); + expect(primary._state.has('k')).toBe(false); + expect(fallback._state.get('k')).toEqual(big); + expect(await a.read('k')).toEqual(big); + }); + + it('cleans stale fallback entry when a key shrinks back under the threshold', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + await a.write('k', { __v: 1, values: { x: 'x'.repeat(500) } }); + expect(fallback._state.has('k')).toBe(true); + // Subsequent small write + await a.write('k', { __v: 1, values: { x: 'hi' } }); + expect(primary._state.has('k')).toBe(true); + expect(fallback._state.has('k')).toBe(false); + }); + + it('falls back to fallback when primary throws QuotaExceededError', async () => { + const onMigration = vi.fn(); + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000, onMigration }); + // Make primary throw quota error on next write + const quotaErr = new Error('LocalStorage quota'); + quotaErr.name = 'QuotaExceededError'; + primary.writeImpl = async () => { + throw quotaErr; + }; + const small = { __v: 1, values: { x: 'hi' } }; + await a.write('k', small); + expect(primary._state.has('k')).toBe(false); + expect(fallback._state.get('k')).toEqual(small); + expect(onMigration).toHaveBeenCalledWith('k', 'quota'); + }); + + it('propagates non-quota errors from primary write', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000 }); + primary.writeImpl = async () => { + throw new TypeError('something else'); + }; + await expect(a.write('k', { foo: 1 })).rejects.toThrow('something else'); + expect(fallback._state.has('k')).toBe(false); + }); + + it('remove clears both primary and fallback', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000 }); + // Manually populate both to simulate post-migration drift + primary._state.set('k', { stale: true }); + fallback._state.set('k', { real: true }); + await a.remove('k'); + expect(primary._state.has('k')).toBe(false); + expect(fallback._state.has('k')).toBe(false); + }); + + it('read prefers primary; falls back to fallback only when primary is null', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000 }); + fallback._state.set('only-in-fallback', { from: 'fallback' }); + primary._state.set('only-in-primary', { from: 'primary' }); + primary._state.set('in-both', { from: 'primary' }); + fallback._state.set('in-both', { from: 'fallback' }); + + expect(await a.read('only-in-fallback')).toEqual({ from: 'fallback' }); + expect(await a.read('only-in-primary')).toEqual({ from: 'primary' }); + expect(await a.read('in-both')).toEqual({ from: 'primary' }); // primary wins + expect(await a.read('missing')).toBeNull(); + }); + + it('fires onMigration with size reason on threshold crossing', async () => { + const onMigration = vi.fn(); + const a = autoAdapter({ primary, fallback, thresholdBytes: 100, onMigration }); + await a.write('k', { __v: 1, values: { x: 'x'.repeat(500) } }); + expect(onMigration).toHaveBeenCalledWith('k', 'size'); + }); + + it('clear() invokes clear on both adapters', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000 }); + primary._state.set('k', 1); + fallback._state.set('k', 2); + await a.clear?.(); + expect(primary._state.size).toBe(0); + expect(fallback._state.size).toBe(0); + }); + + it('remove() surfaces underlying errors (no silent swallow)', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000 }); + primary._state.set('k', 1); + primary.remove = async () => { + throw new Error('primary remove broke'); + }; + await expect(a.remove('k')).rejects.toThrow('primary remove broke'); + }); + + it('clear() waits for in-flight per-key writes before clearing', async () => { + // Audit round-2 HIGH (N1): a write resolving AFTER clear() could + // re-populate the store. Verify clear blocks on pending queues. + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + // Slow down fallback.write so it resolves AFTER we begin clear() + let resolveGate: () => void; + const gate = new Promise((r) => { resolveGate = r; }); + fallback.writeImpl = async (key, value) => { + await gate; + fallback._state.set(key, value); + }; + const writeP = a.write('k', { __v: 1, values: { x: 'x'.repeat(500) } }); + // Yield so the write task enters its await on the gate before we clear + await Promise.resolve(); + const clearP = a.clear?.(); + // Release the write + resolveGate!(); + await Promise.all([writeP, clearP].filter(Boolean)); + // Store should be empty; write must not have outlived clear + expect(fallback._state.size).toBe(0); + expect(primary._state.size).toBe(0); + }); + + it('write fired DURING clear() lands AFTER the clear completes (not before)', async () => { + // Round-3 audit: with only the snapshot-and-wait pattern, a write + // submitted while clear() was awaiting allSettled would see empty + // queues, run immediately, and be wiped by adapter.clear(). Verify + // the clearing-barrier serializes the new write behind us. + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + + // Slow down adapter.clear so a write can sneak in during the wait. + let releaseClear: () => void; + const clearGate = new Promise((r) => { releaseClear = r; }); + primary.clear = async () => { + await clearGate; + primary._state.clear(); + }; + + const clearP = a.clear?.(); + // While clear() is suspended awaiting clearGate, fire a write. + await Promise.resolve(); + const writeP = a.write('after-clear', { __v: 1, values: { x: 'survives' } }); + // Allow the in-flight write to enter runSerialized + sit on `clearing`. + await Promise.resolve(); + // Release clear so it can finish. + releaseClear!(); + await Promise.all([clearP, writeP].filter(Boolean)); + // The write's data must survive the clear. + expect(await a.read('after-clear')).toEqual({ __v: 1, values: { x: 'survives' } }); + }); + + it('concurrent clear() calls chain (write between them survives both)', async () => { + // Round-4 audit: clear2 used to install a new `clearing` independent of + // clear1's. A write queued behind clear2 (already resolved fast-path) + // could land before clear1's still-pending adapter.clear wiped it. + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + + // Hold fallback.write so clear1's drain stalls. + let releaseWrite: () => void; + const writeGate = new Promise((r) => { releaseWrite = r; }); + fallback.writeImpl = async (key, value) => { + await writeGate; + fallback._state.set(key, value); + }; + + // Track when clear1's adapter.clear runs so we can prove it runs AFTER + // a subsequent write submitted between clear1 and clear2. + let clearCalls = 0; + const realPrimaryClear = primary.clear?.bind(primary); + primary.clear = async () => { + clearCalls++; + await realPrimaryClear?.(); + }; + + const slowWriteP = a.write('w0', { __v: 1, values: { x: 'x'.repeat(500) } }); + await Promise.resolve(); + + // clear1 starts; suspends waiting for w0 to drain. + const clear1P = a.clear?.(); + // clear2 follows immediately; must chain on clear1. + const clear2P = a.clear?.(); + // A write after both clears must land AFTER both complete. + const w1P = a.write('w1', { __v: 1, values: { x: 'survives' } }); + + // Release the original write so clear1 can drain. + releaseWrite!(); + await Promise.all([slowWriteP, clear1P, clear2P, w1P].filter(Boolean)); + + expect(clearCalls).toBe(2); // both clears called primary.clear + expect(await a.read('w1')).toEqual({ __v: 1, values: { x: 'survives' } }); + expect(await a.read('w0')).toBeNull(); // cleared + }); + + it('read() awaits in-flight clear so it cannot observe a mid-wipe state', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000 }); + await a.write('k', { value: 'before' }); + + // Slow primary.clear so a read mid-clear would see post-primary, pre-fallback state + let releaseClear: () => void; + const gate = new Promise((r) => { releaseClear = r; }); + primary.clear = async () => { + await gate; + primary._state.clear(); + }; + + const clearP = a.clear?.(); + // Read concurrently — must wait for clearing barrier + const readP = a.read('k'); + // Let clear proceed + releaseClear!(); + await Promise.all([clearP, readP].filter(Boolean)); + // Read must see post-clear state (null), not mid-wipe ghost + expect(await readP).toBeNull(); + }); + + it('best-effort cleanup: primary.remove failure does not block fallback write', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + primary._state.set('k', { stale: true }); + primary.remove = async () => { + throw new Error('primary remove broke'); + }; + const big = { __v: 1, values: { x: 'x'.repeat(500) } }; + // Should still complete and write to fallback + await expect(a.write('k', big)).resolves.toBeUndefined(); + expect(fallback._state.get('k')).toEqual(big); + }); + + it('serializes concurrent writes to the same key (no race-driven data loss)', async () => { + // Without per-key serialization, big+small interleaved writes can leave + // the store empty: big's fallback.write + small's primary.write succeed, + // then small's trailing fallback.remove deletes big's data AND big's + // trailing primary.remove deletes small's data. Both writes "succeeded" + // yet the read returns null. + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + const big = { __v: 1, values: { x: 'x'.repeat(500) } }; + const small = { __v: 1, values: { x: 'hi' } }; + // Fire both without awaiting + const p1 = a.write('k', big); + const p2 = a.write('k', small); + await Promise.all([p1, p2]); + // The LAST write to enter the queue wins — small. Verify state matches. + expect(await a.read('k')).toEqual(small); + expect(primary._state.has('k')).toBe(true); + expect(fallback._state.has('k')).toBe(false); + }); + + it('serializes write then remove (no orphan fallback entry)', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + const big = { __v: 1, values: { x: 'x'.repeat(500) } }; + const pWrite = a.write('k', big); + const pRemove = a.remove('k'); + await Promise.all([pWrite, pRemove]); + expect(await a.read('k')).toBeNull(); + expect(primary._state.has('k')).toBe(false); + expect(fallback._state.has('k')).toBe(false); + }); + + it('payload exactly at threshold goes to primary (not fallback)', async () => { + // Strictly-greater-than semantics: size === threshold stays on primary. + const value = { v: 'x'.repeat(20) }; + const serializedLen = JSON.stringify(value).length; + const a = autoAdapter({ primary, fallback, thresholdBytes: serializedLen }); + await a.write('k', value); + expect(primary._state.has('k')).toBe(true); + expect(fallback._state.has('k')).toBe(false); + }); + + it('rejects when primary and fallback are the same adapter instance', () => { + expect(() => autoAdapter({ primary, fallback: primary })).toThrow(/distinct adapters/); + }); + + it('circular references throw cleanly (no infinite loop)', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + const circ: Record = {}; + circ.self = circ; + await expect(a.write('k', circ)).rejects.toThrow(/circular|cyclic/i); + }); + + it('fallback.write failure surfaces the underlying error', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + fallback.writeImpl = async () => { + throw new Error('IDB private mode'); + }; + const big = { __v: 1, values: { x: 'x'.repeat(500) } }; + await expect(a.write('k', big)).rejects.toThrow('IDB private mode'); + // Primary stays untouched + expect(primary._state.has('k')).toBe(false); + }); + + it('partial-failure migration: stale primary returned until next write fixes it', async () => { + // Pin the documented trade-off: when fallback.write succeeds but + // primary.remove throws, read() returns the OLD primary value. + const a = autoAdapter({ primary, fallback, thresholdBytes: 100 }); + // First, put a small value into primary + await a.write('k', { __v: 1, values: { x: 'old' } }); + expect(primary._state.has('k')).toBe(true); + // Now simulate primary.remove failure on next migration + primary.remove = async () => { + throw new Error('primary remove broke'); + }; + await a.write('k', { __v: 1, values: { x: 'x'.repeat(500) } }); + // Read returns STALE primary value because primary.remove failed + expect(await a.read('k')).toEqual({ __v: 1, values: { x: 'old' } }); + }); + + it('does not match unrelated errors that happen to contain "quota" in message', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000 }); + // Custom adapter throws an error with "quota" in the message but with + // a different name and no code — should propagate, NOT migrate. + primary.writeImpl = async () => { + throw new TypeError('exceeded quota_test_var'); + }; + await expect(a.write('k', { foo: 1 })).rejects.toThrow('exceeded quota_test_var'); + expect(fallback._state.has('k')).toBe(false); + }); + + it('matches DOMException with code 22 (Safari quota) as quota', async () => { + const a = autoAdapter({ primary, fallback, thresholdBytes: 1_000_000 }); + primary.writeImpl = async () => { + const err = new Error('quota') as Error & { code: number }; + err.code = 22; + throw err; + }; + await a.write('k', { foo: 1 }); + expect(fallback._state.get('k')).toEqual({ foo: 1 }); + }); + + it('uses default localStorage primary + indexedDB fallback when no options', async () => { + // Smoke test that default construction works (delegates to real adapters) + const a = autoAdapter(); + expect(a.name).toBe('auto'); + expect(typeof a.read).toBe('function'); + expect(typeof a.write).toBe('function'); + expect(typeof a.remove).toBe('function'); + }); +}); diff --git a/src/storage/auto.ts b/src/storage/auto.ts new file mode 100644 index 0000000..08b3d76 --- /dev/null +++ b/src/storage/auto.ts @@ -0,0 +1,228 @@ +import type { StorageAdapter } from '../types'; +import { localStorageAdapter } from './localStorage'; +import { indexedDBAdapter } from './indexedDB'; + +export type AutoAdapterOptions = { + /** + * Threshold (computed from `JSON.stringify(value).length`, i.e. UTF-16 + * code units — NOT raw bytes) strictly above which writes are routed to + * `fallback` instead of `primary`. Default 1,000,000 — well under the + * lowest common localStorage quota (~5 MB on most browsers, ~2.5 MB on + * iOS Safari) leaving headroom for other libraries sharing the same + * origin AND for multi-byte chars where UTF-16 code units under-count + * actual bytes (Korean, emoji surrogate pairs). + */ + thresholdBytes?: number; + /** Adapter used for small payloads. Default `localStorageAdapter()`. */ + primary?: StorageAdapter; + /** Adapter used for large payloads. Default `indexedDBAdapter()`. */ + fallback?: StorageAdapter; + /** + * Fires once per migration so callers can surface this in dev tools, log + * to telemetry, etc. `reason` is `'size'` when crossing the threshold via + * `JSON.stringify` measurement, or `'quota'` when primary threw + * QuotaExceededError despite the size being under threshold (other code + * on the same origin filled localStorage first). + */ + onMigration?: (key: string, reason: 'size' | 'quota') => void; +}; + +const DEFAULT_THRESHOLD_BYTES = 1_000_000; + +function isQuotaError(e: unknown): boolean { + // localStorage quota errors are reported as DOMException with code 22 in + // most browsers, code 1014 in Firefox, name 'QuotaExceededError' (modern), + // 'NS_ERROR_DOM_QUOTA_REACHED' (older Firefox), or 'QUOTA_EXCEEDED_ERR' + // (older WebKit). Match by name + code only — a regex on `.message` would + // false-positive on unrelated errors that happen to contain "quota". + if (!(e instanceof Error)) return false; + if ( + e.name === 'QuotaExceededError' || + e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || + e.name === 'QUOTA_EXCEEDED_ERR' + ) { + return true; + } + const code = (e as Error & { code?: number }).code; + return code === 22 || code === 1014; +} + +/** + * Storage adapter that starts on `primary` (localStorage) and transparently + * migrates a key to `fallback` (IndexedDB) once its serialized size exceeds + * `thresholdBytes`, or when primary throws QuotaExceededError. Cleans up the + * stale location on every write so reads stay consistent without needing a + * sentinel. + * + * useFormDraft({ ..., storage: autoAdapter() }); + * useFormDraft({ ..., storage: autoAdapter({ thresholdBytes: 500_000 }) }); + */ +export function autoAdapter(opts: AutoAdapterOptions = {}): StorageAdapter { + const thresholdBytes = opts.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES; + const primary = opts.primary ?? localStorageAdapter(); + const fallback = opts.fallback ?? indexedDBAdapter(); + const onMigration = opts.onMigration; + + if (primary === fallback) { + throw new Error( + '[formdraft] autoAdapter: primary and fallback must be distinct adapters (a large-write would `fallback.write` then `primary.remove` against the same store, deleting the data).', + ); + } + + // Per-key serialization queue. Two concurrent write/remove operations to + // the same key would otherwise interleave the four sub-operations + // (write+remove on primary, write+remove on fallback) and could leave the + // key in an inconsistent state — or empty, the worst case. Each op chains + // onto the previous op's settle so the same-key ordering is sequential. + const queues: Map> = new Map(); + + // Global barrier for `clear()`. A clear in progress must block all new + // write/remove tasks until the underlying adapter.clear() calls complete, + // otherwise a write started during clear's allSettled wait could land its + // data AFTER adapter.clear wipes the store. + let clearing: Promise = Promise.resolve(); + + const runSerialized = (key: string, task: () => Promise): Promise => { + const prev = queues.get(key) ?? Promise.resolve(); + // Capture both gates at call time. Once a clear is in flight, all new + // tasks chain onto its completion before running. + const gate = clearing; + const next = Promise.all([ + gate.catch(() => undefined), + prev.catch(() => undefined), + ]).then(task); + queues.set(key, next); + // Detach completed tasks so the map doesn't grow forever. The catch + // attached here only handles the bookkeeping branch — the rejection + // still flows to the caller via the returned `next`. + next.finally(() => { + if (queues.get(key) === next) queues.delete(key); + }).catch(() => undefined); + return next; + }; + + const writeToFallback = async (key: string, value: unknown, reason: 'size' | 'quota'): Promise => { + // Write fallback FIRST, then clean primary. If fallback rejects on a + // re-migration, primary still holds the prior (smaller) value — the + // caller sees the rejection and can retry. On first-ever large write, + // primary was empty, so a fallback rejection cleanly surfaces with + // nothing to roll back. + await fallback.write(key, value); + let primaryCleared = true; + try { + await primary.remove(key); + } catch { + // Best-effort cleanup. Stale primary will be overwritten on next + // small-write or removed on explicit remove(). Until then, read() + // returns the stale value — documented trade-off (see README). + primaryCleared = false; + } + // Only fire onMigration when the move is fully consistent (data in + // fallback, primary cleaned). Otherwise the callback would report + // "migrated" while reads still see the old primary value. + if (primaryCleared) onMigration?.(key, reason); + }; + + return { + name: 'auto', + async read(key) { + // Respect the clearing barrier so a read STARTED after clear() sees + // post-clear state, not mid-wipe garbage (primary cleared, fallback + // not yet). A read started BEFORE a concurrent clear() may still + // resolve with the pre-clear value — concurrent read+clear ordering + // is undefined; sequence them in caller code if you need strict + // happens-before guarantees. + await clearing.catch(() => undefined); + // Primary first (cheap, synchronous localStorage). Falls back to IDB + // only when primary is empty, which is the common case after migration. + // Stale-primary trade-off: if a partial-failure migration left primary + // with old data (fallback.write succeeded, primary.remove threw), or + // if a concurrent write is mid-migration, this returns the primary + // value until the migration's primary.remove completes. + const fromPrimary = await primary.read(key); + if (fromPrimary !== null && fromPrimary !== undefined) return fromPrimary; + return fallback.read(key); + }, + write(key, value) { + return runSerialized(key, async () => { + // JSON.stringify returns undefined for `undefined`, function, or + // Symbol top-level values — those can't be persisted at all. Surface + // a clear error instead of letting `.length` throw a cryptic TypeError. + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new TypeError( + '[formdraft] autoAdapter.write: value is not JSON-serializable (undefined / function / top-level Symbol)', + ); + } + const size = serialized.length; + + if (size > thresholdBytes) { + await writeToFallback(key, value, 'size'); + return; + } + + // Under threshold — try primary. If quota error (other libs on the + // origin filled localStorage), migrate to fallback as a recovery path. + try { + await primary.write(key, value); + } catch (e) { + if (isQuotaError(e)) { + await writeToFallback(key, value, 'quota'); + return; + } + throw e; + } + // Successful primary write — clean any stale fallback data so reads + // stay consistent. Best-effort; fallback may not have an entry. + try { + await fallback.remove(key); + } catch { + // ignore — read() prefers primary so stale fallback won't be returned + } + }); + }, + remove(key) { + return runSerialized(key, async () => { + // Remove from both — value could be in either after past migrations. + // Surface the first failure so the caller can detect that the data + // wasn't actually cleared. Don't swallow errors silently; if the + // user wants best-effort, they can `.catch()` themselves. + const results = await Promise.allSettled([ + primary.remove(key), + fallback.remove(key), + ]); + const failed = results.find( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ); + if (failed) throw failed.reason; + }); + }, + clear() { + // Snapshot in-flight ops + empty the queue map SYNCHRONOUSLY (before + // any await). Tasks queued AFTER this point chain on `clearing` and + // run after the clear completes — which is correct. + // Capturing pending inside the async IIFE (after `await prev`) would + // include later tasks that are themselves waiting on this clear, + // creating a deadlock (clear awaits write awaits clear). + const prev = clearing; + const pending = Array.from(queues.values()); + queues.clear(); + const op = (async () => { + // Serialize against any prior clear so the older clear can't run + // adapter.clear() AFTER a write that chained on the newer clear. + await prev.catch(() => undefined); + await Promise.allSettled(pending); + const tasks: Array> = []; + if (primary.clear) tasks.push(primary.clear()); + if (fallback.clear) tasks.push(fallback.clear()); + const results = await Promise.allSettled(tasks); + const failed = results.find( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ); + if (failed) throw failed.reason; + })(); + clearing = op.catch(() => undefined); + return op; + }, + }; +}