@tanstack/db
Version:
A reactive client store for building super fast apps on sync
1,162 lines (1,083 loc) • 36.9 kB
text/typescript
import { safeRandomUUID } from './utils/uuid'
import { deepEquals } from './utils'
import { withCollectionConfigFactory } from './client.js'
import { collectionOptionsClaim } from './collection-options.js'
import { registerTransactionCommitWork } from './transaction-commit-work.js'
import {
InvalidStorageDataFormatError,
InvalidStorageObjectFormatError,
LocalStorageCollectionError,
SerializationError,
StorageKeyRequiredError,
} from './errors'
import { codedMessage, codedWarning, devBuild } from './error-message.js'
import type {
BaseCollectionConfig,
CollectionConfig,
DeleteMutationFnParams,
InferSchemaOutput,
InsertMutationFnParams,
PendingMutation,
SyncConfig,
UpdateMutationFnParams,
UtilsRecord,
} from './types'
import type { StandardSchemaV1 } from '@standard-schema/spec'
/**
* Storage API interface - subset of DOM Storage that we need
*/
export type StorageApi = Pick<Storage, `getItem` | `setItem` | `removeItem`>
/**
* Storage event API - subset of Window for 'storage' events only
*/
export type StorageEventApi = {
addEventListener: (
type: `storage`,
listener: (event: StorageEvent) => void,
) => void
removeEventListener: (
type: `storage`,
listener: (event: StorageEvent) => void,
) => void
}
// Browser storage events do not reach other Collections in the writing tab.
// A sync run owns one listener for writes through the same Storage object/key.
const sameTabListeners = new WeakMap<StorageApi, Map<string, Set<() => void>>>()
function subscribeToSameTabWrites(
storage: StorageApi,
key: string,
refresh: () => void,
): () => void {
let byKey = sameTabListeners.get(storage)
if (!byKey) {
byKey = new Map()
sameTabListeners.set(storage, byKey)
}
let listeners = byKey.get(key)
if (!listeners) {
listeners = new Set()
byKey.set(key, listeners)
}
listeners.add(refresh)
return () => {
listeners.delete(refresh)
if (listeners.size === 0) byKey.delete(key)
if (byKey.size === 0) sameTabListeners.delete(storage)
}
}
function publishSameTabWrite(
storage: StorageApi,
key: string,
writer?: { listener: () => void; refresh: () => void },
): void {
for (const refresh of [...(sameTabListeners.get(storage)?.get(key) ?? [])]) {
try {
if (refresh === writer?.listener) writer.refresh()
else refresh()
} catch (error) {
// A peer's parser or sync listener cannot revoke the writer's durable
// write or prevent another peer from observing it.
console.warn(
devBuild() && process.env.NODE_ENV !== `production`
? `[LocalStorageCollection] Error refreshing a same-tab peer for storage key "${key}":`
: codedWarning(232, { storageKey: key }),
error,
)
}
}
}
/**
* Internal storage format that includes version tracking
*/
interface StoredItem<T> {
versionKey: string
data: T
}
export interface Parser {
parse: (data: string) => unknown
stringify: (data: unknown) => string
}
/**
* Configuration interface for localStorage collection options
* @template T - The type of items in the collection
* @template TSchema - The schema type for validation
* @template TKey - The type of the key returned by `getKey`
*/
export interface LocalStorageCollectionConfig<
T extends object = object,
TSchema extends StandardSchemaV1 = never,
TKey extends string | number = string | number,
> extends BaseCollectionConfig<T, TKey, TSchema> {
/**
* The key to use for storing the collection data in localStorage/sessionStorage
*/
storageKey: string
/**
* Storage API to use (defaults to window.localStorage)
* Can be any object that implements the Storage interface (e.g., sessionStorage)
*/
storage?: StorageApi
/**
* Storage event API to use for cross-tab synchronization (defaults to window)
* Can be any object that implements addEventListener/removeEventListener for storage events
*/
storageEventApi?: StorageEventApi
/**
* Parser to use for serializing and deserializing data to and from storage
* Defaults to JSON
*/
parser?: Parser
}
/**
* Type for the clear utility function
*/
export type ClearStorageFn = () => void
/**
* Type for the getStorageSize utility function
*/
export type GetStorageSizeFn = () => number
/**
* LocalStorage collection utilities type
*/
export interface LocalStorageCollectionUtils extends UtilsRecord {
clearStorage: ClearStorageFn
getStorageSize: GetStorageSizeFn
/**
* Accepts this Collection's manual mutations in write order and persists
* them to localStorage. Call it inside the transaction's mutationFn. The
* transaction's persistence receipt waits for this work even if the caller
* does not await the returned Promise. Await it when later mutationFn work
* depends on the storage write.
*
* @param transaction - The transaction containing mutations to accept
* @example
* const localSettings = createCollection(localStorageCollectionOptions({...}))
*
* const tx = createTransaction({
* mutationFn: async ({ transaction }) => {
* // Make API call first
* await api.save(...)
* // Then persist local-storage mutations after success
* await localSettings.utils.acceptMutations(transaction)
* }
* })
*/
acceptMutations: (transaction: {
mutations: Array<PendingMutation<Record<string, unknown>>>
}) => Promise<void>
}
/**
* Validates that a value can be JSON serialized
* @param parser - The parser to use for serialization
* @param value - The value to validate for JSON serialization
* @param operation - The operation type being performed (for error messages)
* @throws Error if the value cannot be JSON serialized
*/
function validateJsonSerializable(
parser: Parser,
value: any,
operation: string,
): void {
try {
parser.stringify(value)
} catch (error) {
throw new SerializationError(
operation,
error instanceof Error ? error.message : String(error),
)
}
}
/**
* Generate a UUID for version tracking
* @returns A unique identifier string for tracking data versions
*/
function generateUuid(): string {
return safeRandomUUID()
}
/**
* Encodes a key (string or number) into a storage-safe string format.
* This prevents collisions between numeric and string keys by prefixing with type information.
*
* Examples:
* - number 1 → "n:1"
* - string "1" → "s:1"
* - string "n:1" → "s:n:1"
*
* @param key - The key to encode (string or number)
* @returns Type-prefixed string that is safe for storage
*/
function encodeStorageKey(key: string | number): string {
if (typeof key === `number`) {
return `n:${key}`
}
return `s:${key}`
}
/**
* Decodes a storage key back to its original form.
* This is the inverse of encodeStorageKey.
*
* @param encodedKey - The encoded key from storage
* @returns The original key (string or number)
*/
function decodeStorageKey(encodedKey: string): string | number {
if (encodedKey.startsWith(`n:`)) {
return Number(encodedKey.slice(2))
}
if (encodedKey.startsWith(`s:`)) {
return encodedKey.slice(2)
}
// Fallback for legacy data without encoding
return encodedKey
}
function sameStorageKey(
left: string | number,
right: string | number,
): boolean {
return left === right || (Number.isNaN(left) && Number.isNaN(right))
}
/**
* Creates an in-memory storage implementation that mimics the StorageApi interface
* Used as a fallback when localStorage is not available (e.g., server-side rendering)
* @returns An object implementing the StorageApi interface using an in-memory Map
*/
function createInMemoryStorage(): StorageApi {
const storage = new Map<string, string>()
return {
getItem(key: string): string | null {
return storage.get(key) ?? null
},
setItem(key: string, value: string): void {
storage.set(key, value)
},
removeItem(key: string): void {
storage.delete(key)
},
}
}
/**
* Creates a no-op storage event API for environments without window (e.g., server-side)
* This provides the required interface but doesn't actually listen to any events
* since cross-tab synchronization is not possible in server environments
* @returns An object implementing the StorageEventApi interface with no-op methods
*/
function createNoOpStorageEventApi(): StorageEventApi {
return {
addEventListener: () => {
// No-op: cannot listen to storage events without window
},
removeEventListener: () => {
// No-op: cannot remove listeners without window
},
}
}
/**
* Creates localStorage collection options for use with a standard Collection
*
* This function creates a collection that persists data to localStorage/sessionStorage
* and synchronizes changes across browser tabs using storage events. Active
* Collections sharing this Storage object and storage key also synchronize
* writes in the same tab, without waiting for a browser event.
* Create fresh options for each direct `createCollection()` call. One options
* object contains state owned by one Collection and cannot be reused.
*
* **Fallback Behavior:**
*
* When localStorage is not available (e.g., in server-side rendering environments),
* this function automatically falls back to an in-memory storage implementation.
* This prevents errors during module initialization and allows the collection to
* work in any environment, though data will not persist across page reloads or
* be shared across tabs when using the in-memory fallback.
*
* **Using with Manual Transactions:**
*
* For manual transactions, call `utils.acceptMutations()` in your transaction's `mutationFn`
* to persist changes made during `tx.mutate()`. The transaction receipt waits for this work even
* when the call is not awaited. Await it when later mutationFn work depends on the storage write.
*
* @template TExplicit - The explicit type of items in the collection (highest priority)
* @template TSchema - The schema type for validation and type inference (second priority)
* @template TFallback - The fallback type if no explicit or schema type is provided
* @param config - Configuration options for the localStorage collection
* @returns Collection options with utilities including clearStorage, getStorageSize, and acceptMutations
*
* @example
* // Basic localStorage collection
* const collection = createCollection(
* localStorageCollectionOptions({
* storageKey: 'todos',
* getKey: (item) => item.id,
* })
* )
*
* @example
* // localStorage collection with custom storage
* const collection = createCollection(
* localStorageCollectionOptions({
* storageKey: 'todos',
* storage: window.sessionStorage, // Use sessionStorage instead
* getKey: (item) => item.id,
* })
* )
*
* @example
* // localStorage collection with mutation handlers
* const collection = createCollection(
* localStorageCollectionOptions({
* storageKey: 'todos',
* getKey: (item) => item.id,
* onInsert: async ({ transaction }) => {
* console.log('Item inserted:', transaction.mutations[0].modified)
* },
* })
* )
*
* @example
* // Using with manual transactions
* const localSettings = createCollection(
* localStorageCollectionOptions({
* storageKey: 'user-settings',
* getKey: (item) => item.id,
* })
* )
*
* const tx = createTransaction({
* mutationFn: async ({ transaction }) => {
* // Use settings data in API call
* const settingsMutations = transaction.mutations.filter(m => m.collection === localSettings)
* await api.updateUserProfile({ settings: settingsMutations[0]?.modified })
*
* // Persist local-storage mutations after API success
* await localSettings.utils.acceptMutations(transaction)
* }
* })
*
* tx.mutate(() => {
* localSettings.insert({ id: 'theme', value: 'dark' })
* apiCollection.insert({ id: 2, data: 'profile data' })
* })
*
* await tx.commit()
*/
// Overload for when schema is provided
export function localStorageCollectionOptions<
T extends StandardSchemaV1,
TKey extends string | number = string | number,
>(
config: LocalStorageCollectionConfig<InferSchemaOutput<T>, T, TKey> & {
schema: T
},
): CollectionConfig<
InferSchemaOutput<T>,
TKey,
T,
LocalStorageCollectionUtils
> & {
id: string
utils: LocalStorageCollectionUtils
schema: T
}
// Overload for when no schema is provided
// the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config
export function localStorageCollectionOptions<
T extends object,
TKey extends string | number = string | number,
>(
config: LocalStorageCollectionConfig<T, never, TKey> & {
schema?: never // prohibit schema
},
): CollectionConfig<T, TKey, never, LocalStorageCollectionUtils> & {
id: string
utils: LocalStorageCollectionUtils
schema?: never // no schema in the result
}
export function localStorageCollectionOptions(
config: LocalStorageCollectionConfig<any, any, string | number>,
): Omit<
CollectionConfig<any, string | number, any, LocalStorageCollectionUtils>,
`id`
> & {
id: string
utils: LocalStorageCollectionUtils
schema?: StandardSchemaV1
} {
// Validate required parameters
if (!config.storageKey) {
throw new StorageKeyRequiredError()
}
// Default to window.localStorage if no storage is provided
// Fall back to in-memory storage if localStorage is not available (e.g., server-side rendering)
const storage =
config.storage ||
(typeof window !== `undefined` ? window.localStorage : null) ||
createInMemoryStorage()
// Default to window for storage events if not provided
// Fall back to no-op storage event API if window is not available (e.g., server-side rendering)
const storageEventApi =
config.storageEventApi ||
(typeof window !== `undefined` ? window : null) ||
createNoOpStorageEventApi()
// Default to JSON parser if no parser is provided
const parser = config.parser || JSON
// Track the last known state to detect changes
const lastKnownData = new Map<string | number, StoredItem<any>>()
// Create the sync configuration
const sync = createLocalStorageSync<any>(
config.storageKey,
storage,
storageEventApi,
parser,
config.getKey,
lastKnownData,
)
/**
* Save data to storage
* @param dataMap - Map of items with version tracking to save to storage
*/
const saveToStorage = (
dataMap: Map<string | number, StoredItem<any>>,
mutations: Array<PendingMutation<Record<string, unknown>>>,
): {
serialized: string
persisted: Map<string | number, StoredItem<any>>
} => {
try {
// Convert Map to object format for storage
const objectData: Record<string, StoredItem<any>> = {}
dataMap.forEach((storedItem, key) => {
objectData[encodeStorageKey(key)] = storedItem
})
const serialized = parser.stringify(objectData)
// A custom parser may normalize rows while serializing. Validate and
// retain the exact snapshot before setItem makes the write durable.
const persisted =
parser === JSON
? dataMap
: readFromStorage(
config.storageKey,
storage,
parser,
config.getKey,
serialized,
)
for (const mutation of mutations) {
const storedItem = persisted.get(mutation.key)
if (mutation.type === `delete` ? storedItem : !storedItem) {
throw new InvalidStorageDataFormatError(
config.storageKey,
encodeStorageKey(mutation.key),
)
}
}
storage.setItem(config.storageKey, serialized)
return { serialized, persisted }
} catch (error) {
console.error(
devBuild() && process.env.NODE_ENV !== `production`
? `[LocalStorageCollection] Error saving data to storage key "${config.storageKey}":`
: codedMessage(218, { storageKey: config.storageKey }),
error,
)
throw error
}
}
/**
* Removes the stored snapshot and publishes removal of accepted synced rows.
* Pending optimistic mutations can still settle afterward.
*/
const clearStorage: ClearStorageFn = (): void => {
storage.removeItem(config.storageKey)
publishSameTabWrite(storage, config.storageKey)
}
/**
* Get the size of the stored data in bytes (approximate)
* @returns The approximate size in bytes of the stored collection data
*/
const getStorageSize: GetStorageSizeFn = (): number => {
const data = storage.getItem(config.storageKey)
return data ? new Blob([data]).size : 0
}
const persistMutations = (
mutations: Array<PendingMutation<Record<string, unknown>>>,
): void => {
// A peer may have written after our last storage event. Start from the
// current durable snapshot so this write preserves untouched peer rows.
const staged = readFromStorage(
config.storageKey,
storage,
parser,
config.getKey,
)
for (const mutation of mutations) {
if (mutation.type === `delete`) staged.delete(mutation.key)
else {
if (parser === JSON) {
const restored = JSON.parse(JSON.stringify(mutation.modified))
if (!sameStorageKey(config.getKey(restored), mutation.key)) {
throw new InvalidStorageDataFormatError(
config.storageKey,
encodeStorageKey(mutation.key),
)
}
}
staged.set(mutation.key, {
versionKey: generateUuid(),
data: mutation.modified,
})
}
}
const { serialized: savedRaw, persisted } = saveToStorage(staged, mutations)
try {
for (const mutation of mutations) {
const storedItem = persisted.get(mutation.key)
if (storedItem) lastKnownData.set(mutation.key, storedItem)
else lastKnownData.delete(mutation.key)
}
sync.confirmOperationsSync(mutations, persisted)
} finally {
// Storage has already accepted this snapshot. A local confirmation
// error must not suppress publication to other active Collections.
const writerRefresh = sync.manualTrigger
publishSameTabWrite(
storage,
config.storageKey,
writerRefresh
? {
listener: writerRefresh,
refresh: () =>
writerRefresh(
storage.getItem(config.storageKey) === savedRaw
? persisted
: undefined,
),
}
: undefined,
)
}
}
// Reserve automatic and manual writes in acceptance order. A rejected
// handler releases only its storage slot after its predecessor, while its
// own transaction receipt can reject immediately.
let writeTail: Promise<void> | undefined
const reserveWrite = () => {
const previous = writeTail
let release!: () => void
const slot = new Promise<void>((resolve) => {
release = resolve
})
writeTail = slot
const retire = () => {
release()
if (writeTail === slot) writeTail = undefined
}
return { previous, retire }
}
const persistAutomatic = (
mutations: Array<PendingMutation<Record<string, unknown>>>,
handler?: () => unknown | Promise<unknown>,
): Promise<unknown> => {
const { previous, retire } = reserveWrite()
// Native Storage writes synchronously. With no handler or earlier write,
// preserve that direct-mutation return boundary.
if (!handler && previous === undefined) {
try {
persistMutations(mutations)
return Promise.resolve({})
} catch (error) {
return Promise.reject(error)
} finally {
retire()
}
}
let result: unknown
try {
result = handler?.()
} catch (error) {
if (previous) void previous.then(retire)
else retire()
return Promise.reject(error)
}
return Promise.resolve(result).then(
async (value) => {
await previous
try {
persistMutations(mutations)
return value ?? {}
} finally {
retire()
}
},
(error: unknown) => {
// Rejection belongs to this transaction immediately. Only its empty
// storage slot must wait for the earlier accepted write to retire.
if (previous) void previous.then(retire)
else retire()
throw error
},
)
}
const persistManual = (
mutations: Array<PendingMutation<Record<string, unknown>>>,
): Promise<void> => {
const { previous, retire } = reserveWrite()
if (previous === undefined) {
try {
persistMutations(mutations)
return Promise.resolve()
} finally {
retire()
}
}
return previous.then(() => {
try {
persistMutations(mutations)
} finally {
retire()
}
})
}
const wrappedOnInsert = (params: InsertMutationFnParams<any>) => {
params.transaction.mutations.forEach((mutation) => {
validateJsonSerializable(parser, mutation.modified, `insert`)
})
return persistAutomatic(
params.transaction.mutations,
config.onInsert ? () => config.onInsert!(params) : undefined,
)
}
const wrappedOnUpdate = (params: UpdateMutationFnParams<any>) => {
params.transaction.mutations.forEach((mutation) => {
validateJsonSerializable(parser, mutation.modified, `update`)
})
return persistAutomatic(
params.transaction.mutations,
config.onUpdate ? () => config.onUpdate!(params) : undefined,
)
}
const wrappedOnDelete = (params: DeleteMutationFnParams<any>) =>
persistAutomatic(
params.transaction.mutations,
config.onDelete ? () => config.onDelete!(params) : undefined,
)
// Extract standard Collection config properties
// Remove localStorage-specific properties so they don't leak into the CollectionConfig
const {
storageKey: _storageKey,
storage: _storage,
storageEventApi: _storageEventApi,
parser: _parser,
onInsert: _onInsert,
onUpdate: _onUpdate,
onDelete: _onDelete,
id,
...restConfig
} = config
// Default id to a pattern based on storage key if not provided
const collectionId = id ?? `local-collection:${config.storageKey}`
/**
* Accepts this Collection's manual mutations in write order and resolves
* after their storage write. A mutationFn receipt tracks this work even
* when the caller does not await the returned Promise.
*/
const persistAcceptedMutations = async (
transaction: {
mutations: Array<PendingMutation<Record<string, unknown>>>
},
owner: PendingMutation<Record<string, unknown>>[`collection`] | undefined,
): Promise<void> => {
const collectionMutations = transaction.mutations.filter(
(mutation) => mutation.collection === owner,
)
if (collectionMutations.length === 0) {
return Promise.resolve()
}
// Validate all mutations can be serialized before modifying storage
for (const mutation of collectionMutations) {
switch (mutation.type) {
case `insert`:
case `update`:
validateJsonSerializable(parser, mutation.modified, mutation.type)
break
case `delete`:
validateJsonSerializable(parser, mutation.original, mutation.type)
break
}
}
await persistManual(collectionMutations)
}
const materializedAcceptors = new WeakSet<
LocalStorageCollectionUtils[`acceptMutations`]
>()
const acceptMutations = (transaction: {
mutations: Array<PendingMutation<Record<string, unknown>>>
}): Promise<void> => {
// A DbClient materializes module-level options into a fresh Collection.
// Route acceptance to that Collection's utility so its automatic and
// manual writes reserve slots in the same queue.
const candidates = new Set(
transaction.mutations
.filter((mutation) => mutation.collection.id === collectionId)
.map((mutation) => mutation.collection),
)
const owned = [...candidates].filter((candidate) => {
const candidateAccept = (candidate.utils as LocalStorageCollectionUtils)
.acceptMutations
return (
candidateAccept === acceptMutations ||
materializedAcceptors.has(candidateAccept)
)
})
if (owned.length > 1 || (candidates.size > 0 && owned.length === 0)) {
const work = Promise.reject(
new LocalStorageCollectionError(
devBuild() && process.env.NODE_ENV !== `production`
? `LocalStorage manual acceptance belongs to a different Collection.`
: codedMessage(231),
),
)
registerTransactionCommitWork(transaction, work)
return work
}
const owner = owned[0]
const ownerAccept = owner
? (owner.utils as LocalStorageCollectionUtils).acceptMutations
: undefined
if (ownerAccept && ownerAccept !== acceptMutations)
return ownerAccept(transaction)
const work = persistAcceptedMutations(transaction, owner)
registerTransactionCommitWork(transaction, work)
return work
}
const options = {
...restConfig,
id: collectionId,
sync,
onInsert: wrappedOnInsert,
onUpdate: wrappedOnUpdate,
onDelete: wrappedOnDelete,
utils: {
clearStorage,
getStorageSize,
acceptMutations,
},
}
let claimed = false
Object.defineProperty(options, collectionOptionsClaim, {
enumerable: true,
value: () => {
if (claimed)
throw new LocalStorageCollectionError(
devBuild() && process.env.NODE_ENV !== `production`
? `LocalStorage options can create only one Collection. Create fresh options for each Collection.`
: codedMessage(230),
)
claimed = true
},
})
return withCollectionConfigFactory(options, () => {
const materialized = localStorageCollectionOptions({
...config,
id: collectionId,
}) as unknown as typeof options
materializedAcceptors.add(materialized.utils.acceptMutations)
return materialized
})
}
/**
* Read data from storage without treating a failed read as an empty snapshot.
* @param parser - The parser to use for deserializing the data
* @param storageKey - The key used to store data in the storage API
* @param storage - The storage API to load from (localStorage, sessionStorage, etc.)
* @returns Map of stored items with version tracking
*/
function readFromStorage<T extends object>(
storageKey: string,
storage: StorageApi,
parser: Parser,
getKey: (item: T) => string | number,
knownRaw?: string,
): Map<string | number, StoredItem<T>> {
const rawData = knownRaw ?? storage.getItem(storageKey)
if (rawData === null) {
return new Map()
}
const parsed = parser.parse(rawData)
const dataMap = new Map<string | number, StoredItem<T>>()
// Handle object format where keys map to StoredItem values
if (typeof parsed === `object` && parsed !== null && !Array.isArray(parsed)) {
Object.entries(parsed).forEach(([encodedKey, value]) => {
// Runtime check to ensure the value has the expected StoredItem structure
if (
value &&
typeof value === `object` &&
`versionKey` in value &&
typeof value.versionKey === `string` &&
`data` in value &&
value.data !== null &&
typeof value.data === `object`
) {
const storedItem = value as StoredItem<T>
const decodedKey = decodeStorageKey(encodedKey)
if (
!sameStorageKey(getKey(storedItem.data), decodedKey) ||
dataMap.has(decodedKey)
) {
throw new InvalidStorageDataFormatError(storageKey, encodedKey)
}
dataMap.set(decodedKey, storedItem)
} else {
throw new InvalidStorageDataFormatError(storageKey, encodedKey)
}
})
} else {
throw new InvalidStorageObjectFormatError(storageKey)
}
return dataMap
}
/**
* Internal function to create localStorage sync configuration
* Creates a sync configuration that handles localStorage persistence and cross-tab synchronization
* @param storageKey - The key used for storing data in localStorage
* @param storage - The storage API to use (localStorage, sessionStorage, etc.)
* @param storageEventApi - The event API for listening to storage changes
* @param getKey - Function to extract the key from an item
* @param lastKnownData - Map tracking the last known state for change detection
* @returns Sync configuration with manual trigger capability
*/
function createLocalStorageSync<T extends object>(
storageKey: string,
storage: StorageApi,
storageEventApi: StorageEventApi,
parser: Parser,
getKey: (item: T) => string | number,
lastKnownData: Map<string | number, StoredItem<T>>,
): SyncConfig<T> & {
manualTrigger?: (knownSnapshot?: Map<string | number, StoredItem<T>>) => void
collection: any
confirmOperationsSync: (
mutations: Array<any>,
persisted: Map<string | number, StoredItem<T>>,
) => void
} {
let syncParams: Parameters<SyncConfig<T>[`sync`]>[0] | null = null
let collection: any = null
/**
* Compare two Maps to find differences using version keys
* @param oldData - The previous state of stored items
* @param newData - The current state of stored items
* @returns Array of changes with type, key, and value information
*/
const findChanges = (
oldData: Map<string | number, StoredItem<T>>,
newData: Map<string | number, StoredItem<T>>,
compareContent: boolean,
): Array<{
type: `insert` | `update` | `delete`
key: string | number
value?: T
}> => {
const changes: Array<{
type: `insert` | `update` | `delete`
key: string | number
value?: T
}> = []
// Check for deletions and updates
oldData.forEach((oldStoredItem, key) => {
const newStoredItem = newData.get(key)
if (!newStoredItem) {
changes.push({ type: `delete`, key, value: oldStoredItem.data })
} else if (
oldStoredItem.versionKey !== newStoredItem.versionKey ||
(compareContent &&
!deepEquals(oldStoredItem.data, newStoredItem.data) &&
// A default-JSON writer may retain a native Date while the stored
// row contains its ISO string. A peer's unrelated write must not
// publish that representation change as an update.
(parser !== JSON ||
JSON.stringify(oldStoredItem.data) !==
JSON.stringify(newStoredItem.data)))
) {
changes.push({ type: `update`, key, value: newStoredItem.data })
}
})
// Check for insertions
newData.forEach((newStoredItem, key) => {
if (!oldData.has(key)) {
changes.push({ type: `insert`, key, value: newStoredItem.data })
}
})
return changes
}
/**
* Process storage changes and update collection
* Loads new data from storage, compares with last known state, and applies changes
*/
const processStorageChanges = (
knownSnapshot?: Map<string | number, StoredItem<T>>,
) => {
if (!syncParams) return
const { begin, write, commit } = syncParams
// Load the new data
let newData: Map<string | number, StoredItem<T>>
try {
newData =
knownSnapshot ?? readFromStorage<T>(storageKey, storage, parser, getKey)
} catch (error) {
console.warn(
devBuild() && process.env.NODE_ENV !== `production`
? `[LocalStorageCollection] Error loading data from storage key "${storageKey}":`
: codedWarning(219, { storageKey }),
error,
)
return
}
// Find the specific changes
// The default-JSON writer deliberately retains native authored values.
// Peers and storage events must compare content even under the same token.
const changes = findChanges(
lastKnownData,
newData,
knownSnapshot === undefined || parser !== JSON,
)
if (changes.length > 0) {
// Reject an invalid snapshot before opening a sync transaction. This
// leaves the previous mirror available for a later valid refresh.
changes.forEach(({ type, value }) => {
if (value) validateJsonSerializable(parser, value, type)
})
begin()
changes.forEach(({ type, value }) => {
if (value) {
write({ type, value })
}
})
// A commit can synchronously publish to a subscriber that writes again.
// The nested refresh must compare against this snapshot, not its parent.
lastKnownData.clear()
newData.forEach((storedItem, key) => {
lastKnownData.set(key, storedItem)
})
commit()
}
}
const syncConfig: SyncConfig<T> & {
manualTrigger?: (
knownSnapshot?: Map<string | number, StoredItem<T>>,
) => void
} = {
rowUpdateMode: `full`,
sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
const { begin, write, commit, markReady } = params
// A failed restore cannot establish an empty authoritative snapshot.
// Read before retaining this sync run's callbacks so startup failure
// leaves no stale adapter state.
const initialData = readFromStorage<T>(
storageKey,
storage,
parser,
getKey,
)
initialData.forEach((storedItem) => {
validateJsonSerializable(parser, storedItem.data, `load`)
})
syncParams = params
collection = params.collection
// Establish the mirror and same-tab listener before restore publishes
// callbacks, which can synchronously write through an already-ready peer.
lastKnownData.clear()
initialData.forEach((storedItem, key) => {
lastKnownData.set(key, storedItem)
})
// Listen for storage events from other tabs
const handleStorageEvent = (event: StorageEvent) => {
// Only respond to changes to our specific key and from our storage
if (event.key !== storageKey || event.storageArea !== storage) {
return
}
processStorageChanges()
}
const unsubscribeSameTab = subscribeToSameTabWrites(
storage,
storageKey,
processStorageChanges,
)
try {
if (initialData.size > 0) {
begin()
initialData.forEach((storedItem) => {
write({ type: `insert`, value: storedItem.data })
})
commit()
}
// Mark collection as ready after initial load, then listen for
// browser events from other tabs.
markReady()
storageEventApi.addEventListener(`storage`, handleStorageEvent)
} catch (error) {
unsubscribeSameTab()
if (syncParams === params) {
syncParams = null
}
throw error
}
return {
cleanup: () => {
storageEventApi.removeEventListener(`storage`, handleStorageEvent)
unsubscribeSameTab()
if (syncParams === params) {
syncParams = null
}
},
}
},
/**
* Get sync metadata - returns storage key information
* @returns Object containing storage key and storage type metadata
*/
getSyncMetadata: () => ({
storageKey,
storageType:
storage === (typeof window !== `undefined` ? window.localStorage : null)
? `localStorage`
: `custom`,
}),
// Manual trigger function for local updates
manualTrigger: processStorageChanges,
}
/**
* Confirms mutations by writing them through the sync interface
* This moves mutations from optimistic to synced state
* @param mutations - Array of mutation objects to confirm
*/
const confirmOperationsSync = (
mutations: Array<any>,
persisted: Map<string | number, StoredItem<T>>,
) => {
if (!syncParams) {
// Sync not initialized yet, mutations will be handled on next sync
return
}
const { begin, write, commit } = syncParams
// Write the mutations through sync to confirm them
begin()
mutations.forEach((mutation: any) => {
write({
// A peer may have inserted the same key while this local mutation
// was pending. Full-row update confirms the accepted stored value in
// either case without a duplicate insert.
type: mutation.type === `delete` ? `delete` : `update`,
value:
mutation.type === `delete`
? mutation.original
: persisted.get(mutation.key)!.data,
})
})
commit()
}
return {
...syncConfig,
get collection() {
return collection
},
confirmOperationsSync,
}
}