From c02ed22fea09c5912438e28d8a907a98db0cfe98 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:18:02 +0000 Subject: [PATCH] refactor(expo): extract client storage module (#11221) --- packages/expo/src/client-storage.ts | 555 ++++++++++++ packages/expo/src/client.ts | 559 +------------ packages/expo/test/client-storage.test.ts | 971 +++++++++++++++++++++ packages/expo/test/expo.test.ts | 974 +--------------------- 4 files changed, 1539 insertions(+), 1520 deletions(-) create mode 100644 packages/expo/src/client-storage.ts create mode 100644 packages/expo/test/client-storage.test.ts diff --git a/packages/expo/src/client-storage.ts b/packages/expo/src/client-storage.ts new file mode 100644 index 0000000000..53589ab2b0 --- /dev/null +++ b/packages/expo/src/client-storage.ts @@ -0,0 +1,555 @@ +import { logger } from "@better-auth/core/env"; +import type * as SecureStore from "expo-secure-store"; + +/** + * Storage used by the Expo client for cookies and cached session data. + * Async access is coordinated through the provided object, so reuse it across + * clients that access the same stored data. + */ +export type ExpoClientStorage = Pick< + typeof SecureStore, + "setItem" | "setItemAsync" | "getItem" | "getItemAsync" +>; + +/** + * Expo secure store does not support colons in the keys. + * This function replaces colons with underscores. + * + * @see https://github.com/better-auth/better-auth/issues/5426 + * + * @param name cookie name to be saved in the storage + * @returns normalized cookie name + */ +export function normalizeCookieName(name: string) { + return name.replace(/:/g, "_"); +} + +/** + * Character budget per stored chunk. Some native stores reject large values, + * so larger strings are split across keys. This is not a byte-size guarantee. + * + * @see https://github.com/better-auth/better-auth/issues/9151 + */ +const STORAGE_VALUE_LIMIT = 1800; +const MAX_STORAGE_CHUNKS = 100; + +/** + * Marks a base key whose value is split across multiple storage keys. Legacy + * markers contain only the chunk count. Current markers also identify the + * active slot and retain the previous slot's chunk count for recovery. + * + * @see https://github.com/better-auth/better-auth/issues/11082 + */ +const CHUNK_MARKER = "\u0001ba-chunks:"; + +type ChunkSlot = 0 | 1; +const CHUNK_SLOTS = [null, 0, 1] as const; + +interface ChunkMarker { + count: number; + slot: ChunkSlot | null; + fallbackCount: number | null; +} + +function parseChunkCount(value: string | undefined): number | null { + if (value === undefined) { + return null; + } + const count = Number(value); + if (!Number.isInteger(count) || count < 1 || count > MAX_STORAGE_CHUNKS) { + return null; + } + return count; +} + +function parseChunkMarker(baseValue: string): ChunkMarker | null { + const parts = baseValue.slice(CHUNK_MARKER.length).split(":"); + if (parts.length > 3) { + return null; + } + const [countValue, slotValue, fallbackCountValue] = parts; + const count = parseChunkCount(countValue); + if (count === null) { + return null; + } + if (slotValue === undefined) { + return fallbackCountValue === undefined + ? { count, slot: null, fallbackCount: null } + : null; + } + if (slotValue !== "0" && slotValue !== "1") { + return null; + } + const fallbackCount = parseChunkCount(fallbackCountValue); + if (fallbackCountValue !== undefined && fallbackCount === null) { + return null; + } + return { + count, + slot: slotValue === "0" ? 0 : 1, + fallbackCount, + }; +} + +function getChunkKey(key: string, marker: ChunkMarker, index: number) { + return marker.slot === null + ? `${key}.${index}` + : `${key}.${marker.slot}.${index}`; +} + +function getOtherSlot(slot: ChunkSlot): ChunkSlot { + return slot === 0 ? 1 : 0; +} + +function serializeChunkMarker(marker: ChunkMarker) { + if (marker.slot === null) { + return `${CHUNK_MARKER}${marker.count}`; + } + const fallback = + marker.fallbackCount === null ? "" : `:${marker.fallbackCount}`; + return `${CHUNK_MARKER}${marker.count}:${marker.slot}${fallback}`; +} + +function readChunks( + storage: Pick, + key: string, + marker: ChunkMarker, +): string | null { + let value = ""; + for (let i = 0; i < marker.count; i++) { + const chunk = storage.getItem(getChunkKey(key, marker, i)); + if (!chunk) { + return null; + } + value += chunk; + } + return value; +} + +async function readChunksAsync( + storage: Pick, + key: string, + marker: ChunkMarker, +): Promise { + let value = ""; + for (let i = 0; i < marker.count; i++) { + const chunk = await storage.getItemAsync(getChunkKey(key, marker, i)); + if (!chunk) { + return null; + } + value += chunk; + } + return value; +} + +function readStoredValue( + storage: Pick, + key: string, + baseValue: string | null, +): string | null { + if (baseValue == null || !baseValue.startsWith(CHUNK_MARKER)) { + return baseValue; + } + const marker = parseChunkMarker(baseValue); + if (!marker) { + return null; + } + const value = readChunks(storage, key, marker); + if (value !== null || marker.slot === null || marker.fallbackCount === null) { + return value; + } + return readChunks(storage, key, { + count: marker.fallbackCount, + slot: getOtherSlot(marker.slot), + fallbackCount: null, + }); +} + +async function readStoredValueAsync( + storage: Pick, + key: string, + baseValue: string | null, +): Promise { + if (baseValue == null || !baseValue.startsWith(CHUNK_MARKER)) { + return baseValue; + } + const marker = parseChunkMarker(baseValue); + if (!marker) { + return null; + } + const value = await readChunksAsync(storage, key, marker); + if (value !== null || marker.slot === null || marker.fallbackCount === null) { + return value; + } + return readChunksAsync(storage, key, { + count: marker.fallbackCount, + slot: getOtherSlot(marker.slot), + fallbackCount: null, + }); +} + +interface ChunkCleanupRange { + prefix: string; + start: number; + end: number; +} + +function getStorageWritePlan( + key: string, + value: string, + currentBaseValue: string | null, +): { writes: [key: string, value: string][]; cleanup: ChunkCleanupRange[] } { + const currentMarker = currentBaseValue?.startsWith(CHUNK_MARKER) + ? parseChunkMarker(currentBaseValue) + : null; + if (value.length <= STORAGE_VALUE_LIMIT) { + return { + writes: [[key, value]], + cleanup: getUnusedChunkRanges(key, currentMarker, null), + }; + } + + const count = Math.ceil(value.length / STORAGE_VALUE_LIMIT); + if (count > MAX_STORAGE_CHUNKS) { + throw new Error( + `Storage value requires ${count} chunks, exceeding the limit of ${MAX_STORAGE_CHUNKS}`, + ); + } + const slot: ChunkSlot = currentMarker?.slot === 0 ? 1 : 0; + const marker: ChunkMarker = { + count, + slot, + fallbackCount: currentMarker?.slot == null ? null : currentMarker.count, + }; + const writes: [string, string][] = []; + if (currentMarker?.slot != null && currentMarker.fallbackCount !== null) { + // The fallback slot becomes the next write target. + // Stop readers from using it until the new value is complete. + writes.push([ + key, + serializeChunkMarker({ ...currentMarker, fallbackCount: null }), + ]); + } + for (let i = 0; i < count; i++) { + const start = i * STORAGE_VALUE_LIMIT; + writes.push([ + getChunkKey(key, marker, i), + value.slice(start, start + STORAGE_VALUE_LIMIT), + ]); + } + writes.push([key, serializeChunkMarker(marker)]); + return { writes, cleanup: getUnusedChunkRanges(key, currentMarker, marker) }; +} + +function getSlotChunkCount(marker: ChunkMarker | null, slot: ChunkSlot | null) { + if (!marker) return 0; + if (marker.slot === slot) return marker.count; + if (marker.slot === null || slot === null) return 0; + return marker.fallbackCount ?? 0; +} + +function getUnusedChunkRanges( + key: string, + previousMarker: ChunkMarker | null, + marker: ChunkMarker | null, +): ChunkCleanupRange[] { + return CHUNK_SLOTS.map((slot) => ({ + prefix: slot === null ? key : `${key}.${slot}`, + start: getSlotChunkCount(marker, slot), + end: getSlotChunkCount(previousMarker, slot), + })); +} + +interface StorageRead { + snapshot: { value: string | null } | null; +} + +interface StorageKeyState { + pending: Promise | null; + pendingWrites: number; + activeRead: StorageRead | null; + cleanupComplete: boolean; +} + +const storageStates = new WeakMap< + ExpoClientStorage, + Map +>(); + +function getStorageState(storage: ExpoClientStorage, key: string) { + let states = storageStates.get(storage); + if (!states) { + states = new Map(); + storageStates.set(storage, states); + } + let state = states.get(key); + if (!state) { + state = { + pending: null, + pendingWrites: 0, + activeRead: null, + cleanupComplete: false, + }; + states.set(key, state); + } + return state; +} + +function enqueueStorageOperation( + state: StorageKeyState, + operation: () => Promise, +): Promise { + const previous = state.pending ?? Promise.resolve(); + const queued = previous.then(operation, operation); + state.pending = queued; + + const cleanup = () => { + if (state.pending === queued) state.pending = null; + }; + void queued.then(cleanup, cleanup); + return queued; +} + +function enqueueStorageWrite( + state: StorageKeyState, + operation: () => Promise, +): Promise { + state.pendingWrites++; + return enqueueStorageOperation(state, async () => { + try { + return await operation(); + } finally { + state.pendingWrites--; + } + }); +} + +interface ExpoStorageAdapter { + getItem(name: string): string | null; + getItemAsync(name: string): Promise; + setItem(name: string, value: string): void; + setItemAsync(name: string, value: string): Promise; +} + +interface StoredUpdate { + previousValue: string | null; + value: string; +} + +/** @internal */ +export function createManagedStorage(storage: ExpoClientStorage) { + const logWriteError = (key: string, error: unknown) => { + logger.error( + `[better-auth/expo] failed to persist "${key}" to storage`, + error, + ); + }; + const logCleanupError = (key: string, error: unknown) => { + logger.error( + `[better-auth/expo] failed to clear unused chunks for "${key}"`, + error, + ); + }; + const getItem = (name: string): string | null => { + const key = normalizeCookieName(name); + return readStoredValue(storage, key, storage.getItem(key)); + }; + const getItemAsync = (name: string): Promise => { + const key = normalizeCookieName(name); + const state = getStorageState(storage, key); + return enqueueStorageOperation(state, async () => { + const read: StorageRead = { snapshot: null }; + state.activeRead = read; + try { + const baseValue = await storage.getItemAsync(key); + const value = await readStoredValueAsync(storage, key, baseValue); + return read.snapshot ? read.snapshot.value : value; + } finally { + state.activeRead = null; + } + }); + }; + const writeItem = ( + key: string, + value: string, + currentBaseValue: string | null, + ) => { + const state = getStorageState(storage, key); + const { writes, cleanup } = getStorageWritePlan( + key, + value, + currentBaseValue, + ); + for (const [writeKey, writeValue] of writes) { + storage.setItem(writeKey, writeValue); + } + try { + const scanContiguousOrphans = !state.cleanupComplete; + for (const { prefix, start, end } of cleanup) { + if (scanContiguousOrphans) { + const orphanStart = Math.max(start, end); + let orphanEnd = orphanStart; + for (; orphanEnd < MAX_STORAGE_CHUNKS; orphanEnd++) { + if (!storage.getItem(`${prefix}.${orphanEnd}`)) break; + } + for (let i = orphanEnd - 1; i >= orphanStart; i--) { + storage.setItem(`${prefix}.${i}`, ""); + } + } + for (let i = end - 1; i >= start; i--) { + storage.setItem(`${prefix}.${i}`, ""); + } + } + state.cleanupComplete = true; + } catch (error) { + state.cleanupComplete = false; + logCleanupError(key, error); + } + }; + const writeItemAsync = async ( + key: string, + value: string, + currentBaseValue: string | null, + ) => { + const state = getStorageState(storage, key); + const { writes, cleanup } = getStorageWritePlan( + key, + value, + currentBaseValue, + ); + for (const [writeKey, writeValue] of writes) { + await storage.setItemAsync(writeKey, writeValue); + } + try { + const scanContiguousOrphans = !state.cleanupComplete; + for (const { prefix, start, end } of cleanup) { + if (scanContiguousOrphans) { + const orphanStart = Math.max(start, end); + let orphanEnd = orphanStart; + for (; orphanEnd < MAX_STORAGE_CHUNKS; orphanEnd++) { + const chunk = await storage.getItemAsync(`${prefix}.${orphanEnd}`); + if (!chunk) break; + } + for (let i = orphanEnd - 1; i >= orphanStart; i--) { + await storage.setItemAsync(`${prefix}.${i}`, ""); + } + } + for (let i = end - 1; i >= start; i--) { + await storage.setItemAsync(`${prefix}.${i}`, ""); + } + } + state.cleanupComplete = true; + } catch (error) { + state.cleanupComplete = false; + logCleanupError(key, error); + } + }; + const setItem = (name: string, value: string): void => { + const key = normalizeCookieName(name); + const state = getStorageState(storage, key); + if (state.pendingWrites > 0) { + logWriteError( + key, + new Error("Cannot write synchronously while an async write is pending"), + ); + return; + } + + let currentBaseValue: string | null = null; + try { + currentBaseValue = storage.getItem(key); + } catch (error) { + state.cleanupComplete = false; + if (value.length > STORAGE_VALUE_LIMIT) { + logWriteError(key, error); + return; + } + } + + const read = state.activeRead; + if (read && read.snapshot === null) { + // Preserve the reader's value before its chunks can be overwritten. + try { + read.snapshot = { + value: readStoredValue(storage, key, currentBaseValue), + }; + } catch { + read.snapshot = { value: null }; + } + } + + try { + writeItem(key, value, currentBaseValue); + } catch (error) { + state.cleanupComplete = false; + logWriteError(key, error); + return; + } + if (read) { + read.snapshot = { value }; + } + }; + const setItemAsync = (name: string, value: string): Promise => { + const key = normalizeCookieName(name); + const state = getStorageState(storage, key); + return enqueueStorageWrite(state, async () => { + let currentBaseValue: string | null = null; + try { + currentBaseValue = await storage.getItemAsync(key); + } catch (error) { + state.cleanupComplete = false; + if (value.length > STORAGE_VALUE_LIMIT) { + logWriteError(key, error); + return; + } + } + + try { + await writeItemAsync(key, value, currentBaseValue); + } catch (error) { + state.cleanupComplete = false; + logWriteError(key, error); + } + }); + }; + const updateItemAsync = ( + name: string, + update: (currentValue: string | null) => string, + ): Promise => { + const key = normalizeCookieName(name); + const state = getStorageState(storage, key); + return enqueueStorageWrite(state, async () => { + try { + const currentBaseValue = await storage.getItemAsync(key); + const previousValue = await readStoredValueAsync( + storage, + key, + currentBaseValue, + ); + const value = update(previousValue); + await writeItemAsync(key, value, currentBaseValue); + return { previousValue, value }; + } catch (error) { + state.cleanupComplete = false; + logWriteError(key, error); + return null; + } + }); + }; + + return { getItem, getItemAsync, setItem, setItemAsync, updateItemAsync }; +} + +/** + * Wraps Expo storage with chunking, recoverable writes, and serialized async + * access. + */ +export function storageAdapter(storage: ExpoClientStorage): ExpoStorageAdapter { + const managedStorage = createManagedStorage(storage); + return { + getItem: managedStorage.getItem, + getItemAsync: managedStorage.getItemAsync, + setItem: managedStorage.setItem, + setItemAsync: managedStorage.setItemAsync, + }; +} diff --git a/packages/expo/src/client.ts b/packages/expo/src/client.ts index 921cc4399e..b8693cc293 100644 --- a/packages/expo/src/client.ts +++ b/packages/expo/src/client.ts @@ -4,7 +4,6 @@ import type { ClientStore, } from "@better-auth/core"; import type { Session, User } from "@better-auth/core/db"; -import { logger } from "@better-auth/core/env"; import { safeJSONParse } from "@better-auth/core/utils/json"; import { parseSetCookieHeader, @@ -13,27 +12,21 @@ import { } from "better-auth/cookies/utils"; import Constants from "expo-constants"; import * as Linking from "expo-linking"; -import type * as SecureStore from "expo-secure-store"; import { Platform } from "react-native"; +import type { ExpoClientStorage } from "./client-storage"; +import { createManagedStorage } from "./client-storage"; import { setupExpoFocusManager } from "./focus-manager"; import { setupExpoOnlineManager } from "./online-manager"; import { PACKAGE_VERSION } from "./version"; +export type { ExpoClientStorage } from "./client-storage"; +export { normalizeCookieName, storageAdapter } from "./client-storage"; + if (Platform.OS !== "web") { setupExpoFocusManager(); setupExpoOnlineManager(); } -/** - * Storage used by the Expo client for cookies and cached session data. - * Async access is coordinated through the provided object, so reuse it across - * clients that access the same stored data. - */ -export type ExpoClientStorage = Pick< - typeof SecureStore, - "setItem" | "setItemAsync" | "getItem" | "getItemAsync" ->; - interface ExpoClientOptions { scheme?: string | undefined; storage: ExpoClientStorage; @@ -253,548 +246,6 @@ export function hasBetterAuthCookies( return false; } -/** - * Expo secure store does not support colons in the keys. - * This function replaces colons with underscores. - * - * @see https://github.com/better-auth/better-auth/issues/5426 - * - * @param name cookie name to be saved in the storage - * @returns normalized cookie name - */ -export function normalizeCookieName(name: string) { - return name.replace(/:/g, "_"); -} - -/** - * Character budget per stored chunk. Some native stores reject large values, - * so larger strings are split across keys. This is not a byte-size guarantee. - * - * @see https://github.com/better-auth/better-auth/issues/9151 - */ -const STORAGE_VALUE_LIMIT = 1800; -const MAX_STORAGE_CHUNKS = 100; - -/** - * Marks a base key whose value is split across multiple storage keys. Legacy - * markers contain only the chunk count. Current markers also identify the - * active slot and retain the previous slot's chunk count for recovery. - * - * @see https://github.com/better-auth/better-auth/issues/11082 - */ -const CHUNK_MARKER = "\u0001ba-chunks:"; - -type ChunkSlot = 0 | 1; -const CHUNK_SLOTS = [null, 0, 1] as const; - -interface ChunkMarker { - count: number; - slot: ChunkSlot | null; - fallbackCount: number | null; -} - -function parseChunkCount(value: string | undefined): number | null { - if (value === undefined) { - return null; - } - const count = Number(value); - if (!Number.isInteger(count) || count < 1 || count > MAX_STORAGE_CHUNKS) { - return null; - } - return count; -} - -function parseChunkMarker(baseValue: string): ChunkMarker | null { - const parts = baseValue.slice(CHUNK_MARKER.length).split(":"); - if (parts.length > 3) { - return null; - } - const [countValue, slotValue, fallbackCountValue] = parts; - const count = parseChunkCount(countValue); - if (count === null) { - return null; - } - if (slotValue === undefined) { - return fallbackCountValue === undefined - ? { count, slot: null, fallbackCount: null } - : null; - } - if (slotValue !== "0" && slotValue !== "1") { - return null; - } - const fallbackCount = parseChunkCount(fallbackCountValue); - if (fallbackCountValue !== undefined && fallbackCount === null) { - return null; - } - return { - count, - slot: slotValue === "0" ? 0 : 1, - fallbackCount, - }; -} - -function getChunkKey(key: string, marker: ChunkMarker, index: number) { - return marker.slot === null - ? `${key}.${index}` - : `${key}.${marker.slot}.${index}`; -} - -function getOtherSlot(slot: ChunkSlot): ChunkSlot { - return slot === 0 ? 1 : 0; -} - -function serializeChunkMarker(marker: ChunkMarker) { - if (marker.slot === null) { - return `${CHUNK_MARKER}${marker.count}`; - } - const fallback = - marker.fallbackCount === null ? "" : `:${marker.fallbackCount}`; - return `${CHUNK_MARKER}${marker.count}:${marker.slot}${fallback}`; -} - -function readChunks( - storage: Pick, - key: string, - marker: ChunkMarker, -): string | null { - let value = ""; - for (let i = 0; i < marker.count; i++) { - const chunk = storage.getItem(getChunkKey(key, marker, i)); - if (!chunk) { - return null; - } - value += chunk; - } - return value; -} - -async function readChunksAsync( - storage: Pick, - key: string, - marker: ChunkMarker, -): Promise { - let value = ""; - for (let i = 0; i < marker.count; i++) { - const chunk = await storage.getItemAsync(getChunkKey(key, marker, i)); - if (!chunk) { - return null; - } - value += chunk; - } - return value; -} - -function readStoredValue( - storage: Pick, - key: string, - baseValue: string | null, -): string | null { - if (baseValue == null || !baseValue.startsWith(CHUNK_MARKER)) { - return baseValue; - } - const marker = parseChunkMarker(baseValue); - if (!marker) { - return null; - } - const value = readChunks(storage, key, marker); - if (value !== null || marker.slot === null || marker.fallbackCount === null) { - return value; - } - return readChunks(storage, key, { - count: marker.fallbackCount, - slot: getOtherSlot(marker.slot), - fallbackCount: null, - }); -} - -async function readStoredValueAsync( - storage: Pick, - key: string, - baseValue: string | null, -): Promise { - if (baseValue == null || !baseValue.startsWith(CHUNK_MARKER)) { - return baseValue; - } - const marker = parseChunkMarker(baseValue); - if (!marker) { - return null; - } - const value = await readChunksAsync(storage, key, marker); - if (value !== null || marker.slot === null || marker.fallbackCount === null) { - return value; - } - return readChunksAsync(storage, key, { - count: marker.fallbackCount, - slot: getOtherSlot(marker.slot), - fallbackCount: null, - }); -} - -interface ChunkCleanupRange { - prefix: string; - start: number; - end: number; -} - -function getStorageWritePlan( - key: string, - value: string, - currentBaseValue: string | null, -): { writes: [key: string, value: string][]; cleanup: ChunkCleanupRange[] } { - const currentMarker = currentBaseValue?.startsWith(CHUNK_MARKER) - ? parseChunkMarker(currentBaseValue) - : null; - if (value.length <= STORAGE_VALUE_LIMIT) { - return { - writes: [[key, value]], - cleanup: getUnusedChunkRanges(key, currentMarker, null), - }; - } - - const count = Math.ceil(value.length / STORAGE_VALUE_LIMIT); - if (count > MAX_STORAGE_CHUNKS) { - throw new Error( - `Storage value requires ${count} chunks, exceeding the limit of ${MAX_STORAGE_CHUNKS}`, - ); - } - const slot: ChunkSlot = currentMarker?.slot === 0 ? 1 : 0; - const marker: ChunkMarker = { - count, - slot, - fallbackCount: currentMarker?.slot == null ? null : currentMarker.count, - }; - const writes: [string, string][] = []; - if (currentMarker?.slot != null && currentMarker.fallbackCount !== null) { - // The fallback slot becomes the next write target. - // Stop readers from using it until the new value is complete. - writes.push([ - key, - serializeChunkMarker({ ...currentMarker, fallbackCount: null }), - ]); - } - for (let i = 0; i < count; i++) { - const start = i * STORAGE_VALUE_LIMIT; - writes.push([ - getChunkKey(key, marker, i), - value.slice(start, start + STORAGE_VALUE_LIMIT), - ]); - } - writes.push([key, serializeChunkMarker(marker)]); - return { writes, cleanup: getUnusedChunkRanges(key, currentMarker, marker) }; -} - -function getSlotChunkCount(marker: ChunkMarker | null, slot: ChunkSlot | null) { - if (!marker) return 0; - if (marker.slot === slot) return marker.count; - if (marker.slot === null || slot === null) return 0; - return marker.fallbackCount ?? 0; -} - -function getUnusedChunkRanges( - key: string, - previousMarker: ChunkMarker | null, - marker: ChunkMarker | null, -): ChunkCleanupRange[] { - return CHUNK_SLOTS.map((slot) => ({ - prefix: slot === null ? key : `${key}.${slot}`, - start: getSlotChunkCount(marker, slot), - end: getSlotChunkCount(previousMarker, slot), - })); -} - -interface StorageRead { - snapshot: { value: string | null } | null; -} - -interface StorageKeyState { - pending: Promise | null; - pendingWrites: number; - activeRead: StorageRead | null; - cleanupComplete: boolean; -} - -const storageStates = new WeakMap< - ExpoClientStorage, - Map ->(); - -function getStorageState(storage: ExpoClientStorage, key: string) { - let states = storageStates.get(storage); - if (!states) { - states = new Map(); - storageStates.set(storage, states); - } - let state = states.get(key); - if (!state) { - state = { - pending: null, - pendingWrites: 0, - activeRead: null, - cleanupComplete: false, - }; - states.set(key, state); - } - return state; -} - -function enqueueStorageOperation( - state: StorageKeyState, - operation: () => Promise, -): Promise { - const previous = state.pending ?? Promise.resolve(); - const queued = previous.then(operation, operation); - state.pending = queued; - - const cleanup = () => { - if (state.pending === queued) state.pending = null; - }; - void queued.then(cleanup, cleanup); - return queued; -} - -function enqueueStorageWrite( - state: StorageKeyState, - operation: () => Promise, -): Promise { - state.pendingWrites++; - return enqueueStorageOperation(state, async () => { - try { - return await operation(); - } finally { - state.pendingWrites--; - } - }); -} - -interface ExpoStorageAdapter { - getItem(name: string): string | null; - getItemAsync(name: string): Promise; - setItem(name: string, value: string): void; - setItemAsync(name: string, value: string): Promise; -} - -interface StoredUpdate { - previousValue: string | null; - value: string; -} - -function createManagedStorage(storage: ExpoClientStorage) { - const logWriteError = (key: string, error: unknown) => { - logger.error( - `[better-auth/expo] failed to persist "${key}" to storage`, - error, - ); - }; - const logCleanupError = (key: string, error: unknown) => { - logger.error( - `[better-auth/expo] failed to clear unused chunks for "${key}"`, - error, - ); - }; - const getItem = (name: string): string | null => { - const key = normalizeCookieName(name); - return readStoredValue(storage, key, storage.getItem(key)); - }; - const getItemAsync = (name: string): Promise => { - const key = normalizeCookieName(name); - const state = getStorageState(storage, key); - return enqueueStorageOperation(state, async () => { - const read: StorageRead = { snapshot: null }; - state.activeRead = read; - try { - const baseValue = await storage.getItemAsync(key); - const value = await readStoredValueAsync(storage, key, baseValue); - return read.snapshot ? read.snapshot.value : value; - } finally { - state.activeRead = null; - } - }); - }; - const writeItem = ( - key: string, - value: string, - currentBaseValue: string | null, - ) => { - const state = getStorageState(storage, key); - const { writes, cleanup } = getStorageWritePlan( - key, - value, - currentBaseValue, - ); - for (const [writeKey, writeValue] of writes) { - storage.setItem(writeKey, writeValue); - } - try { - const scanContiguousOrphans = !state.cleanupComplete; - for (const { prefix, start, end } of cleanup) { - if (scanContiguousOrphans) { - const orphanStart = Math.max(start, end); - let orphanEnd = orphanStart; - for (; orphanEnd < MAX_STORAGE_CHUNKS; orphanEnd++) { - if (!storage.getItem(`${prefix}.${orphanEnd}`)) break; - } - for (let i = orphanEnd - 1; i >= orphanStart; i--) { - storage.setItem(`${prefix}.${i}`, ""); - } - } - for (let i = end - 1; i >= start; i--) { - storage.setItem(`${prefix}.${i}`, ""); - } - } - state.cleanupComplete = true; - } catch (error) { - state.cleanupComplete = false; - logCleanupError(key, error); - } - }; - const writeItemAsync = async ( - key: string, - value: string, - currentBaseValue: string | null, - ) => { - const state = getStorageState(storage, key); - const { writes, cleanup } = getStorageWritePlan( - key, - value, - currentBaseValue, - ); - for (const [writeKey, writeValue] of writes) { - await storage.setItemAsync(writeKey, writeValue); - } - try { - const scanContiguousOrphans = !state.cleanupComplete; - for (const { prefix, start, end } of cleanup) { - if (scanContiguousOrphans) { - const orphanStart = Math.max(start, end); - let orphanEnd = orphanStart; - for (; orphanEnd < MAX_STORAGE_CHUNKS; orphanEnd++) { - const chunk = await storage.getItemAsync(`${prefix}.${orphanEnd}`); - if (!chunk) break; - } - for (let i = orphanEnd - 1; i >= orphanStart; i--) { - await storage.setItemAsync(`${prefix}.${i}`, ""); - } - } - for (let i = end - 1; i >= start; i--) { - await storage.setItemAsync(`${prefix}.${i}`, ""); - } - } - state.cleanupComplete = true; - } catch (error) { - state.cleanupComplete = false; - logCleanupError(key, error); - } - }; - const setItem = (name: string, value: string): void => { - const key = normalizeCookieName(name); - const state = getStorageState(storage, key); - if (state.pendingWrites > 0) { - logWriteError( - key, - new Error("Cannot write synchronously while an async write is pending"), - ); - return; - } - - let currentBaseValue: string | null = null; - try { - currentBaseValue = storage.getItem(key); - } catch (error) { - state.cleanupComplete = false; - if (value.length > STORAGE_VALUE_LIMIT) { - logWriteError(key, error); - return; - } - } - - const read = state.activeRead; - if (read && read.snapshot === null) { - // Preserve the reader's value before its chunks can be overwritten. - try { - read.snapshot = { - value: readStoredValue(storage, key, currentBaseValue), - }; - } catch { - read.snapshot = { value: null }; - } - } - - try { - writeItem(key, value, currentBaseValue); - } catch (error) { - state.cleanupComplete = false; - logWriteError(key, error); - return; - } - if (read) { - read.snapshot = { value }; - } - }; - const setItemAsync = (name: string, value: string): Promise => { - const key = normalizeCookieName(name); - const state = getStorageState(storage, key); - return enqueueStorageWrite(state, async () => { - let currentBaseValue: string | null = null; - try { - currentBaseValue = await storage.getItemAsync(key); - } catch (error) { - state.cleanupComplete = false; - if (value.length > STORAGE_VALUE_LIMIT) { - logWriteError(key, error); - return; - } - } - - try { - await writeItemAsync(key, value, currentBaseValue); - } catch (error) { - state.cleanupComplete = false; - logWriteError(key, error); - } - }); - }; - const updateItemAsync = ( - name: string, - update: (currentValue: string | null) => string, - ): Promise => { - const key = normalizeCookieName(name); - const state = getStorageState(storage, key); - return enqueueStorageWrite(state, async () => { - try { - const currentBaseValue = await storage.getItemAsync(key); - const previousValue = await readStoredValueAsync( - storage, - key, - currentBaseValue, - ); - const value = update(previousValue); - await writeItemAsync(key, value, currentBaseValue); - return { previousValue, value }; - } catch (error) { - state.cleanupComplete = false; - logWriteError(key, error); - return null; - } - }); - }; - - return { getItem, getItemAsync, setItem, setItemAsync, updateItemAsync }; -} - -/** - * Wraps Expo storage with chunking, recoverable writes, and serialized async - * access. - */ -export function storageAdapter(storage: ExpoClientStorage): ExpoStorageAdapter { - const managedStorage = createManagedStorage(storage); - return { - getItem: managedStorage.getItem, - getItemAsync: managedStorage.getItemAsync, - setItem: managedStorage.setItem, - setItemAsync: managedStorage.setItemAsync, - }; -} - export const expoClient = (opts: ExpoClientOptions) => { let store: ClientStore | null = null; const storagePrefix = opts?.storagePrefix || "better-auth"; diff --git a/packages/expo/test/client-storage.test.ts b/packages/expo/test/client-storage.test.ts new file mode 100644 index 0000000000..7a870a5d60 --- /dev/null +++ b/packages/expo/test/client-storage.test.ts @@ -0,0 +1,971 @@ +import { logger } from "@better-auth/core/env"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { storageAdapter } from "../src/client-storage"; + +beforeAll(() => { + vi.useFakeTimers(); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +it("should normalize colons in secure storage name via storage adapter", async () => { + const map = new Map(); + const setItem = vi.fn((name: string, value: string) => { + map.set(name, value); + }); + const setItemAsync = vi.fn(async (name: string, value: string) => { + map.set(name, value); + }); + const storage = storageAdapter({ + getItem(name) { + return map.get(name) || null; + }, + setItem, + async getItemAsync(name) { + return map.get(name) || null; + }, + setItemAsync, + }); + storage.setItem("better-auth:session_token", "123"); + expect(map.has("better-auth_session_token")).toBe(true); + expect(map.has("better-auth:session_token")).toBe(false); + expect(setItem).toHaveBeenCalledWith("better-auth_session_token", "123"); + expect(setItemAsync).not.toHaveBeenCalled(); + + await storage.setItemAsync("better-auth:session_token", "456"); + expect(setItemAsync).toHaveBeenCalledWith("better-auth_session_token", "456"); +}); + +/** + * Large provider tokens (e.g. Keycloak) overflow the device storage ceiling, + * so the adapter must split and reassemble the value instead of dropping it. + * + * @see https://github.com/better-auth/better-auth/issues/9151 + * @see https://github.com/better-auth/better-auth/issues/9814 + */ +it("should round-trip a value larger than the per-write storage limit", async () => { + const WRITE_LIMIT = 2048; + const map = new Map(); + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => { + if (value.length > WRITE_LIMIT) { + throw new Error("value exceeds storage limit"); + } + map.set(name, value); + }, + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + if (value.length > WRITE_LIMIT) { + throw new Error("value exceeds storage limit"); + } + map.set(name, value); + }, + }); + + const large = "x".repeat(10_000); + storage.setItem("better-auth_cookie", large); + + // No single physical write may exceed the backend limit. + for (const value of map.values()) { + expect(value.length).toBeLessThanOrEqual(WRITE_LIMIT); + } + // The value is split across several keys, not stored under the base key. + expect(map.size).toBeGreaterThan(1); + expect(storage.getItem("better-auth_cookie")).toBe(large); + + await storage.setItemAsync("better-auth_cookie_async", large); + const storedValue = await storage.getItemAsync("better-auth_cookie_async"); + expect(storedValue).toBe(large); +}); + +it("should store a value within the limit under the base key unchanged", () => { + const map = new Map(); + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + map.set(name, value); + }, + }); + + const small = JSON.stringify({ token: "abc" }); + storage.setItem("better-auth_cookie", small); + + expect(map.get("better-auth_cookie")).toBe(small); + expect(map.size).toBe(1); + expect(storage.getItem("better-auth_cookie")).toBe(small); +}); + +it("should read back a value written before chunking existed", () => { + // Pre-fix installs stored the whole jar under the base key. + const map = new Map([ + ["better-auth_cookie", JSON.stringify({ legacy: true })], + ]); + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + map.set(name, value); + }, + }); + + expect(storage.getItem("better-auth_cookie")).toBe( + JSON.stringify({ legacy: true }), + ); +}); + +it("should read values written with the legacy chunk marker", async () => { + const map = new Map([ + ["better-auth_cookie", "\u0001ba-chunks:2"], + ["better-auth_cookie.0", "legacy-"], + ["better-auth_cookie.1", "value"], + ]); + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + map.set(name, value); + }, + }); + + expect(storage.getItem("better-auth_cookie")).toBe("legacy-value"); + await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( + "legacy-value", + ); +}); + +it("should fail closed when a chunk is missing", async () => { + const map = new Map([ + ["better-auth_cookie", "\u0001ba-chunks:2:0"], + ["better-auth_cookie.0.0", "first chunk"], + ]); + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + map.set(name, value); + }, + }); + expect(storage.getItem("better-auth_cookie")).toBeNull(); + await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBeNull(); +}); + +/** + * @see https://github.com/better-auth/better-auth/issues/11082 + */ +describe("chunked storage consistency", () => { + function createAsyncStorage() { + const map = new Map(); + const nextTask = () => + new Promise((resolve) => queueMicrotask(resolve)); + return storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => { + await nextTask(); + return map.get(name) ?? null; + }, + setItemAsync: async (name, value) => { + await nextTask(); + map.set(name, value); + }, + }); + } + + it("should preserve the previous value when a sync overwrite fails", () => { + const map = new Map(); + let failAfter = Number.POSITIVE_INFINITY; + let writes = 0; + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => { + if (writes++ >= failAfter) { + throw new Error("interrupted"); + } + map.set(name, value); + }, + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + if (writes++ >= failAfter) { + throw new Error("interrupted"); + } + map.set(name, value); + }, + }); + + storage.setItem("better-auth_cookie", "a".repeat(3_000)); + const previousValue = "b".repeat(5_000); + storage.setItem("better-auth_cookie", previousValue); + expect(storage.getItem("better-auth_cookie")).toBe(previousValue); + + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + writes = 0; + failAfter = 2; + storage.setItem("better-auth_cookie", "c".repeat(5_000)); + + expect(error).toHaveBeenCalledTimes(1); + expect(storage.getItem("better-auth_cookie")).toBe(previousValue); + }); + + it.for([ + { failedWrite: 1 }, + { failedWrite: 2 }, + { failedWrite: 3 }, + { failedWrite: 4 }, + { failedWrite: 5 }, + ])("should preserve the previous value when async write $failedWrite fails", async ({ + failedWrite, + }) => { + const map = new Map(); + let writeIndex = 0; + let failing = false; + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + if (failing && ++writeIndex === failedWrite) { + throw new Error("interrupted"); + } + map.set(name, value); + }, + }); + await storage.setItemAsync("better-auth_cookie", "a".repeat(3_000)); + const previousValue = "b".repeat(5_000); + await storage.setItemAsync("better-auth_cookie", previousValue); + writeIndex = 0; + failing = true; + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + + await storage.setItemAsync("better-auth_cookie", "c".repeat(5_000)); + + expect(error).toHaveBeenCalledTimes(1); + await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( + previousValue, + ); + }); + + it("should disable fallback while its slot is being overwritten", async () => { + const map = new Map(); + let writeIndex = 0; + let failing = false; + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + if (failing && ++writeIndex === 3) { + throw new Error("interrupted"); + } + map.set(name, value); + }, + }); + await storage.setItemAsync("better-auth_cookie", "a".repeat(3_000)); + await storage.setItemAsync("better-auth_cookie", "b".repeat(5_000)); + writeIndex = 0; + failing = true; + vi.spyOn(logger, "error").mockImplementation(() => {}); + + await storage.setItemAsync("better-auth_cookie", "c".repeat(5_000)); + map.delete("better-auth_cookie.1.1"); + + expect(storage.getItem("better-auth_cookie")).toBeNull(); + await expect( + storage.getItemAsync("better-auth_cookie"), + ).resolves.toBeNull(); + }); + + it("should fall back when the active slot is incomplete", async () => { + const map = new Map(); + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + map.set(name, value); + }, + }); + const previousValue = "a".repeat(3_000); + await storage.setItemAsync("better-auth_cookie", previousValue); + await storage.setItemAsync("better-auth_cookie", "b".repeat(5_000)); + map.delete("better-auth_cookie.1.1"); + + expect(storage.getItem("better-auth_cookie")).toBe(previousValue); + await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( + previousValue, + ); + }); + + it("should finish an earlier read before a chunked overwrite", async () => { + const storage = createAsyncStorage(); + await storage.setItemAsync("better-auth_cookie", "a".repeat(3_000)); + const previousValue = "b".repeat(5_000); + await storage.setItemAsync("better-auth_cookie", previousValue); + const newValue = "c".repeat(5_000); + + const read = storage.getItemAsync("better-auth_cookie"); + const write = storage.setItemAsync("better-auth_cookie", newValue); + const [stored] = await Promise.all([read, write]); + + expect(stored).toBe(previousValue); + }); + + it("should serialize concurrent chunked overwrites", async () => { + const storage = createAsyncStorage(); + const firstValue = "a".repeat(5_000); + const secondValue = "b".repeat(5_000); + const thirdValue = "c".repeat(5_000); + + await Promise.all([ + storage.setItemAsync("better-auth_cookie", firstValue), + storage.setItemAsync("better-auth_cookie", secondValue), + storage.setItemAsync("better-auth_cookie", thirdValue), + ]); + + await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( + thirdValue, + ); + }); + + it("should serialize writes across adapters sharing storage", async () => { + const map = new Map(); + let activeWrites = 0; + let peakWrites = 0; + const backingStorage = { + getItem: (name: string) => map.get(name) ?? null, + setItem: (name: string, value: string) => map.set(name, value), + getItemAsync: async (name: string) => map.get(name) ?? null, + setItemAsync: async (name: string, value: string) => { + activeWrites++; + peakWrites = Math.max(peakWrites, activeWrites); + await new Promise((resolve) => queueMicrotask(resolve)); + map.set(name, value); + activeWrites--; + }, + }; + const first = storageAdapter(backingStorage); + const second = storageAdapter(backingStorage); + + await Promise.all([ + first.setItemAsync("better-auth_cookie", "a".repeat(5_000)), + second.setItemAsync("better-auth_cookie", "b".repeat(5_000)), + ]); + + expect(peakWrites).toBe(1); + + peakWrites = 0; + await Promise.all([ + first.setItemAsync("first_cookie", "a".repeat(5_000)), + second.setItemAsync("second_cookie", "b".repeat(5_000)), + ]); + + expect(peakWrites).toBe(2); + }); + + it("should not coordinate independent storage backends", async () => { + const firstMap = new Map(); + const secondMap = new Map(); + const first = storageAdapter({ + getItem: (name) => firstMap.get(name) ?? null, + setItem: (name, value) => firstMap.set(name, value), + getItemAsync: async (name) => firstMap.get(name) ?? null, + setItemAsync: async (name, value) => { + await new Promise((resolve) => queueMicrotask(resolve)); + firstMap.set(name, value); + }, + }); + const second = storageAdapter({ + getItem: (name) => secondMap.get(name) ?? null, + setItem: (name, value) => secondMap.set(name, value), + getItemAsync: async (name) => secondMap.get(name) ?? null, + setItemAsync: async (name, value) => { + secondMap.set(name, value); + }, + }); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + + const write = first.setItemAsync("better-auth_cookie", "a".repeat(5_000)); + second.setItem("better-auth_cookie", "independent"); + await write; + const errorCalls = error.mock.calls.length; + error.mockRestore(); + + expect(errorCalls).toBe(0); + expect(second.getItem("better-auth_cookie")).toBe("independent"); + }); + + it("should not mix a sync write into a pending async write", async () => { + const map = new Map(); + const backingStorage = { + getItem: (name: string) => map.get(name) ?? null, + setItem: (name: string, value: string) => map.set(name, value), + getItemAsync: async (name: string) => map.get(name) ?? null, + setItemAsync: async (name: string, value: string) => { + await new Promise((resolve) => queueMicrotask(resolve)); + map.set(name, value); + }, + }; + const asyncStorage = storageAdapter(backingStorage); + const syncStorage = storageAdapter(backingStorage); + const asyncValue = "a".repeat(5_000); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + + const write = asyncStorage.setItemAsync("better-auth_cookie", asyncValue); + syncStorage.setItem("better-auth_cookie", "b".repeat(5_000)); + await write; + + expect(error).toHaveBeenCalledTimes(1); + await expect(asyncStorage.getItemAsync("better-auth_cookie")).resolves.toBe( + asyncValue, + ); + }); + + it("should keep atomic updates internal to the Expo client", () => { + const storage = storageAdapter({ + getItem: () => null, + setItem: () => {}, + getItemAsync: async () => null, + setItemAsync: async () => {}, + }); + + expect(storage).not.toHaveProperty("updateItemAsync"); + }); + + it("should reject excessive chunk counts", async () => { + const map = new Map([["better-auth_cookie", "previous"]]); + const getItem = vi.fn((name: string) => map.get(name) ?? null); + const getItemAsync = vi.fn(async (name: string) => map.get(name) ?? null); + const storage = storageAdapter({ + getItem, + setItem: (name, value) => map.set(name, value), + getItemAsync, + setItemAsync: async (name, value) => { + map.set(name, value); + }, + }); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + + await storage.setItemAsync("better-auth_cookie", "x".repeat(180_001)); + + expect(error).toHaveBeenCalledTimes(1); + await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( + "previous", + ); + + map.set("better-auth_cookie", "\u0001ba-chunks:101:0"); + getItem.mockClear(); + getItemAsync.mockClear(); + expect(storage.getItem("better-auth_cookie")).toBeNull(); + await expect( + storage.getItemAsync("better-auth_cookie"), + ).resolves.toBeNull(); + expect(getItem).toHaveBeenCalledTimes(1); + expect(getItemAsync).toHaveBeenCalledTimes(1); + }); +}); + +/** + * @see https://github.com/better-auth/better-auth/issues/11194 + */ +describe("obsolete storage chunks", () => { + beforeEach(() => vi.clearAllTimers()); + afterEach(() => vi.clearAllTimers()); + const key = "scenecutai_cookie"; + const previousValue = JSON.stringify({ + "better-auth.session_token": { value: "old-session-token" }, + "better-auth.session_data": { value: "x".repeat(2_000) }, + }); + function createBackingStorage(map: Map) { + return { + getItem: (name: string) => map.get(name) ?? null, + setItem: (name: string, value: string) => { + map.set(name, value); + }, + getItemAsync: async (name: string) => map.get(name) ?? null, + setItemAsync: async (name: string, value: string) => { + map.set(name, value); + }, + }; + } + + function pauseNextStorageRead( + backing: ReturnType, + chunkKey: string, + ) { + let resume = () => {}; + let started = () => {}; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const reading = new Promise((resolve) => { + started = resolve; + }); + const getItemAsync = backing.getItemAsync; + let pauseRead = true; + backing.getItemAsync = async (name) => { + if (name === chunkKey && pauseRead) { + pauseRead = false; + started(); + await paused; + } + return getItemAsync(name); + }; + return { reading, resume }; + } + + describe.each(["setItem", "setItemAsync"] as const)("%s", (method) => { + it("retries orphan cleanup without leaving an unscannable gap", async () => { + const map = new Map([ + [key, "\u0001ba-chunks:2:0"], + [`${key}.0.0`, "first"], + [`${key}.0.1`, "second"], + [`${key}.0.2`, "old-secret"], + ]); + const backing = createBackingStorage(map); + let failing = true; + const write = (name: string, value: string) => { + if (failing && name === `${key}.0.2`) throw new Error("cleanup failed"); + map.set(name, value); + }; + backing.setItem = write; + backing.setItemAsync = async (name, value) => write(name, value); + const storage = storageAdapter(backing); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + + await storage[method](key, "{}"); + await vi.runAllTimersAsync(); + expect(map.get(key)).toBe("{}"); + expect(map.get(`${key}.0.0`)).toBe("first"); + expect(map.get(`${key}.0.1`)).toBe("second"); + expect(map.get(`${key}.0.2`)).toBe("old-secret"); + expect(error).toHaveBeenCalledOnce(); + + failing = false; + await storage[method](key, "{}"); + await vi.runAllTimersAsync(); + expect(map.get(`${key}.0.0`)).toBe(""); + expect(map.get(`${key}.0.1`)).toBe(""); + expect(map.get(`${key}.0.2`)).toBe(""); + }); + + it("persists a short value when reading the previous marker fails", async () => { + const map = new Map(); + const backing = createBackingStorage(map); + backing.getItem = () => { + throw new Error("read unavailable"); + }; + backing.getItemAsync = async () => { + throw new Error("read unavailable"); + }; + const storage = storageAdapter(backing); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + + await storage[method](key, "{}"); + + expect(map.get(key)).toBe("{}"); + await vi.runAllTimersAsync(); + expect(error).toHaveBeenCalledOnce(); + }); + + it("clears known chunks beyond a missing chunk", async () => { + const map = new Map([ + [key, "\u0001ba-chunks:3"], + [`${key}.0`, "old-first"], + [`${key}.2`, "old-secret"], + ]); + const storage = storageAdapter(createBackingStorage(map)); + await storage[method](key, "{}"); + expect(map.get(`${key}.2`) ?? "").toBe(""); + }); + + it("uses the marker to clear incomplete chunks after the initial sweep", async () => { + const map = new Map(); + const backing = createBackingStorage(map); + const storage = storageAdapter(backing); + await storage[method](key, "a".repeat(5_000)); + await vi.runAllTimersAsync(); + map.delete(`${key}.0.1`); + const getItem = vi.spyOn(backing, "getItem"); + const getItemAsync = vi.spyOn(backing, "getItemAsync"); + + await storage[method](key, "{}"); + + expect(getItem.mock.calls.length + getItemAsync.mock.calls.length).toBe( + 1, + ); + expect(map.get(`${key}.0.0`)).toBe(""); + expect(map.get(`${key}.0.2`)).toBe(""); + }); + + it.each([ + { + name: "legacy migration", + marker: "\u0001ba-chunks:2", + initialValue: previousValue, + chunkKeys: [`${key}.0`, `${key}.1`], + value: previousValue.replace("old-session-token", "new-session-token"), + }, + { + name: "legacy clear", + marker: "\u0001ba-chunks:2", + initialValue: previousValue, + chunkKeys: [`${key}.0`, `${key}.1`], + value: "{}", + }, + { + name: "slotted clear with fallback", + marker: "\u0001ba-chunks:2:1:2", + initialValue: previousValue, + chunkKeys: [`${key}.1.0`, `${key}.1.1`, `${key}.0.0`, `${key}.0.1`], + value: "{}", + }, + { + name: "a lost legacy marker", + marker: "{}", + initialValue: "{}", + chunkKeys: [`${key}.0`, `${key}.1`], + value: "{}", + }, + { + name: "a lost slotted marker", + marker: "{}", + initialValue: "{}", + chunkKeys: [`${key}.1.0`, `${key}.1.1`, `${key}.0.0`, `${key}.0.1`], + value: "{}", + }, + ])("clears obsolete bytes after $name", async ({ + marker, + initialValue, + chunkKeys, + value, + }) => { + const map = new Map([[key, marker]]); + for (const [index, chunkKey] of chunkKeys.entries()) { + const start = (index % 2) * 1_800; + map.set(chunkKey, previousValue.slice(start, start + 1_800)); + } + const storage = storageAdapter(createBackingStorage(map)); + + expect(storage.getItem(key)).toBe(initialValue); + await storage[method](key, value); + expect(storage.getItem(key)).toBe(value); + await vi.runAllTimersAsync(); + expect(chunkKeys.map((chunkKey) => map.get(chunkKey) ?? "")).toEqual( + chunkKeys.map(() => ""), + ); + }); + + it("clears a reused slot's tail while preserving recovery", async () => { + const map = new Map(); + const storage = storageAdapter(createBackingStorage(map)); + await storage[method](key, "a".repeat(5_000)); + await storage[method](key, previousValue); + const nextValue = "b".repeat(2_000); + await storage[method](key, nextValue); + + expect(storage.getItem(key)).toBe(nextValue); + expect(map.get(`${key}.0.2`)).toBe(""); + map.delete(`${key}.0.0`); + expect(storage.getItem(key)).toBe(previousValue); + }); + + it("preserves legacy chunks when the replacement cannot be committed", async () => { + const map = new Map([ + [key, "\u0001ba-chunks:2"], + [`${key}.0`, previousValue.slice(0, 1_800)], + [`${key}.1`, previousValue.slice(1_800)], + ]); + const backing = createBackingStorage(map); + const write = (name: string, value: string) => { + if (name === key) throw new Error("interrupted commit"); + map.set(name, value); + }; + backing.setItem = write; + backing.setItemAsync = async (name, value) => write(name, value); + const storage = storageAdapter(backing); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + + await storage[method](key, "b".repeat(3_000)); + + expect(error).toHaveBeenCalledOnce(); + expect(storage.getItem(key)).toBe(previousValue); + }); + + it("retries interrupted cleanup without losing the committed value", async () => { + const map = new Map([ + [key, "\u0001ba-chunks:2"], + [`${key}.0`, previousValue.slice(0, 1_800)], + [`${key}.1`, previousValue.slice(1_800)], + ]); + const backing = createBackingStorage(map); + let interrupted = false; + const write = (name: string, value: string) => { + if (name === `${key}.0` && !interrupted) { + interrupted = true; + throw new Error("interrupted cleanup"); + } + map.set(name, value); + }; + backing.setItem = write; + backing.setItemAsync = async (name, value) => write(name, value); + const storage = storageAdapter(backing); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + + await storage[method](key, "{}"); + expect(error).toHaveBeenCalledOnce(); + expect(storage.getItem(key)).toBe("{}"); + expect(map.get(`${key}.1`)).toBe(""); + + await storage[method](key, "{}"); + await vi.runAllTimersAsync(); + expect(map.get(`${key}.0`)).toBe(""); + expect(map.get(`${key}.1`)).toBe(""); + }); + + it("stops orphan probing at the first empty chunk", async () => { + const map = new Map(); + const backing = createBackingStorage(map); + const getItem = vi.spyOn(backing, "getItem"); + const getItemAsync = vi.spyOn(backing, "getItemAsync"); + const storage = storageAdapter(backing); + + await storage[method](key, "{}"); + expect(getItem.mock.calls.length + getItemAsync.mock.calls.length).toBe( + 4, + ); + + getItem.mockClear(); + getItemAsync.mockClear(); + await storageAdapter(backing)[method](key, "{}"); + expect(getItem.mock.calls.length + getItemAsync.mock.calls.length).toBe( + 1, + ); + + const nextBacking = createBackingStorage(map); + const nextGetItem = vi.spyOn(nextBacking, "getItem"); + const nextGetItemAsync = vi.spyOn(nextBacking, "getItemAsync"); + await storageAdapter(nextBacking)[method](key, "{}"); + expect( + nextGetItem.mock.calls.length + nextGetItemAsync.mock.calls.length, + ).toBe(4); + }); + }); + + it("retries partially cleared orphan chunks on the next write", async () => { + const map = new Map([ + [`${key}.0`, "first"], + [`${key}.1`, "second"], + [`${key}.2`, "third"], + ]); + const backing = createBackingStorage(map); + let failing = true; + backing.setItemAsync = async (name, value) => { + if (name === `${key}.1` && failing) throw new Error("cleanup failed"); + map.set(name, value); + }; + const storage = storageAdapter(backing); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + await storage.setItemAsync(key, "{}"); + expect(map.get(`${key}.0`)).toBe("first"); + expect(map.get(`${key}.1`)).toBe("second"); + expect(map.get(`${key}.2`)).toBe(""); + expect(error).toHaveBeenCalledOnce(); + + failing = false; + await storage.setItemAsync(key, "next"); + expect(map.get(`${key}.0`)).toBe(""); + expect(map.get(`${key}.1`)).toBe(""); + expect(map.get(key)).toBe("next"); + }); + + it("allows a synchronous write while only an async read is pending", async () => { + const map = new Map([[key, "previous"]]); + const storage = storageAdapter(createBackingStorage(map)); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + const read = storage.getItemAsync(key); + storage.setItem(key, "next"); + expect(map.get(key)).toBe("next"); + await expect(read).resolves.toBe("next"); + expect(error).not.toHaveBeenCalled(); + }); + + it("keeps a complete read when synchronous writes reuse its slot", async () => { + const map = new Map(); + const backing = createBackingStorage(map); + const storage = storageAdapter(backing); + const other = storageAdapter(backing); + await storage.setItemAsync(key, previousValue); + const { reading, resume } = pauseNextStorageRead(backing, `${key}.0.1`); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + const read = storage.getItemAsync(key); + await reading; + try { + other.setItem(key, "b".repeat(3_000)); + other.setItem(key, "c".repeat(3_000)); + expect(storage.getItem(key)).toBe("c".repeat(3_000)); + } finally { + resume(); + } + await expect(read).resolves.toBe("c".repeat(3_000)); + expect(error).not.toHaveBeenCalled(); + + map.delete(`${key}.0.1`); + await expect(storage.getItemAsync(key)).resolves.toBe("b".repeat(3_000)); + }); + + it.each([ + { failure: "write", expected: previousValue, errors: 1 }, + { failure: "read", expected: "b".repeat(3_000), errors: 0 }, + ])("keeps a complete async read after a synchronous $failure failure", async ({ + failure, + expected, + errors, + }) => { + const map = new Map([ + [key, "\u0001ba-chunks:2:1:2"], + [`${key}.0.0`, previousValue.slice(0, 1_800)], + [`${key}.0.1`, previousValue.slice(1_800)], + ]); + const backing = createBackingStorage(map); + backing.getItem = (name) => { + if (failure === "read" && name === `${key}.0.0`) + throw new Error("read failed"); + return map.get(name) ?? null; + }; + backing.setItem = (name, value) => { + if (failure === "write" && name === `${key}.0.1`) + throw new Error("write failed"); + map.set(name, value); + }; + const { reading, resume } = pauseNextStorageRead(backing, `${key}.0.0`); + const storage = storageAdapter(backing); + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + const read = storage.getItemAsync(key); + await reading; + try { + storage.setItem(key, "b".repeat(3_000)); + } finally { + resume(); + } + await expect(read).resolves.toBe(expected); + expect(error).toHaveBeenCalledTimes(errors); + }); + + it("continues queued writes after a read fails", async () => { + const map = new Map(); + const backing = createBackingStorage(map); + vi.spyOn(backing, "getItemAsync").mockRejectedValueOnce( + new Error("read failed"), + ); + const storage = storageAdapter(backing); + const read = storage.getItemAsync(key); + const write = storage.setItemAsync(key, "next"); + await expect(read).rejects.toThrow("read failed"); + await write; + expect(storage.getItem(key)).toBe("next"); + }); + + it("finishes reading before another adapter can reuse its slot", async () => { + const map = new Map(); + const backing = createBackingStorage(map); + const storage = storageAdapter(backing); + const other = storageAdapter(backing); + await storage.setItemAsync(key, previousValue); + const { reading, resume } = pauseNextStorageRead(backing, `${key}.0.1`); + const writes = vi.spyOn(backing, "setItemAsync"); + const read = storage.getItemAsync(key); + await reading; + const firstWrite = other.setItemAsync(key, "b".repeat(3_000)); + const secondWrite = other.setItemAsync(key, "c".repeat(3_000)); + let overlappingWrites = 0; + try { + await other.setItemAsync("independent_cookie", "{}"); + overlappingWrites = writes.mock.calls.filter(([name]) => + name.startsWith(key), + ).length; + } finally { + resume(); + } + const value = await read; + await Promise.all([firstWrite, secondWrite]); + expect(overlappingWrites).toBe(0); + expect(value).toBe(previousValue); + }); + + it.each([ + { name: "legacy", marker: "\u0001ba-chunks:2", prefix: key }, + { name: "active", marker: "\u0001ba-chunks:2:0", prefix: `${key}.0` }, + { name: "fallback", marker: "\u0001ba-chunks:2:1:2", prefix: `${key}.0` }, + ])("finishes a $name read before cleanup", async ({ marker, prefix }) => { + const map = new Map([ + [key, marker], + [`${prefix}.0`, previousValue.slice(0, 1_800)], + [`${prefix}.1`, previousValue.slice(1_800)], + ]); + const backing = createBackingStorage(map); + const { reading, resume } = pauseNextStorageRead(backing, `${prefix}.1`); + const storage = storageAdapter(backing); + + const read = storage.getItemAsync(key); + await reading; + const write = storage.setItemAsync(key, "{}"); + resume(); + await expect(read).resolves.toBe(previousValue); + await write; + expect(storage.getItem(key)).toBe("{}"); + }); +}); + +it("should shrink from chunked to a single value without bleeding stale chunks", () => { + const map = new Map(); + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + map.set(name, value); + }, + }); + + storage.setItem("better-auth_cookie", "z".repeat(5_000)); + storage.setItem("better-auth_cookie", "small"); + + expect(storage.getItem("better-auth_cookie")).toBe("small"); +}); + +it("should log instead of throw when the backend rejects a write", async () => { + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + const storage = storageAdapter({ + getItem: () => null, + setItem: () => { + throw new Error("keychain rejected write"); + }, + getItemAsync: async () => null, + setItemAsync: async () => { + throw new Error("keychain rejected write"); + }, + }); + + expect(() => storage.setItem("better-auth_cookie", "value")).not.toThrow(); + await expect( + storage.setItemAsync("better-auth_cookie", "value"), + ).resolves.toBeUndefined(); + expect(error).toHaveBeenCalledTimes(2); + error.mockRestore(); +}); diff --git a/packages/expo/test/expo.test.ts b/packages/expo/test/expo.test.ts index 76f177e634..55ddd0d46e 100644 --- a/packages/expo/test/expo.test.ts +++ b/packages/expo/test/expo.test.ts @@ -3,18 +3,10 @@ import { createAuthMiddleware } from "better-auth/api"; import { magicLinkClient } from "better-auth/client/plugins"; import { magicLink, oAuthProxy } from "better-auth/plugins"; import { getTestInstance } from "better-auth/test"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { expo } from "../src"; -import { expoClient, storageAdapter } from "../src/client"; +import { expoClient, normalizeCookieName, storageAdapter } from "../src/client"; +import * as clientStorage from "../src/client-storage"; vi.mock("expo-web-browser", async () => { return { @@ -58,6 +50,11 @@ vi.mock("expo-linking", async () => { const fn = vi.fn(); +it("should preserve the public storage exports", () => { + expect(storageAdapter).toBe(clientStorage.storageAdapter); + expect(normalizeCookieName).toBe(clientStorage.normalizeCookieName); +}); + describe("expo", async () => { const storage = new Map(); @@ -916,158 +913,6 @@ describe("expo with cookieCache", async () => { expect(hasBetterAuthCookies(sessionTokenHeader, [""])).toBe(true); }); - it("should normalize colons in secure storage name via storage adapter", async () => { - const map = new Map(); - const setItem = vi.fn((name: string, value: string) => { - map.set(name, value); - }); - const setItemAsync = vi.fn(async (name: string, value: string) => { - map.set(name, value); - }); - const storage = storageAdapter({ - getItem(name) { - return map.get(name) || null; - }, - setItem, - async getItemAsync(name) { - return map.get(name) || null; - }, - setItemAsync, - }); - storage.setItem("better-auth:session_token", "123"); - expect(map.has("better-auth_session_token")).toBe(true); - expect(map.has("better-auth:session_token")).toBe(false); - expect(setItem).toHaveBeenCalledWith("better-auth_session_token", "123"); - expect(setItemAsync).not.toHaveBeenCalled(); - - await storage.setItemAsync("better-auth:session_token", "456"); - expect(setItemAsync).toHaveBeenCalledWith( - "better-auth_session_token", - "456", - ); - }); - - /** - * Large provider tokens (e.g. Keycloak) overflow the device storage ceiling, - * so the adapter must split and reassemble the value instead of dropping it. - * - * @see https://github.com/better-auth/better-auth/issues/9151 - * @see https://github.com/better-auth/better-auth/issues/9814 - */ - it("should round-trip a value larger than the per-write storage limit", async () => { - const WRITE_LIMIT = 2048; - const map = new Map(); - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => { - if (value.length > WRITE_LIMIT) { - throw new Error("value exceeds storage limit"); - } - map.set(name, value); - }, - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - if (value.length > WRITE_LIMIT) { - throw new Error("value exceeds storage limit"); - } - map.set(name, value); - }, - }); - - const large = "x".repeat(10_000); - storage.setItem("better-auth_cookie", large); - - // No single physical write may exceed the backend limit. - for (const value of map.values()) { - expect(value.length).toBeLessThanOrEqual(WRITE_LIMIT); - } - // The value is split across several keys, not stored under the base key. - expect(map.size).toBeGreaterThan(1); - expect(storage.getItem("better-auth_cookie")).toBe(large); - - await storage.setItemAsync("better-auth_cookie_async", large); - const storedValue = await storage.getItemAsync("better-auth_cookie_async"); - expect(storedValue).toBe(large); - }); - - it("should store a value within the limit under the base key unchanged", () => { - const map = new Map(); - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - map.set(name, value); - }, - }); - - const small = JSON.stringify({ token: "abc" }); - storage.setItem("better-auth_cookie", small); - - expect(map.get("better-auth_cookie")).toBe(small); - expect(map.size).toBe(1); - expect(storage.getItem("better-auth_cookie")).toBe(small); - }); - - it("should read back a value written before chunking existed", () => { - // Pre-fix installs stored the whole jar under the base key. - const map = new Map([ - ["better-auth_cookie", JSON.stringify({ legacy: true })], - ]); - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - map.set(name, value); - }, - }); - - expect(storage.getItem("better-auth_cookie")).toBe( - JSON.stringify({ legacy: true }), - ); - }); - - it("should read values written with the legacy chunk marker", async () => { - const map = new Map([ - ["better-auth_cookie", "\u0001ba-chunks:2"], - ["better-auth_cookie.0", "legacy-"], - ["better-auth_cookie.1", "value"], - ]); - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - map.set(name, value); - }, - }); - - expect(storage.getItem("better-auth_cookie")).toBe("legacy-value"); - await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( - "legacy-value", - ); - }); - - it("should fail closed when a chunk is missing", async () => { - const map = new Map([ - ["better-auth_cookie", "\u0001ba-chunks:2:0"], - ["better-auth_cookie.0.0", "first chunk"], - ]); - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - map.set(name, value); - }, - }); - expect(storage.getItem("better-auth_cookie")).toBeNull(); - await expect( - storage.getItemAsync("better-auth_cookie"), - ).resolves.toBeNull(); - }); - /** * @see https://github.com/better-auth/better-auth/issues/11082 */ @@ -1086,272 +931,6 @@ describe("expo with cookieCache", async () => { } as Parameters[0]); } - function createAsyncStorage() { - const map = new Map(); - const nextTask = () => - new Promise((resolve) => queueMicrotask(resolve)); - return storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => { - await nextTask(); - return map.get(name) ?? null; - }, - setItemAsync: async (name, value) => { - await nextTask(); - map.set(name, value); - }, - }); - } - - it("should preserve the previous value when a sync overwrite fails", () => { - const map = new Map(); - let failAfter = Number.POSITIVE_INFINITY; - let writes = 0; - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => { - if (writes++ >= failAfter) { - throw new Error("interrupted"); - } - map.set(name, value); - }, - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - if (writes++ >= failAfter) { - throw new Error("interrupted"); - } - map.set(name, value); - }, - }); - - storage.setItem("better-auth_cookie", "a".repeat(3_000)); - const previousValue = "b".repeat(5_000); - storage.setItem("better-auth_cookie", previousValue); - expect(storage.getItem("better-auth_cookie")).toBe(previousValue); - - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - writes = 0; - failAfter = 2; - storage.setItem("better-auth_cookie", "c".repeat(5_000)); - - expect(error).toHaveBeenCalledTimes(1); - expect(storage.getItem("better-auth_cookie")).toBe(previousValue); - }); - - it.for([ - { failedWrite: 1 }, - { failedWrite: 2 }, - { failedWrite: 3 }, - { failedWrite: 4 }, - { failedWrite: 5 }, - ])("should preserve the previous value when async write $failedWrite fails", async ({ - failedWrite, - }) => { - const map = new Map(); - let writeIndex = 0; - let failing = false; - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - if (failing && ++writeIndex === failedWrite) { - throw new Error("interrupted"); - } - map.set(name, value); - }, - }); - await storage.setItemAsync("better-auth_cookie", "a".repeat(3_000)); - const previousValue = "b".repeat(5_000); - await storage.setItemAsync("better-auth_cookie", previousValue); - writeIndex = 0; - failing = true; - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - - await storage.setItemAsync("better-auth_cookie", "c".repeat(5_000)); - - expect(error).toHaveBeenCalledTimes(1); - await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( - previousValue, - ); - }); - - it("should disable fallback while its slot is being overwritten", async () => { - const map = new Map(); - let writeIndex = 0; - let failing = false; - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - if (failing && ++writeIndex === 3) { - throw new Error("interrupted"); - } - map.set(name, value); - }, - }); - await storage.setItemAsync("better-auth_cookie", "a".repeat(3_000)); - await storage.setItemAsync("better-auth_cookie", "b".repeat(5_000)); - writeIndex = 0; - failing = true; - vi.spyOn(logger, "error").mockImplementation(() => {}); - - await storage.setItemAsync("better-auth_cookie", "c".repeat(5_000)); - map.delete("better-auth_cookie.1.1"); - - expect(storage.getItem("better-auth_cookie")).toBeNull(); - await expect( - storage.getItemAsync("better-auth_cookie"), - ).resolves.toBeNull(); - }); - - it("should fall back when the active slot is incomplete", async () => { - const map = new Map(); - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - map.set(name, value); - }, - }); - const previousValue = "a".repeat(3_000); - await storage.setItemAsync("better-auth_cookie", previousValue); - await storage.setItemAsync("better-auth_cookie", "b".repeat(5_000)); - map.delete("better-auth_cookie.1.1"); - - expect(storage.getItem("better-auth_cookie")).toBe(previousValue); - await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( - previousValue, - ); - }); - - it("should finish an earlier read before a chunked overwrite", async () => { - const storage = createAsyncStorage(); - await storage.setItemAsync("better-auth_cookie", "a".repeat(3_000)); - const previousValue = "b".repeat(5_000); - await storage.setItemAsync("better-auth_cookie", previousValue); - const newValue = "c".repeat(5_000); - - const read = storage.getItemAsync("better-auth_cookie"); - const write = storage.setItemAsync("better-auth_cookie", newValue); - const [stored] = await Promise.all([read, write]); - - expect(stored).toBe(previousValue); - }); - - it("should serialize concurrent chunked overwrites", async () => { - const storage = createAsyncStorage(); - const firstValue = "a".repeat(5_000); - const secondValue = "b".repeat(5_000); - const thirdValue = "c".repeat(5_000); - - await Promise.all([ - storage.setItemAsync("better-auth_cookie", firstValue), - storage.setItemAsync("better-auth_cookie", secondValue), - storage.setItemAsync("better-auth_cookie", thirdValue), - ]); - - await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( - thirdValue, - ); - }); - - it("should serialize writes across adapters sharing storage", async () => { - const map = new Map(); - let activeWrites = 0; - let peakWrites = 0; - const backingStorage = { - getItem: (name: string) => map.get(name) ?? null, - setItem: (name: string, value: string) => map.set(name, value), - getItemAsync: async (name: string) => map.get(name) ?? null, - setItemAsync: async (name: string, value: string) => { - activeWrites++; - peakWrites = Math.max(peakWrites, activeWrites); - await new Promise((resolve) => queueMicrotask(resolve)); - map.set(name, value); - activeWrites--; - }, - }; - const first = storageAdapter(backingStorage); - const second = storageAdapter(backingStorage); - - await Promise.all([ - first.setItemAsync("better-auth_cookie", "a".repeat(5_000)), - second.setItemAsync("better-auth_cookie", "b".repeat(5_000)), - ]); - - expect(peakWrites).toBe(1); - - peakWrites = 0; - await Promise.all([ - first.setItemAsync("first_cookie", "a".repeat(5_000)), - second.setItemAsync("second_cookie", "b".repeat(5_000)), - ]); - - expect(peakWrites).toBe(2); - }); - - it("should not coordinate independent storage backends", async () => { - const firstMap = new Map(); - const secondMap = new Map(); - const first = storageAdapter({ - getItem: (name) => firstMap.get(name) ?? null, - setItem: (name, value) => firstMap.set(name, value), - getItemAsync: async (name) => firstMap.get(name) ?? null, - setItemAsync: async (name, value) => { - await new Promise((resolve) => queueMicrotask(resolve)); - firstMap.set(name, value); - }, - }); - const second = storageAdapter({ - getItem: (name) => secondMap.get(name) ?? null, - setItem: (name, value) => secondMap.set(name, value), - getItemAsync: async (name) => secondMap.get(name) ?? null, - setItemAsync: async (name, value) => { - secondMap.set(name, value); - }, - }); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - - const write = first.setItemAsync("better-auth_cookie", "a".repeat(5_000)); - second.setItem("better-auth_cookie", "independent"); - await write; - const errorCalls = error.mock.calls.length; - error.mockRestore(); - - expect(errorCalls).toBe(0); - expect(second.getItem("better-auth_cookie")).toBe("independent"); - }); - - it("should not mix a sync write into a pending async write", async () => { - const map = new Map(); - const backingStorage = { - getItem: (name: string) => map.get(name) ?? null, - setItem: (name: string, value: string) => map.set(name, value), - getItemAsync: async (name: string) => map.get(name) ?? null, - setItemAsync: async (name: string, value: string) => { - await new Promise((resolve) => queueMicrotask(resolve)); - map.set(name, value); - }, - }; - const asyncStorage = storageAdapter(backingStorage); - const syncStorage = storageAdapter(backingStorage); - const asyncValue = "a".repeat(5_000); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - - const write = asyncStorage.setItemAsync("better-auth_cookie", asyncValue); - syncStorage.setItem("better-auth_cookie", "b".repeat(5_000)); - await write; - - expect(error).toHaveBeenCalledTimes(1); - await expect( - asyncStorage.getItemAsync("better-auth_cookie"), - ).resolves.toBe(asyncValue); - }); - it("should update a value without losing concurrent changes", async () => { const map = new Map(); const backingStorage = { @@ -1379,17 +958,6 @@ describe("expo with cookieCache", async () => { }); }); - it("should keep atomic updates internal to the Expo client", () => { - const storage = storageAdapter({ - getItem: () => null, - setItem: () => {}, - getItemAsync: async () => null, - setItemAsync: async () => {}, - }); - - expect(storage).not.toHaveProperty("updateItemAsync"); - }); - it("should not notify when a cookie update cannot be stored", async () => { const previousValue = JSON.stringify({ "better-auth.session_token": { value: "previous", expires: null }, @@ -1419,532 +987,6 @@ describe("expo with cookieCache", async () => { expect(error).toHaveBeenCalledTimes(1); expect(notify).not.toHaveBeenCalled(); }); - - it("should reject excessive chunk counts", async () => { - const map = new Map([["better-auth_cookie", "previous"]]); - const getItem = vi.fn((name: string) => map.get(name) ?? null); - const getItemAsync = vi.fn(async (name: string) => map.get(name) ?? null); - const storage = storageAdapter({ - getItem, - setItem: (name, value) => map.set(name, value), - getItemAsync, - setItemAsync: async (name, value) => { - map.set(name, value); - }, - }); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - - await storage.setItemAsync("better-auth_cookie", "x".repeat(180_001)); - - expect(error).toHaveBeenCalledTimes(1); - await expect(storage.getItemAsync("better-auth_cookie")).resolves.toBe( - "previous", - ); - - map.set("better-auth_cookie", "\u0001ba-chunks:101:0"); - getItem.mockClear(); - getItemAsync.mockClear(); - expect(storage.getItem("better-auth_cookie")).toBeNull(); - await expect( - storage.getItemAsync("better-auth_cookie"), - ).resolves.toBeNull(); - expect(getItem).toHaveBeenCalledTimes(1); - expect(getItemAsync).toHaveBeenCalledTimes(1); - }); - }); - - /** - * @see https://github.com/better-auth/better-auth/issues/11194 - */ - describe("obsolete storage chunks", () => { - beforeEach(() => vi.clearAllTimers()); - afterEach(() => vi.clearAllTimers()); - const key = "scenecutai_cookie"; - const previousValue = JSON.stringify({ - "better-auth.session_token": { value: "old-session-token" }, - "better-auth.session_data": { value: "x".repeat(2_000) }, - }); - function createBackingStorage(map: Map) { - return { - getItem: (name: string) => map.get(name) ?? null, - setItem: (name: string, value: string) => { - map.set(name, value); - }, - getItemAsync: async (name: string) => map.get(name) ?? null, - setItemAsync: async (name: string, value: string) => { - map.set(name, value); - }, - }; - } - - function pauseNextStorageRead( - backing: ReturnType, - chunkKey: string, - ) { - let resume = () => {}; - let started = () => {}; - const paused = new Promise((resolve) => { - resume = resolve; - }); - const reading = new Promise((resolve) => { - started = resolve; - }); - const getItemAsync = backing.getItemAsync; - let pauseRead = true; - backing.getItemAsync = async (name) => { - if (name === chunkKey && pauseRead) { - pauseRead = false; - started(); - await paused; - } - return getItemAsync(name); - }; - return { reading, resume }; - } - - describe.each(["setItem", "setItemAsync"] as const)("%s", (method) => { - it("retries orphan cleanup without leaving an unscannable gap", async () => { - const map = new Map([ - [key, "\u0001ba-chunks:2:0"], - [`${key}.0.0`, "first"], - [`${key}.0.1`, "second"], - [`${key}.0.2`, "old-secret"], - ]); - const backing = createBackingStorage(map); - let failing = true; - const write = (name: string, value: string) => { - if (failing && name === `${key}.0.2`) - throw new Error("cleanup failed"); - map.set(name, value); - }; - backing.setItem = write; - backing.setItemAsync = async (name, value) => write(name, value); - const storage = storageAdapter(backing); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - - await storage[method](key, "{}"); - await vi.runAllTimersAsync(); - expect(map.get(key)).toBe("{}"); - expect(map.get(`${key}.0.0`)).toBe("first"); - expect(map.get(`${key}.0.1`)).toBe("second"); - expect(map.get(`${key}.0.2`)).toBe("old-secret"); - expect(error).toHaveBeenCalledOnce(); - - failing = false; - await storage[method](key, "{}"); - await vi.runAllTimersAsync(); - expect(map.get(`${key}.0.0`)).toBe(""); - expect(map.get(`${key}.0.1`)).toBe(""); - expect(map.get(`${key}.0.2`)).toBe(""); - }); - - it("persists a short value when reading the previous marker fails", async () => { - const map = new Map(); - const backing = createBackingStorage(map); - backing.getItem = () => { - throw new Error("read unavailable"); - }; - backing.getItemAsync = async () => { - throw new Error("read unavailable"); - }; - const storage = storageAdapter(backing); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - - await storage[method](key, "{}"); - - expect(map.get(key)).toBe("{}"); - await vi.runAllTimersAsync(); - expect(error).toHaveBeenCalledOnce(); - }); - - it("clears known chunks beyond a missing chunk", async () => { - const map = new Map([ - [key, "\u0001ba-chunks:3"], - [`${key}.0`, "old-first"], - [`${key}.2`, "old-secret"], - ]); - const storage = storageAdapter(createBackingStorage(map)); - await storage[method](key, "{}"); - expect(map.get(`${key}.2`) ?? "").toBe(""); - }); - - it("uses the marker to clear incomplete chunks after the initial sweep", async () => { - const map = new Map(); - const backing = createBackingStorage(map); - const storage = storageAdapter(backing); - await storage[method](key, "a".repeat(5_000)); - await vi.runAllTimersAsync(); - map.delete(`${key}.0.1`); - const getItem = vi.spyOn(backing, "getItem"); - const getItemAsync = vi.spyOn(backing, "getItemAsync"); - - await storage[method](key, "{}"); - - expect(getItem.mock.calls.length + getItemAsync.mock.calls.length).toBe( - 1, - ); - expect(map.get(`${key}.0.0`)).toBe(""); - expect(map.get(`${key}.0.2`)).toBe(""); - }); - - it.each([ - { - name: "legacy migration", - marker: "\u0001ba-chunks:2", - initialValue: previousValue, - chunkKeys: [`${key}.0`, `${key}.1`], - value: previousValue.replace( - "old-session-token", - "new-session-token", - ), - }, - { - name: "legacy clear", - marker: "\u0001ba-chunks:2", - initialValue: previousValue, - chunkKeys: [`${key}.0`, `${key}.1`], - value: "{}", - }, - { - name: "slotted clear with fallback", - marker: "\u0001ba-chunks:2:1:2", - initialValue: previousValue, - chunkKeys: [`${key}.1.0`, `${key}.1.1`, `${key}.0.0`, `${key}.0.1`], - value: "{}", - }, - { - name: "a lost legacy marker", - marker: "{}", - initialValue: "{}", - chunkKeys: [`${key}.0`, `${key}.1`], - value: "{}", - }, - { - name: "a lost slotted marker", - marker: "{}", - initialValue: "{}", - chunkKeys: [`${key}.1.0`, `${key}.1.1`, `${key}.0.0`, `${key}.0.1`], - value: "{}", - }, - ])("clears obsolete bytes after $name", async ({ - marker, - initialValue, - chunkKeys, - value, - }) => { - const map = new Map([[key, marker]]); - for (const [index, chunkKey] of chunkKeys.entries()) { - const start = (index % 2) * 1_800; - map.set(chunkKey, previousValue.slice(start, start + 1_800)); - } - const storage = storageAdapter(createBackingStorage(map)); - - expect(storage.getItem(key)).toBe(initialValue); - await storage[method](key, value); - expect(storage.getItem(key)).toBe(value); - await vi.runAllTimersAsync(); - expect(chunkKeys.map((chunkKey) => map.get(chunkKey) ?? "")).toEqual( - chunkKeys.map(() => ""), - ); - }); - - it("clears a reused slot's tail while preserving recovery", async () => { - const map = new Map(); - const storage = storageAdapter(createBackingStorage(map)); - await storage[method](key, "a".repeat(5_000)); - await storage[method](key, previousValue); - const nextValue = "b".repeat(2_000); - await storage[method](key, nextValue); - - expect(storage.getItem(key)).toBe(nextValue); - expect(map.get(`${key}.0.2`)).toBe(""); - map.delete(`${key}.0.0`); - expect(storage.getItem(key)).toBe(previousValue); - }); - - it("preserves legacy chunks when the replacement cannot be committed", async () => { - const map = new Map([ - [key, "\u0001ba-chunks:2"], - [`${key}.0`, previousValue.slice(0, 1_800)], - [`${key}.1`, previousValue.slice(1_800)], - ]); - const backing = createBackingStorage(map); - const write = (name: string, value: string) => { - if (name === key) throw new Error("interrupted commit"); - map.set(name, value); - }; - backing.setItem = write; - backing.setItemAsync = async (name, value) => write(name, value); - const storage = storageAdapter(backing); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - - await storage[method](key, "b".repeat(3_000)); - - expect(error).toHaveBeenCalledOnce(); - expect(storage.getItem(key)).toBe(previousValue); - }); - - it("retries interrupted cleanup without losing the committed value", async () => { - const map = new Map([ - [key, "\u0001ba-chunks:2"], - [`${key}.0`, previousValue.slice(0, 1_800)], - [`${key}.1`, previousValue.slice(1_800)], - ]); - const backing = createBackingStorage(map); - let interrupted = false; - const write = (name: string, value: string) => { - if (name === `${key}.0` && !interrupted) { - interrupted = true; - throw new Error("interrupted cleanup"); - } - map.set(name, value); - }; - backing.setItem = write; - backing.setItemAsync = async (name, value) => write(name, value); - const storage = storageAdapter(backing); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - - await storage[method](key, "{}"); - expect(error).toHaveBeenCalledOnce(); - expect(storage.getItem(key)).toBe("{}"); - expect(map.get(`${key}.1`)).toBe(""); - - await storage[method](key, "{}"); - await vi.runAllTimersAsync(); - expect(map.get(`${key}.0`)).toBe(""); - expect(map.get(`${key}.1`)).toBe(""); - }); - - it("stops orphan probing at the first empty chunk", async () => { - const map = new Map(); - const backing = createBackingStorage(map); - const getItem = vi.spyOn(backing, "getItem"); - const getItemAsync = vi.spyOn(backing, "getItemAsync"); - const storage = storageAdapter(backing); - - await storage[method](key, "{}"); - expect(getItem.mock.calls.length + getItemAsync.mock.calls.length).toBe( - 4, - ); - - getItem.mockClear(); - getItemAsync.mockClear(); - await storageAdapter(backing)[method](key, "{}"); - expect(getItem.mock.calls.length + getItemAsync.mock.calls.length).toBe( - 1, - ); - - const nextBacking = createBackingStorage(map); - const nextGetItem = vi.spyOn(nextBacking, "getItem"); - const nextGetItemAsync = vi.spyOn(nextBacking, "getItemAsync"); - await storageAdapter(nextBacking)[method](key, "{}"); - expect( - nextGetItem.mock.calls.length + nextGetItemAsync.mock.calls.length, - ).toBe(4); - }); - }); - - it("retries partially cleared orphan chunks on the next write", async () => { - const map = new Map([ - [`${key}.0`, "first"], - [`${key}.1`, "second"], - [`${key}.2`, "third"], - ]); - const backing = createBackingStorage(map); - let failing = true; - backing.setItemAsync = async (name, value) => { - if (name === `${key}.1` && failing) throw new Error("cleanup failed"); - map.set(name, value); - }; - const storage = storageAdapter(backing); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - await storage.setItemAsync(key, "{}"); - expect(map.get(`${key}.0`)).toBe("first"); - expect(map.get(`${key}.1`)).toBe("second"); - expect(map.get(`${key}.2`)).toBe(""); - expect(error).toHaveBeenCalledOnce(); - - failing = false; - await storage.setItemAsync(key, "next"); - expect(map.get(`${key}.0`)).toBe(""); - expect(map.get(`${key}.1`)).toBe(""); - expect(map.get(key)).toBe("next"); - }); - - it("allows a synchronous write while only an async read is pending", async () => { - const map = new Map([[key, "previous"]]); - const storage = storageAdapter(createBackingStorage(map)); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - const read = storage.getItemAsync(key); - storage.setItem(key, "next"); - expect(map.get(key)).toBe("next"); - await expect(read).resolves.toBe("next"); - expect(error).not.toHaveBeenCalled(); - }); - - it("keeps a complete read when synchronous writes reuse its slot", async () => { - const map = new Map(); - const backing = createBackingStorage(map); - const storage = storageAdapter(backing); - const other = storageAdapter(backing); - await storage.setItemAsync(key, previousValue); - const { reading, resume } = pauseNextStorageRead(backing, `${key}.0.1`); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - const read = storage.getItemAsync(key); - await reading; - try { - other.setItem(key, "b".repeat(3_000)); - other.setItem(key, "c".repeat(3_000)); - expect(storage.getItem(key)).toBe("c".repeat(3_000)); - } finally { - resume(); - } - await expect(read).resolves.toBe("c".repeat(3_000)); - expect(error).not.toHaveBeenCalled(); - - map.delete(`${key}.0.1`); - await expect(storage.getItemAsync(key)).resolves.toBe("b".repeat(3_000)); - }); - - it.each([ - { failure: "write", expected: previousValue, errors: 1 }, - { failure: "read", expected: "b".repeat(3_000), errors: 0 }, - ])("keeps a complete async read after a synchronous $failure failure", async ({ - failure, - expected, - errors, - }) => { - const map = new Map([ - [key, "\u0001ba-chunks:2:1:2"], - [`${key}.0.0`, previousValue.slice(0, 1_800)], - [`${key}.0.1`, previousValue.slice(1_800)], - ]); - const backing = createBackingStorage(map); - backing.getItem = (name) => { - if (failure === "read" && name === `${key}.0.0`) - throw new Error("read failed"); - return map.get(name) ?? null; - }; - backing.setItem = (name, value) => { - if (failure === "write" && name === `${key}.0.1`) - throw new Error("write failed"); - map.set(name, value); - }; - const { reading, resume } = pauseNextStorageRead(backing, `${key}.0.0`); - const storage = storageAdapter(backing); - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - const read = storage.getItemAsync(key); - await reading; - try { - storage.setItem(key, "b".repeat(3_000)); - } finally { - resume(); - } - await expect(read).resolves.toBe(expected); - expect(error).toHaveBeenCalledTimes(errors); - }); - - it("continues queued writes after a read fails", async () => { - const map = new Map(); - const backing = createBackingStorage(map); - vi.spyOn(backing, "getItemAsync").mockRejectedValueOnce( - new Error("read failed"), - ); - const storage = storageAdapter(backing); - const read = storage.getItemAsync(key); - const write = storage.setItemAsync(key, "next"); - await expect(read).rejects.toThrow("read failed"); - await write; - expect(storage.getItem(key)).toBe("next"); - }); - - it("finishes reading before another adapter can reuse its slot", async () => { - const map = new Map(); - const backing = createBackingStorage(map); - const storage = storageAdapter(backing); - const other = storageAdapter(backing); - await storage.setItemAsync(key, previousValue); - const { reading, resume } = pauseNextStorageRead(backing, `${key}.0.1`); - const writes = vi.spyOn(backing, "setItemAsync"); - const read = storage.getItemAsync(key); - await reading; - const firstWrite = other.setItemAsync(key, "b".repeat(3_000)); - const secondWrite = other.setItemAsync(key, "c".repeat(3_000)); - let overlappingWrites = 0; - try { - await other.setItemAsync("independent_cookie", "{}"); - overlappingWrites = writes.mock.calls.filter(([name]) => - name.startsWith(key), - ).length; - } finally { - resume(); - } - const value = await read; - await Promise.all([firstWrite, secondWrite]); - expect(overlappingWrites).toBe(0); - expect(value).toBe(previousValue); - }); - - it.each([ - { name: "legacy", marker: "\u0001ba-chunks:2", prefix: key }, - { name: "active", marker: "\u0001ba-chunks:2:0", prefix: `${key}.0` }, - { name: "fallback", marker: "\u0001ba-chunks:2:1:2", prefix: `${key}.0` }, - ])("finishes a $name read before cleanup", async ({ marker, prefix }) => { - const map = new Map([ - [key, marker], - [`${prefix}.0`, previousValue.slice(0, 1_800)], - [`${prefix}.1`, previousValue.slice(1_800)], - ]); - const backing = createBackingStorage(map); - const { reading, resume } = pauseNextStorageRead(backing, `${prefix}.1`); - const storage = storageAdapter(backing); - - const read = storage.getItemAsync(key); - await reading; - const write = storage.setItemAsync(key, "{}"); - resume(); - await expect(read).resolves.toBe(previousValue); - await write; - expect(storage.getItem(key)).toBe("{}"); - }); - }); - - it("should shrink from chunked to a single value without bleeding stale chunks", () => { - const map = new Map(); - const storage = storageAdapter({ - getItem: (name) => map.get(name) ?? null, - setItem: (name, value) => map.set(name, value), - getItemAsync: async (name) => map.get(name) ?? null, - setItemAsync: async (name, value) => { - map.set(name, value); - }, - }); - - storage.setItem("better-auth_cookie", "z".repeat(5_000)); - storage.setItem("better-auth_cookie", "small"); - - expect(storage.getItem("better-auth_cookie")).toBe("small"); - }); - - it("should log instead of throw when the backend rejects a write", async () => { - const error = vi.spyOn(logger, "error").mockImplementation(() => {}); - const storage = storageAdapter({ - getItem: () => null, - setItem: () => { - throw new Error("keychain rejected write"); - }, - getItemAsync: async () => null, - setItemAsync: async () => { - throw new Error("keychain rejected write"); - }, - }); - - expect(() => storage.setItem("better-auth_cookie", "value")).not.toThrow(); - await expect( - storage.setItemAsync("better-auth_cookie", "value"), - ).resolves.toBeUndefined(); - expect(error).toHaveBeenCalledTimes(2); - error.mockRestore(); }); });