@tanstack/db
Version:
A reactive client store for building super fast apps on sync
1,553 lines (1,393 loc) • 82.6 kB
text/typescript
import { withArrayChangeTracking, withChangeTracking } from "./proxy"
import { deepEquals } from "./utils"
import { SortedMap } from "./SortedMap"
import {
createSingleRowRefProxy,
toExpression,
} from "./query/builder/ref-proxy"
import { BTreeIndex } from "./indexes/btree-index.js"
import { IndexProxy, LazyIndexWrapper } from "./indexes/lazy-index.js"
import { ensureIndexForExpression } from "./indexes/auto-index.js"
import { createTransaction, getActiveTransaction } from "./transactions"
import {
CollectionInErrorStateError,
CollectionIsInErrorStateError,
CollectionRequiresConfigError,
CollectionRequiresSyncConfigError,
DeleteKeyNotFoundError,
DuplicateKeyError,
DuplicateKeySyncError,
InvalidCollectionStatusTransitionError,
InvalidSchemaError,
KeyUpdateNotAllowedError,
MissingDeleteHandlerError,
MissingInsertHandlerError,
MissingUpdateArgumentError,
MissingUpdateHandlerError,
NegativeActiveSubscribersError,
NoKeysPassedToDeleteError,
NoKeysPassedToUpdateError,
NoPendingSyncTransactionCommitError,
NoPendingSyncTransactionWriteError,
SchemaMustBeSynchronousError,
SchemaValidationError,
SyncCleanupError,
SyncTransactionAlreadyCommittedError,
SyncTransactionAlreadyCommittedWriteError,
UndefinedKeyError,
UpdateKeyNotFoundError,
} from "./errors"
import { createFilteredCallback, currentStateAsChanges } from "./change-events"
import { CollectionEvents } from "./collection-events.js"
import type {
AllCollectionEvents,
CollectionEventHandler,
} from "./collection-events.js"
import type { Transaction } from "./transactions"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import type { SingleRowRefProxy } from "./query/builder/ref-proxy"
import type {
ChangeListener,
ChangeMessage,
CollectionConfig,
CollectionStatus,
CurrentStateAsChangesOptions,
Fn,
InferSchemaInput,
InferSchemaOutput,
InsertConfig,
OperationConfig,
OptimisticChangeMessage,
PendingMutation,
StandardSchema,
SubscribeChangesOptions,
Transaction as TransactionType,
TransactionWithMutations,
UtilsRecord,
WritableDeep,
} from "./types"
import type { IndexOptions } from "./indexes/index-options.js"
import type { BaseIndex, IndexResolver } from "./indexes/base-index.js"
interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
committed: boolean
operations: Array<OptimisticChangeMessage<T>>
truncate?: boolean
deletedKeys: Set<string | number>
}
/**
* Enhanced Collection interface that includes both data type T and utilities TUtils
* @template T - The type of items in the collection
* @template TKey - The type of the key for the collection
* @template TUtils - The utilities record type
* @template TInsertInput - The type for insert operations (can be different from T for schemas with defaults)
*/
export interface Collection<
T extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TUtils extends UtilsRecord = {},
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TInsertInput extends object = T,
> extends CollectionImpl<T, TKey, TUtils, TSchema, TInsertInput> {
readonly utils: TUtils
}
/**
* Creates a new Collection instance with the given configuration
*
* @template T - The schema type if a schema is provided, otherwise the type of items in the collection
* @template TKey - The type of the key for the collection
* @template TUtils - The utilities record type
* @param options - Collection options with optional utilities
* @returns A new Collection with utilities exposed both at top level and under .utils
*
* @example
* // Pattern 1: With operation handlers (direct collection calls)
* const todos = createCollection({
* id: "todos",
* getKey: (todo) => todo.id,
* schema,
* onInsert: async ({ transaction, collection }) => {
* // Send to API
* await api.createTodo(transaction.mutations[0].modified)
* },
* onUpdate: async ({ transaction, collection }) => {
* await api.updateTodo(transaction.mutations[0].modified)
* },
* onDelete: async ({ transaction, collection }) => {
* await api.deleteTodo(transaction.mutations[0].key)
* },
* sync: { sync: () => {} }
* })
*
* // Direct usage (handlers manage transactions)
* const tx = todos.insert({ id: "1", text: "Buy milk", completed: false })
* await tx.isPersisted.promise
*
* @example
* // Pattern 2: Manual transaction management
* const todos = createCollection({
* getKey: (todo) => todo.id,
* schema: todoSchema,
* sync: { sync: () => {} }
* })
*
* // Explicit transaction usage
* const tx = createTransaction({
* mutationFn: async ({ transaction }) => {
* // Handle all mutations in transaction
* await api.saveChanges(transaction.mutations)
* }
* })
*
* tx.mutate(() => {
* todos.insert({ id: "1", text: "Buy milk" })
* todos.update("2", draft => { draft.completed = true })
* })
*
* await tx.isPersisted.promise
*
* @example
* // Using schema for type inference (preferred as it also gives you client side validation)
* const todoSchema = z.object({
* id: z.string(),
* title: z.string(),
* completed: z.boolean()
* })
*
* const todos = createCollection({
* schema: todoSchema,
* getKey: (todo) => todo.id,
* sync: { sync: () => {} }
* })
*
*/
// Overload for when schema is provided
export function createCollection<
T extends StandardSchemaV1,
TKey extends string | number = string | number,
TUtils extends UtilsRecord = {},
>(
options: CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
schema: T
utils?: TUtils
}
): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<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 createCollection<
T extends object,
TKey extends string | number = string | number,
TUtils extends UtilsRecord = {},
>(
options: CollectionConfig<T, TKey, never> & {
schema?: never // prohibit schema if an explicit type is provided
utils?: TUtils
}
): Collection<T, TKey, TUtils, never, T>
// Implementation
export function createCollection(
options: CollectionConfig<any, string | number, any> & {
schema?: StandardSchemaV1
utils?: UtilsRecord
}
): Collection<any, string | number, UtilsRecord, any, any> {
const collection = new CollectionImpl<any, string | number, any, any, any>(
options
)
// Copy utils to both top level and .utils namespace
if (options.utils) {
collection.utils = { ...options.utils }
} else {
collection.utils = {}
}
return collection
}
export class CollectionImpl<
TOutput extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TUtils extends UtilsRecord = {},
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TInput extends object = TOutput,
> {
public config: CollectionConfig<TOutput, TKey, TSchema>
// Core state - make public for testing
public transactions: SortedMap<string, Transaction<any>>
public pendingSyncedTransactions: Array<PendingSyncedTransaction<TOutput>> =
[]
public syncedData: Map<TKey, TOutput> | SortedMap<TKey, TOutput>
public syncedMetadata = new Map<TKey, unknown>()
// Optimistic state tracking - make public for testing
public optimisticUpserts = new Map<TKey, TOutput>()
public optimisticDeletes = new Set<TKey>()
// Cached size for performance
private _size = 0
// Index storage
private lazyIndexes = new Map<number, LazyIndexWrapper<TKey>>()
private resolvedIndexes = new Map<number, BaseIndex<TKey>>()
private isIndexesResolved = false
private indexCounter = 0
// Event system
private changeListeners = new Set<ChangeListener<TOutput, TKey>>()
private changeKeyListeners = new Map<
TKey,
Set<ChangeListener<TOutput, TKey>>
>()
// Utilities namespace
// This is populated by createCollection
public utils: Record<string, Fn> = {}
// State used for computing the change events
private syncedKeys = new Set<TKey>()
private preSyncVisibleState = new Map<TKey, TOutput>()
private recentlySyncedKeys = new Set<TKey>()
private hasReceivedFirstCommit = false
private isCommittingSyncTransactions = false
// Array to store one-time ready listeners
private onFirstReadyCallbacks: Array<() => void> = []
private hasBeenReady = false
// Event batching for preventing duplicate emissions during transaction flows
private batchedEvents: Array<ChangeMessage<TOutput, TKey>> = []
private shouldBatchEvents = false
// Lifecycle management
private _status: CollectionStatus = `idle`
private activeSubscribersCount = 0
private gcTimeoutId: ReturnType<typeof setTimeout> | null = null
private preloadPromise: Promise<void> | null = null
private syncCleanupFn: (() => void) | null = null
// Event system
private events: CollectionEvents
/**
* Register a callback to be executed when the collection first becomes ready
* Useful for preloading collections
* @param callback Function to call when the collection first becomes ready
* @example
* collection.onFirstReady(() => {
* console.log('Collection is ready for the first time')
* // Safe to access collection.state now
* })
*/
public onFirstReady(callback: () => void): void {
// If already ready, call immediately
if (this.hasBeenReady) {
callback()
return
}
this.onFirstReadyCallbacks.push(callback)
}
/**
* Check if the collection is ready for use
* Returns true if the collection has been marked as ready by its sync implementation
* @returns true if the collection is ready, false otherwise
* @example
* if (collection.isReady()) {
* console.log('Collection is ready, data is available')
* // Safe to access collection.state
* } else {
* console.log('Collection is still loading')
* }
*/
public isReady(): boolean {
return this._status === `ready`
}
/**
* Mark the collection as ready for use
* This is called by sync implementations to explicitly signal that the collection is ready,
* providing a more intuitive alternative to using commits for readiness signaling
* @private - Should only be called by sync implementations
*/
private markReady(): void {
// Can transition to ready from loading or initialCommit states
if (this._status === `loading` || this._status === `initialCommit`) {
this.setStatus(`ready`)
// Call any registered first ready callbacks (only on first time becoming ready)
if (!this.hasBeenReady) {
this.hasBeenReady = true
// Also mark as having received first commit for backwards compatibility
if (!this.hasReceivedFirstCommit) {
this.hasReceivedFirstCommit = true
}
const callbacks = [...this.onFirstReadyCallbacks]
this.onFirstReadyCallbacks = []
callbacks.forEach((callback) => callback())
}
}
// Always notify dependents when markReady is called, after status is set
// This ensures live queries get notified when their dependencies become ready
if (this.changeListeners.size > 0) {
this.emitEmptyReadyEvent()
}
}
public id = ``
/**
* Gets the current status of the collection
*/
public get status(): CollectionStatus {
return this._status
}
/**
* Get the number of subscribers to the collection
*/
public get subscriberCount(): number {
return this.activeSubscribersCount
}
/**
* Validates that the collection is in a usable state for data operations
* @private
*/
private validateCollectionUsable(operation: string): void {
switch (this._status) {
case `error`:
throw new CollectionInErrorStateError(operation, this.id)
case `cleaned-up`:
// Automatically restart the collection when operations are called on cleaned-up collections
this.startSync()
break
}
}
/**
* Validates state transitions to prevent invalid status changes
* @private
*/
private validateStatusTransition(
from: CollectionStatus,
to: CollectionStatus
): void {
if (from === to) {
// Allow same state transitions
return
}
const validTransitions: Record<
CollectionStatus,
Array<CollectionStatus>
> = {
idle: [`loading`, `error`, `cleaned-up`],
loading: [`initialCommit`, `ready`, `error`, `cleaned-up`],
initialCommit: [`ready`, `error`, `cleaned-up`],
ready: [`cleaned-up`, `error`],
error: [`cleaned-up`, `idle`],
"cleaned-up": [`loading`, `error`],
}
if (!validTransitions[from].includes(to)) {
throw new InvalidCollectionStatusTransitionError(from, to, this.id)
}
}
/**
* Safely update the collection status with validation
* @private
*/
private setStatus(newStatus: CollectionStatus): void {
this.validateStatusTransition(this._status, newStatus)
const previousStatus = this._status
this._status = newStatus
// Resolve indexes when collection becomes ready
if (newStatus === `ready` && !this.isIndexesResolved) {
// Resolve indexes asynchronously without blocking
this.resolveAllIndexes().catch((error) => {
console.warn(`Failed to resolve indexes:`, error)
})
}
// Emit event
this.events.emitStatusChange(newStatus, previousStatus)
}
/**
* Creates a new Collection instance
*
* @param config - Configuration object for the collection
* @throws Error if sync config is missing
*/
constructor(config: CollectionConfig<TOutput, TKey, TSchema>) {
// eslint-disable-next-line
if (!config) {
throw new CollectionRequiresConfigError()
}
if (config.id) {
this.id = config.id
} else {
this.id = crypto.randomUUID()
}
// eslint-disable-next-line
if (!config.sync) {
throw new CollectionRequiresSyncConfigError()
}
this.transactions = new SortedMap<string, Transaction<any>>((a, b) =>
a.compareCreatedAt(b)
)
// Set default values for optional config properties
this.config = {
...config,
autoIndex: config.autoIndex ?? `eager`,
}
// Set up data storage with optional comparison function
if (this.config.compare) {
this.syncedData = new SortedMap<TKey, TOutput>(this.config.compare)
} else {
this.syncedData = new Map<TKey, TOutput>()
}
// Set up event system
this.events = new CollectionEvents(this)
// Only start sync immediately if explicitly enabled
if (config.startSync === true) {
this.startSync()
}
}
/**
* Start sync immediately - internal method for compiled queries
* This bypasses lazy loading for special cases like live query results
*/
public startSyncImmediate(): void {
this.startSync()
}
/**
* Start the sync process for this collection
* This is called when the collection is first accessed or preloaded
*/
private startSync(): void {
if (this._status !== `idle` && this._status !== `cleaned-up`) {
return // Already started or in progress
}
this.setStatus(`loading`)
try {
const cleanupFn = this.config.sync.sync({
collection: this,
begin: () => {
this.pendingSyncedTransactions.push({
committed: false,
operations: [],
deletedKeys: new Set(),
})
},
write: (messageWithoutKey: Omit<ChangeMessage<TOutput>, `key`>) => {
const pendingTransaction =
this.pendingSyncedTransactions[
this.pendingSyncedTransactions.length - 1
]
if (!pendingTransaction) {
throw new NoPendingSyncTransactionWriteError()
}
if (pendingTransaction.committed) {
throw new SyncTransactionAlreadyCommittedWriteError()
}
const key = this.getKeyFromItem(messageWithoutKey.value)
// Check if an item with this key already exists when inserting
if (messageWithoutKey.type === `insert`) {
const insertingIntoExistingSynced = this.syncedData.has(key)
const hasPendingDeleteForKey =
pendingTransaction.deletedKeys.has(key)
const isTruncateTransaction = pendingTransaction.truncate === true
// Allow insert after truncate in the same transaction even if it existed in syncedData
if (
insertingIntoExistingSynced &&
!hasPendingDeleteForKey &&
!isTruncateTransaction
) {
throw new DuplicateKeySyncError(key, this.id)
}
}
const message: ChangeMessage<TOutput> = {
...messageWithoutKey,
key,
}
pendingTransaction.operations.push(message)
if (messageWithoutKey.type === `delete`) {
pendingTransaction.deletedKeys.add(key)
}
},
commit: () => {
const pendingTransaction =
this.pendingSyncedTransactions[
this.pendingSyncedTransactions.length - 1
]
if (!pendingTransaction) {
throw new NoPendingSyncTransactionCommitError()
}
if (pendingTransaction.committed) {
throw new SyncTransactionAlreadyCommittedError()
}
pendingTransaction.committed = true
// Update status to initialCommit when transitioning from loading
// This indicates we're in the process of committing the first transaction
if (this._status === `loading`) {
this.setStatus(`initialCommit`)
}
this.commitPendingTransactions()
},
markReady: () => {
this.markReady()
},
truncate: () => {
const pendingTransaction =
this.pendingSyncedTransactions[
this.pendingSyncedTransactions.length - 1
]
if (!pendingTransaction) {
throw new NoPendingSyncTransactionWriteError()
}
if (pendingTransaction.committed) {
throw new SyncTransactionAlreadyCommittedWriteError()
}
// Clear all operations from the current transaction
pendingTransaction.operations = []
pendingTransaction.deletedKeys.clear()
// Mark the transaction as a truncate operation. During commit, this triggers:
// - Delete events for all previously synced keys (excluding optimistic-deleted keys)
// - Clearing of syncedData/syncedMetadata
// - Subsequent synced ops applied on the fresh base
// - Finally, optimistic mutations re-applied on top (single batch)
pendingTransaction.truncate = true
},
})
// Store cleanup function if provided
this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null
} catch (error) {
this.setStatus(`error`)
throw error
}
}
/**
* Preload the collection data by starting sync if not already started
* Multiple concurrent calls will share the same promise
*/
public preload(): Promise<void> {
if (this.preloadPromise) {
return this.preloadPromise
}
this.preloadPromise = new Promise<void>((resolve, reject) => {
if (this._status === `ready`) {
resolve()
return
}
if (this._status === `error`) {
reject(new CollectionIsInErrorStateError())
return
}
// Register callback BEFORE starting sync to avoid race condition
this.onFirstReady(() => {
resolve()
})
// Start sync if collection hasn't started yet or was cleaned up
if (this._status === `idle` || this._status === `cleaned-up`) {
try {
this.startSync()
} catch (error) {
reject(error)
return
}
}
})
return this.preloadPromise
}
/**
* Clean up the collection by stopping sync and clearing data
* This can be called manually or automatically by garbage collection
*/
public async cleanup(): Promise<void> {
// Clear GC timeout
if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
this.gcTimeoutId = null
}
// Stop sync - wrap in try/catch since it's user-provided code
try {
if (this.syncCleanupFn) {
this.syncCleanupFn()
this.syncCleanupFn = null
}
} catch (error) {
// Re-throw in a microtask to surface the error after cleanup completes
queueMicrotask(() => {
if (error instanceof Error) {
// Preserve the original error and stack trace
const wrappedError = new SyncCleanupError(this.id, error)
wrappedError.cause = error
wrappedError.stack = error.stack
throw wrappedError
} else {
throw new SyncCleanupError(this.id, error as Error | string)
}
})
}
// Clear data
this.syncedData.clear()
this.syncedMetadata.clear()
this.optimisticUpserts.clear()
this.optimisticDeletes.clear()
this._size = 0
this.pendingSyncedTransactions = []
this.syncedKeys.clear()
this.hasReceivedFirstCommit = false
this.hasBeenReady = false
this.onFirstReadyCallbacks = []
this.preloadPromise = null
this.batchedEvents = []
this.shouldBatchEvents = false
this.events.cleanup()
// Update status
this.setStatus(`cleaned-up`)
return Promise.resolve()
}
/**
* Start the garbage collection timer
* Called when the collection becomes inactive (no subscribers)
*/
private startGCTimer(): void {
if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
}
const gcTime = this.config.gcTime ?? 300000 // 5 minutes default
// If gcTime is 0, GC is disabled
if (gcTime === 0) {
return
}
this.gcTimeoutId = setTimeout(() => {
if (this.activeSubscribersCount === 0) {
this.cleanup()
}
}, gcTime)
}
/**
* Cancel the garbage collection timer
* Called when the collection becomes active again
*/
private cancelGCTimer(): void {
if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
this.gcTimeoutId = null
}
}
/**
* Increment the active subscribers count and start sync if needed
*/
private addSubscriber(): void {
const previousSubscriberCount = this.activeSubscribersCount
this.activeSubscribersCount++
this.cancelGCTimer()
// Start sync if collection was cleaned up
if (this._status === `cleaned-up` || this._status === `idle`) {
this.startSync()
}
this.events.emitSubscribersChange(
this.activeSubscribersCount,
previousSubscriberCount
)
}
/**
* Decrement the active subscribers count and start GC timer if needed
*/
private removeSubscriber(): void {
const previousSubscriberCount = this.activeSubscribersCount
this.activeSubscribersCount--
if (this.activeSubscribersCount === 0) {
this.startGCTimer()
} else if (this.activeSubscribersCount < 0) {
throw new NegativeActiveSubscribersError()
}
this.events.emitSubscribersChange(
this.activeSubscribersCount,
previousSubscriberCount
)
}
/**
* Recompute optimistic state from active transactions
*/
private recomputeOptimisticState(
triggeredByUserAction: boolean = false
): void {
// Skip redundant recalculations when we're in the middle of committing sync transactions
if (this.isCommittingSyncTransactions) {
return
}
const previousState = new Map(this.optimisticUpserts)
const previousDeletes = new Set(this.optimisticDeletes)
// Clear current optimistic state
this.optimisticUpserts.clear()
this.optimisticDeletes.clear()
const activeTransactions: Array<Transaction<any>> = []
for (const transaction of this.transactions.values()) {
if (![`completed`, `failed`].includes(transaction.state)) {
activeTransactions.push(transaction)
}
}
// Apply active transactions only (completed transactions are handled by sync operations)
for (const transaction of activeTransactions) {
for (const mutation of transaction.mutations) {
if (mutation.collection === this && mutation.optimistic) {
switch (mutation.type) {
case `insert`:
case `update`:
this.optimisticUpserts.set(
mutation.key,
mutation.modified as TOutput
)
this.optimisticDeletes.delete(mutation.key)
break
case `delete`:
this.optimisticUpserts.delete(mutation.key)
this.optimisticDeletes.add(mutation.key)
break
}
}
}
}
// Update cached size
this._size = this.calculateSize()
// Collect events for changes
const events: Array<ChangeMessage<TOutput, TKey>> = []
this.collectOptimisticChanges(previousState, previousDeletes, events)
// Filter out events for recently synced keys to prevent duplicates
// BUT: Only filter out events that are actually from sync operations
// New user transactions should NOT be filtered even if the key was recently synced
const filteredEventsBySyncStatus = events.filter((event) => {
if (!this.recentlySyncedKeys.has(event.key)) {
return true // Key not recently synced, allow event through
}
// Key was recently synced - allow if this is a user-triggered action
if (triggeredByUserAction) {
return true
}
// Otherwise filter out duplicate sync events
return false
})
// Filter out redundant delete events if there are pending sync transactions
// that will immediately restore the same data, but only for completed transactions
// IMPORTANT: Skip complex filtering for user-triggered actions to prevent UI blocking
if (this.pendingSyncedTransactions.length > 0 && !triggeredByUserAction) {
const pendingSyncKeys = new Set<TKey>()
// Collect keys from pending sync operations
for (const transaction of this.pendingSyncedTransactions) {
for (const operation of transaction.operations) {
pendingSyncKeys.add(operation.key as TKey)
}
}
// Only filter out delete events for keys that:
// 1. Have pending sync operations AND
// 2. Are from completed transactions (being cleaned up)
const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
// Check if this delete is from clearing optimistic state of completed transactions
// We can infer this by checking if we have no remaining optimistic mutations for this key
const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
tx.mutations.some(
(m) => m.collection === this && m.key === event.key
)
)
if (!hasActiveOptimisticMutation) {
return false // Skip this delete event as sync will restore the data
}
}
return true
})
// Update indexes for the filtered events
if (filteredEvents.length > 0) {
this.updateIndexes(filteredEvents)
}
this.emitEvents(filteredEvents, triggeredByUserAction)
} else {
// Update indexes for all events
if (filteredEventsBySyncStatus.length > 0) {
this.updateIndexes(filteredEventsBySyncStatus)
}
// Emit all events if no pending sync transactions
this.emitEvents(filteredEventsBySyncStatus, triggeredByUserAction)
}
}
/**
* Calculate the current size based on synced data and optimistic changes
*/
private calculateSize(): number {
const syncedSize = this.syncedData.size
const deletesFromSynced = Array.from(this.optimisticDeletes).filter(
(key) => this.syncedData.has(key) && !this.optimisticUpserts.has(key)
).length
const upsertsNotInSynced = Array.from(this.optimisticUpserts.keys()).filter(
(key) => !this.syncedData.has(key)
).length
return syncedSize - deletesFromSynced + upsertsNotInSynced
}
/**
* Collect events for optimistic changes
*/
private collectOptimisticChanges(
previousUpserts: Map<TKey, TOutput>,
previousDeletes: Set<TKey>,
events: Array<ChangeMessage<TOutput, TKey>>
): void {
const allKeys = new Set([
...previousUpserts.keys(),
...this.optimisticUpserts.keys(),
...previousDeletes,
...this.optimisticDeletes,
])
for (const key of allKeys) {
const currentValue = this.get(key)
const previousValue = this.getPreviousValue(
key,
previousUpserts,
previousDeletes
)
if (previousValue !== undefined && currentValue === undefined) {
events.push({ type: `delete`, key, value: previousValue })
} else if (previousValue === undefined && currentValue !== undefined) {
events.push({ type: `insert`, key, value: currentValue })
} else if (
previousValue !== undefined &&
currentValue !== undefined &&
previousValue !== currentValue
) {
events.push({
type: `update`,
key,
value: currentValue,
previousValue,
})
}
}
}
/**
* Get the previous value for a key given previous optimistic state
*/
private getPreviousValue(
key: TKey,
previousUpserts: Map<TKey, TOutput>,
previousDeletes: Set<TKey>
): TOutput | undefined {
if (previousDeletes.has(key)) {
return undefined
}
if (previousUpserts.has(key)) {
return previousUpserts.get(key)
}
return this.syncedData.get(key)
}
/**
* Emit an empty ready event to notify subscribers that the collection is ready
* This bypasses the normal empty array check in emitEvents
*/
private emitEmptyReadyEvent(): void {
// Emit empty array directly to all listeners
for (const listener of this.changeListeners) {
listener([])
}
// Emit to key-specific listeners
for (const [_key, keyListeners] of this.changeKeyListeners) {
for (const listener of keyListeners) {
listener([])
}
}
}
/**
* Emit events either immediately or batch them for later emission
*/
private emitEvents(
changes: Array<ChangeMessage<TOutput, TKey>>,
forceEmit = false
): void {
// Skip batching for user actions (forceEmit=true) to keep UI responsive
if (this.shouldBatchEvents && !forceEmit) {
// Add events to the batch
this.batchedEvents.push(...changes)
return
}
// Either we're not batching, or we're forcing emission (user action or ending batch cycle)
let eventsToEmit = changes
// If we have batched events and this is a forced emit, combine them
if (this.batchedEvents.length > 0 && forceEmit) {
eventsToEmit = [...this.batchedEvents, ...changes]
this.batchedEvents = []
this.shouldBatchEvents = false
}
if (eventsToEmit.length === 0) return
// Emit to all listeners
for (const listener of this.changeListeners) {
listener(eventsToEmit)
}
// Emit to key-specific listeners
if (this.changeKeyListeners.size > 0) {
// Group changes by key, but only for keys that have listeners
const changesByKey = new Map<TKey, Array<ChangeMessage<TOutput, TKey>>>()
for (const change of eventsToEmit) {
if (this.changeKeyListeners.has(change.key)) {
if (!changesByKey.has(change.key)) {
changesByKey.set(change.key, [])
}
changesByKey.get(change.key)!.push(change)
}
}
// Emit batched changes to each key's listeners
for (const [key, keyChanges] of changesByKey) {
const keyListeners = this.changeKeyListeners.get(key)!
for (const listener of keyListeners) {
listener(keyChanges)
}
}
}
}
/**
* Get the current value for a key (virtual derived state)
*/
public get(key: TKey): TOutput | undefined {
// Check if optimistically deleted
if (this.optimisticDeletes.has(key)) {
return undefined
}
// Check optimistic upserts first
if (this.optimisticUpserts.has(key)) {
return this.optimisticUpserts.get(key)
}
// Fall back to synced data
return this.syncedData.get(key)
}
/**
* Check if a key exists in the collection (virtual derived state)
*/
public has(key: TKey): boolean {
// Check if optimistically deleted
if (this.optimisticDeletes.has(key)) {
return false
}
// Check optimistic upserts first
if (this.optimisticUpserts.has(key)) {
return true
}
// Fall back to synced data
return this.syncedData.has(key)
}
/**
* Get the current size of the collection (cached)
*/
public get size(): number {
return this._size
}
/**
* Get all keys (virtual derived state)
*/
public *keys(): IterableIterator<TKey> {
// Yield keys from synced data, skipping any that are deleted.
for (const key of this.syncedData.keys()) {
if (!this.optimisticDeletes.has(key)) {
yield key
}
}
// Yield keys from upserts that were not already in synced data.
for (const key of this.optimisticUpserts.keys()) {
if (!this.syncedData.has(key) && !this.optimisticDeletes.has(key)) {
// The optimisticDeletes check is technically redundant if inserts/updates always remove from deletes,
// but it's safer to keep it.
yield key
}
}
}
/**
* Get all values (virtual derived state)
*/
public *values(): IterableIterator<TOutput> {
for (const key of this.keys()) {
const value = this.get(key)
if (value !== undefined) {
yield value
}
}
}
/**
* Get all entries (virtual derived state)
*/
public *entries(): IterableIterator<[TKey, TOutput]> {
for (const key of this.keys()) {
const value = this.get(key)
if (value !== undefined) {
yield [key, value]
}
}
}
/**
* Get all entries (virtual derived state)
*/
public *[Symbol.iterator](): IterableIterator<[TKey, TOutput]> {
for (const [key, value] of this.entries()) {
yield [key, value]
}
}
/**
* Execute a callback for each entry in the collection
*/
public forEach(
callbackfn: (value: TOutput, key: TKey, index: number) => void
): void {
let index = 0
for (const [key, value] of this.entries()) {
callbackfn(value, key, index++)
}
}
/**
* Create a new array with the results of calling a function for each entry in the collection
*/
public map<U>(
callbackfn: (value: TOutput, key: TKey, index: number) => U
): Array<U> {
const result: Array<U> = []
let index = 0
for (const [key, value] of this.entries()) {
result.push(callbackfn(value, key, index++))
}
return result
}
/**
* Attempts to commit pending synced transactions if there are no active transactions
* This method processes operations from pending transactions and applies them to the synced data
*/
commitPendingTransactions = () => {
// Check if there are any persisting transaction
let hasPersistingTransaction = false
for (const transaction of this.transactions.values()) {
if (transaction.state === `persisting`) {
hasPersistingTransaction = true
break
}
}
// pending synced transactions could be either `committed` or still open.
// we only want to process `committed` transactions here
const {
committedSyncedTransactions,
uncommittedSyncedTransactions,
hasTruncateSync,
} = this.pendingSyncedTransactions.reduce(
(acc, t) => {
if (t.committed) {
acc.committedSyncedTransactions.push(t)
if (t.truncate === true) {
acc.hasTruncateSync = true
}
} else {
acc.uncommittedSyncedTransactions.push(t)
}
return acc
},
{
committedSyncedTransactions: [] as Array<
PendingSyncedTransaction<TOutput>
>,
uncommittedSyncedTransactions: [] as Array<
PendingSyncedTransaction<TOutput>
>,
hasTruncateSync: false,
}
)
if (!hasPersistingTransaction || hasTruncateSync) {
// Set flag to prevent redundant optimistic state recalculations
this.isCommittingSyncTransactions = true
// First collect all keys that will be affected by sync operations
const changedKeys = new Set<TKey>()
for (const transaction of committedSyncedTransactions) {
for (const operation of transaction.operations) {
changedKeys.add(operation.key as TKey)
}
}
// Use pre-captured state if available (from optimistic scenarios),
// otherwise capture current state (for pure sync scenarios)
let currentVisibleState = this.preSyncVisibleState
if (currentVisibleState.size === 0) {
// No pre-captured state, capture it now for pure sync operations
currentVisibleState = new Map<TKey, TOutput>()
for (const key of changedKeys) {
const currentValue = this.get(key)
if (currentValue !== undefined) {
currentVisibleState.set(key, currentValue)
}
}
}
const events: Array<ChangeMessage<TOutput, TKey>> = []
const rowUpdateMode = this.config.sync.rowUpdateMode || `partial`
for (const transaction of committedSyncedTransactions) {
// Handle truncate operations first
if (transaction.truncate) {
// TRUNCATE PHASE
// 1) Emit a delete for every currently-synced key so downstream listeners/indexes
// observe a clear-before-rebuild. We intentionally skip keys already in
// optimisticDeletes because their delete was previously emitted by the user.
for (const key of this.syncedData.keys()) {
if (this.optimisticDeletes.has(key)) continue
const previousValue =
this.optimisticUpserts.get(key) || this.syncedData.get(key)
if (previousValue !== undefined) {
events.push({ type: `delete`, key, value: previousValue })
}
}
// 2) Clear the authoritative synced base. Subsequent server ops in this
// same commit will rebuild the base atomically.
this.syncedData.clear()
this.syncedMetadata.clear()
this.syncedKeys.clear()
// 3) Clear currentVisibleState for truncated keys to ensure subsequent operations
// are compared against the post-truncate state (undefined) rather than pre-truncate state
// This ensures that re-inserted keys are emitted as INSERT events, not UPDATE events
for (const key of changedKeys) {
currentVisibleState.delete(key)
}
}
for (const operation of transaction.operations) {
const key = operation.key as TKey
this.syncedKeys.add(key)
// Update metadata
switch (operation.type) {
case `insert`:
this.syncedMetadata.set(key, operation.metadata)
break
case `update`:
this.syncedMetadata.set(
key,
Object.assign(
{},
this.syncedMetadata.get(key),
operation.metadata
)
)
break
case `delete`:
this.syncedMetadata.delete(key)
break
}
// Update synced data
switch (operation.type) {
case `insert`:
this.syncedData.set(key, operation.value)
break
case `update`: {
if (rowUpdateMode === `partial`) {
const updatedValue = Object.assign(
{},
this.syncedData.get(key),
operation.value
)
this.syncedData.set(key, updatedValue)
} else {
this.syncedData.set(key, operation.value)
}
break
}
case `delete`:
this.syncedData.delete(key)
break
}
}
}
// After applying synced operations, if this commit included a truncate,
// re-apply optimistic mutations on top of the fresh synced base. This ensures
// the UI preserves local intent while respecting server rebuild semantics.
// Ordering: deletes (above) -> server ops (just applied) -> optimistic upserts.
if (hasTruncateSync) {
// Avoid duplicating keys that were inserted/updated by synced operations in this commit
const syncedInsertedOrUpdatedKeys = new Set<TKey>()
for (const t of committedSyncedTransactions) {
for (const op of t.operations) {
if (op.type === `insert` || op.type === `update`) {
syncedInsertedOrUpdatedKeys.add(op.key as TKey)
}
}
}
// Build re-apply sets from ACTIVE optimistic transactions against the new synced base
// We do not copy maps; we compute intent directly from transactions to avoid drift.
const reapplyUpserts = new Map<TKey, TOutput>()
const reapplyDeletes = new Set<TKey>()
for (const tx of this.transactions.values()) {
if ([`completed`, `failed`].includes(tx.state)) continue
for (const mutation of tx.mutations) {
if (mutation.collection !== this || !mutation.optimistic) continue
const key = mutation.key as TKey
switch (mutation.type) {
case `insert`:
reapplyUpserts.set(key, mutation.modified as TOutput)
reapplyDeletes.delete(key)
break
case `update`: {
const base = this.syncedData.get(key)
const next = base
? (Object.assign({}, base, mutation.changes) as TOutput)
: (mutation.modified as TOutput)
reapplyUpserts.set(key, next)
reapplyDeletes.delete(key)
break
}
case `delete`:
reapplyUpserts.delete(key)
reapplyDeletes.add(key)
break
}
}
}
// Emit inserts for re-applied upserts, skipping any keys that have an optimistic delete.
// If the server also inserted/updated the same key in this batch, override that value
// with the optimistic value to preserve local intent.
for (const [key, value] of reapplyUpserts) {
if (reapplyDeletes.has(key)) continue
if (syncedInsertedOrUpdatedKeys.has(key)) {
let foundInsert = false
for (let i = events.length - 1; i >= 0; i--) {
const evt = events[i]!
if (evt.key === key && evt.type === `insert`) {
evt.value = value
foundInsert = true
break
}
}
if (!foundInsert) {
events.push({ type: `insert`, key, value })
}
} else {
events.push({ type: `insert`, key, value })
}
}
// Finally, ensure we do NOT insert keys that have an outstanding optimistic delete.
if (events.length > 0 && reapplyDeletes.size > 0) {
const filtered: Array<ChangeMessage<TOutput, TKey>> = []
for (const evt of events) {
if (evt.type === `insert` && reapplyDeletes.has(evt.key)) {
continue
}
filtered.push(evt)
}
events.length = 0
events.push(...filtered)
}
// Ensure listeners are active before emitting this critical batch
if (!this.isReady()) {
this.setStatus(`ready`)
}
}
// Maintain optimistic state appropriately
// Clear optimistic state since sync operations will now provide the authoritative data.
// Any still-active user transactions will be re-applied below in recompute.
this.optimisticUpserts.clear()
this.optimisticDeletes.clear()
// Reset flag and recompute optimistic state for any remaining active transactions
this.isCommittingSyncTransactions = false
for (const transaction of this.transactions.values()) {
if (![`completed`, `failed`].includes(transaction.state)) {
for (const mutation of transaction.mutations) {
if (mutation.collection === this && mutation.optimistic) {
switch (mutation.type) {
case `insert`:
case `update`:
this.optimisticUpserts.set(
mutation.key,
mutation.modified as TOutput
)
this.optimisticDeletes.delete(mutation.key)
break
case `delete`:
this.optimisticUpserts.delete(mutation.key)
this.optimisticDeletes.add(mutation.key)
break
}
}
}
}
}
// Check for redundant sync operations that match completed optimistic operations
const completedOptimisticOps = new Map<TKey, any>()
for (const transaction of this.transactions.values()) {
if (transaction.state === `completed`) {
for (const mutation of transaction.mutations) {
if (mutation.collection === this && changedKeys.has(mutation.key)) {
completedOptimisticOps.set(mutation.key, {
type: mutation.type,
value: mutation.modified,
})
}
}
}
}
// Now check what actually changed in the final visible state
for (const key of changedKeys) {
const previousVisibleValue = currentVisibleState.get(key)
const newVisibleValue = this.get(key) // This returns the new derived state
// Check if this sync operation is redundant with a completed optimistic operation
const completedOp = completedOptimisticOps.get(key)
const isRedundantSync =
completedOp &&
newVisibleValue !== undefined &&
deepEquals(completedOp.value, newVisibleValue)
if (!isRedundantSync) {
if (
previousVisibleValue === undefined &&
newVisibleValue !== undefined
) {
events.push({
type: `insert`,
key,
value: newVisibleValue,
})
} else if (
previousVisibleValue !== undefined &&
newVisibleValue === undefined
) {
events.push({
type: `delete`,
key,
value: previousVisibleValue,
})
} else if (
previousVisibleValue !== undefined &&
newVisibleValue !== undefined &&
!deepEquals(previousVisibleValue, newVisibleValue)
) {
events.push({
type: `update`,
key,
value: newVisibleValue,
previousValue: previousVisibleValue,
})
}
}
}
// Update cached size after synced data changes
this._size = this.calculateSize()
// Update indexes for all events before emitting
if (events.length > 0) {
this.updateIndexes(events)
}
// End batching and emit all events (combines any batched events with sync events)
this.emitEvents(events, true)
this.pendingSyncedTransactions = uncommittedSyncedTransactions
// Clear the pre-sync state since sync operations are complete
this.preSyncVisibleState.clear()
// Clear recently synced keys after a microtask to allow recomputeOptimisticState to see them
Promise.resolve().then(() => {
this.recentlySyncedKeys.clear()
})
// Call any registered one-time commit listeners
if (!this.hasReceivedFirstCommit) {
this.hasReceivedFirstCommit = true
const callbacks = [...this.onFirstReadyCallbacks]
this.onFirstReadyCallbacks = []
callbacks.forEach((callback) => callback())
}
}
}
/**
* Schedule cleanup of a transaction when it completes
* @private
*/
private scheduleTransactionCleanup(transaction: Transaction<any>): void {
// Only schedule cleanup for transactions that aren't already completed
if (transaction.state === `completed`) {
this.transactions.delete(transaction.id)
return
}
// Schedule cleanup when the transaction completes
transaction.isPersisted.promise
.then(() => {
// Transaction completed successfully, remove it immediately
this.transactions.delete(transaction.id)
})
.catch(() => {
// Transaction failed, but we want to keep failed transactions for reference
// so don't remove it.
// This empty catch block is necessary to prevent unhandled promise rejections.
})
}
private ensureStandardSchema(schema: unknown): StandardSchema<TOutput> {
// If the schema already implements the standard-schema interface, return it
if (schema && `~standard` in (schema as {})) {
return schema as StandardSchema<TOutput>
}
throw new InvalidSchemaError()
}
public getKeyFromItem(item: TOutput): TKey {
re