@tanstack/db
Version:
A reactive client store for building super fast apps on sync
1,391 lines (1,271 loc) • 49.6 kB
text/typescript
import { deepEquals } from '../utils'
import { SortedMap } from '../SortedMap'
import { enrichRowWithVirtualProps } from '../virtual-props.js'
import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js'
import type {
VirtualOrigin,
VirtualRowProps,
WithVirtualProps,
} from '../virtual-props.js'
import type { Transaction } from '../transactions'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type {
ChangeMessage,
CollectionConfig,
OptimisticChangeMessage,
} from '../types'
import type { CollectionImpl } from './index.js'
import type { CollectionLifecycleManager } from './lifecycle'
import type { CollectionChangesManager } from './changes'
import type { CollectionIndexesManager } from './indexes'
import type { CollectionEventsManager } from './events'
interface PendingSyncedTransaction<
T extends object = Record<string, unknown>,
TKey extends string | number = string | number,
> {
committed: boolean
operations: Array<OptimisticChangeMessage<T>>
truncate?: boolean
deletedKeys: Set<string | number>
rowMetadataWrites: Map<TKey, PendingMetadataWrite>
collectionMetadataWrites: Map<string, PendingMetadataWrite>
optimisticSnapshot?: {
upserts: Map<TKey, T>
deletes: Set<TKey>
}
/**
* When true, this transaction should be processed immediately even if there
* are persisting user transactions. Used by manual write operations (writeInsert,
* writeUpdate, writeDelete, writeUpsert) which need synchronous updates to syncedData.
*/
immediate?: boolean
}
type PendingMetadataWrite = { type: `set`; value: unknown } | { type: `delete` }
type InternalChangeMessage<
T extends object = Record<string, unknown>,
TKey extends string | number = string | number,
> = ChangeMessage<T, TKey> & {
__virtualProps?: {
value?: VirtualRowProps<TKey>
previousValue?: VirtualRowProps<TKey>
}
}
export class CollectionStateManager<
TOutput extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TInput extends object = TOutput,
> {
public config!: CollectionConfig<TOutput, TKey, TSchema>
public collection!: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
public lifecycle!: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
public changes!: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
public indexes!: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
private _events!: CollectionEventsManager
// Core state - make public for testing
public transactions: SortedMap<string, Transaction<any>>
public pendingSyncedTransactions: Array<
PendingSyncedTransaction<TOutput, TKey>
> = []
public syncedData: SortedMap<TKey, TOutput>
public syncedMetadata = new Map<TKey, unknown>()
public syncedCollectionMetadata = new Map<string, unknown>()
// Optimistic state tracking - make public for testing
public optimisticUpserts = new Map<TKey, TOutput>()
public optimisticDeletes = new Set<TKey>()
public pendingOptimisticUpserts = new Map<TKey, TOutput>()
public pendingOptimisticDeletes = new Set<TKey>()
public pendingOptimisticDirectUpserts = new Set<TKey>()
public pendingOptimisticDirectDeletes = new Set<TKey>()
/**
* Tracks the origin of confirmed changes for each row.
* 'local' = change originated from this client
* 'remote' = change was received via sync
*
* This is used for the $origin virtual property.
* Note: This only tracks *confirmed* changes, not optimistic ones.
* Optimistic changes are always considered 'local' for $origin.
*/
public rowOrigins = new Map<TKey, VirtualOrigin>()
/**
* Tracks keys that have pending local changes.
* Used to determine whether sync-confirmed data should have 'local' or 'remote' origin.
* When sync confirms data for a key with pending local changes, it keeps 'local' origin.
*/
public pendingLocalChanges = new Set<TKey>()
public pendingLocalOrigins = new Set<TKey>()
private virtualPropsCache = new WeakMap<
object,
{
synced: boolean
origin: VirtualOrigin
key: TKey
collectionId: string
enriched: WithVirtualProps<TOutput, TKey>
}
>()
// Cached size for performance
public size = 0
// State used for computing the change events
public syncedKeys = new Set<TKey>()
public preSyncVisibleState = new Map<TKey, TOutput>()
public recentlySyncedKeys = new Set<TKey>()
public hasReceivedFirstCommit = false
public isCommittingSyncTransactions = false
public isLocalOnly = false
/**
* Creates a new CollectionState manager
*/
constructor(config: CollectionConfig<TOutput, TKey, TSchema>) {
this.config = config
this.transactions = new SortedMap<string, Transaction<any>>((a, b) =>
a.compareCreatedAt(b),
)
// Set up data storage - always use SortedMap for deterministic iteration.
// If a custom compare function is provided, use it; otherwise entries are sorted by key only.
this.syncedData = new SortedMap<TKey, TOutput>(config.compare)
}
setDeps(deps: {
collection: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
changes: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
events: CollectionEventsManager
}) {
this.collection = deps.collection
this.lifecycle = deps.lifecycle
this.changes = deps.changes
this.indexes = deps.indexes
this._events = deps.events
}
/**
* Checks if a row has pending optimistic mutations (not yet confirmed by sync).
* Used to compute the $synced virtual property.
*/
public isRowSynced(key: TKey): boolean {
if (this.isLocalOnly) {
return true
}
return !this.optimisticUpserts.has(key) && !this.optimisticDeletes.has(key)
}
/**
* Gets the origin of the last confirmed change to a row.
* Returns 'local' if the row has optimistic mutations (optimistic changes are local).
* Used to compute the $origin virtual property.
*/
public getRowOrigin(key: TKey): VirtualOrigin {
if (this.isLocalOnly) {
return 'local'
}
// If there are optimistic changes, they're local
if (this.optimisticUpserts.has(key) || this.optimisticDeletes.has(key)) {
return 'local'
}
// Otherwise, return the confirmed origin (defaults to 'remote' for synced data)
return this.rowOrigins.get(key) ?? 'remote'
}
private createVirtualPropsSnapshot(
key: TKey,
overrides?: Partial<VirtualRowProps<TKey>>,
): VirtualRowProps<TKey> {
return {
$synced: overrides?.$synced ?? this.isRowSynced(key),
$origin: overrides?.$origin ?? this.getRowOrigin(key),
$key: overrides?.$key ?? key,
$collectionId: overrides?.$collectionId ?? this.collection.id,
}
}
private getVirtualPropsSnapshotForState(
key: TKey,
options?: {
rowOrigins?: ReadonlyMap<TKey, VirtualOrigin>
optimisticUpserts?: Pick<Map<TKey, unknown>, 'has'>
optimisticDeletes?: Pick<Set<TKey>, 'has'>
completedOptimisticKeys?: Pick<Map<TKey, unknown>, 'has'>
},
): VirtualRowProps<TKey> {
if (this.isLocalOnly) {
return this.createVirtualPropsSnapshot(key, {
$synced: true,
$origin: 'local',
})
}
const optimisticUpserts =
options?.optimisticUpserts ?? this.optimisticUpserts
const optimisticDeletes =
options?.optimisticDeletes ?? this.optimisticDeletes
const hasOptimisticChange =
optimisticUpserts.has(key) ||
optimisticDeletes.has(key) ||
options?.completedOptimisticKeys?.has(key) === true
return this.createVirtualPropsSnapshot(key, {
$synced: !hasOptimisticChange,
$origin: hasOptimisticChange
? 'local'
: ((options?.rowOrigins ?? this.rowOrigins).get(key) ?? 'remote'),
})
}
private enrichWithVirtualPropsSnapshot(
row: TOutput,
virtualProps: VirtualRowProps<TKey>,
): WithVirtualProps<TOutput, TKey> {
const existingRow = row as Partial<WithVirtualProps<TOutput, TKey>>
const synced = existingRow.$synced ?? virtualProps.$synced
const origin = existingRow.$origin ?? virtualProps.$origin
const resolvedKey = existingRow.$key ?? virtualProps.$key
const collectionId = existingRow.$collectionId ?? virtualProps.$collectionId
const cached = this.virtualPropsCache.get(row as object)
if (
cached &&
cached.synced === synced &&
cached.origin === origin &&
cached.key === resolvedKey &&
cached.collectionId === collectionId
) {
return cached.enriched
}
const enriched = {
...row,
$synced: synced,
$origin: origin,
$key: resolvedKey,
$collectionId: collectionId,
} as WithVirtualProps<TOutput, TKey>
this.virtualPropsCache.set(row as object, {
synced,
origin,
key: resolvedKey,
collectionId,
enriched,
})
return enriched
}
private clearOriginTrackingState(): void {
this.rowOrigins.clear()
this.pendingLocalChanges.clear()
this.pendingLocalOrigins.clear()
}
/**
* Enriches a row with virtual properties using the "add-if-missing" pattern.
* If the row already has virtual properties (from an upstream collection),
* they are preserved. Otherwise, new values are computed.
*/
public enrichWithVirtualProps(
row: TOutput,
key: TKey,
): WithVirtualProps<TOutput, TKey> {
return this.enrichWithVirtualPropsSnapshot(
row,
this.createVirtualPropsSnapshot(key),
)
}
/**
* Creates a change message with virtual properties.
* Uses the "add-if-missing" pattern so that pass-through from upstream
* collections works correctly.
*/
public enrichChangeMessage(
change: ChangeMessage<TOutput, TKey>,
): ChangeMessage<WithVirtualProps<TOutput, TKey>, TKey> {
const { __virtualProps } = change as InternalChangeMessage<TOutput, TKey>
const enrichedValue = __virtualProps?.value
? this.enrichWithVirtualPropsSnapshot(change.value, __virtualProps.value)
: this.enrichWithVirtualProps(change.value, change.key)
const enrichedPreviousValue = change.previousValue
? __virtualProps?.previousValue
? this.enrichWithVirtualPropsSnapshot(
change.previousValue,
__virtualProps.previousValue,
)
: this.enrichWithVirtualProps(change.previousValue, change.key)
: undefined
return {
key: change.key,
type: change.type,
value: enrichedValue,
previousValue: enrichedPreviousValue,
metadata: change.metadata,
} as ChangeMessage<WithVirtualProps<TOutput, TKey>, TKey>
}
/**
* Get the current value for a key enriched with virtual properties.
*/
public getWithVirtualProps(
key: TKey,
): WithVirtualProps<TOutput, TKey> | undefined {
const value = this.get(key)
if (value === undefined) {
return undefined
}
return this.enrichWithVirtualProps(value, key)
}
/**
* Get the current value for a key (virtual derived state)
*/
public get(key: TKey): TOutput | undefined {
const { optimisticDeletes, optimisticUpserts, syncedData } = this
// Check if optimistically deleted
if (optimisticDeletes.has(key)) {
return undefined
}
// Check optimistic upserts first
if (optimisticUpserts.has(key)) {
return optimisticUpserts.get(key)
}
// Fall back to synced data
return syncedData.get(key)
}
/**
* Check if a key exists in the collection (virtual derived state)
*/
public has(key: TKey): boolean {
const { optimisticDeletes, optimisticUpserts, syncedData } = this
// Check if optimistically deleted
if (optimisticDeletes.has(key)) {
return false
}
// Check optimistic upserts first
if (optimisticUpserts.has(key)) {
return true
}
// Fall back to synced data
return syncedData.has(key)
}
/**
* Get all keys (virtual derived state)
*/
public *keys(): IterableIterator<TKey> {
const { syncedData, optimisticDeletes, optimisticUpserts } = this
// Yield keys from synced data, skipping any that are deleted.
for (const key of syncedData.keys()) {
if (!optimisticDeletes.has(key)) {
yield key
}
}
// Yield keys from upserts that were not already in synced data.
for (const key of optimisticUpserts.keys()) {
if (!syncedData.has(key) && !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
}
/**
* Check if the given collection is this collection
* @param collection The collection to check
* @returns True if the given collection is this collection, false otherwise
*/
private isThisCollection(
collection: CollectionImpl<any, any, any, any, any>,
): boolean {
return collection === this.collection
}
/**
* Recompute optimistic state from active transactions
*/
public recomputeOptimisticState(
triggeredByUserAction: boolean = false,
): void {
// Skip redundant recalculations when we're in the middle of committing sync transactions
// While the sync pipeline is replaying a large batch we still want to honour
// fresh optimistic mutations from the UI. Only skip recompute for the
// internal sync-driven redraws; user-triggered work (triggeredByUserAction)
// must run so live queries stay responsive during long commits.
if (this.isCommittingSyncTransactions && !triggeredByUserAction) {
return
}
const previousState = new Map(this.optimisticUpserts)
const previousDeletes = new Set(this.optimisticDeletes)
const previousRowOrigins = new Map(this.rowOrigins)
// Update pending optimistic state for completed/failed transactions
for (const transaction of this.transactions.values()) {
const isDirectTransaction =
transaction.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true
if (transaction.state === `completed`) {
for (const mutation of transaction.mutations) {
if (!this.isThisCollection(mutation.collection)) {
continue
}
this.pendingLocalOrigins.add(mutation.key)
if (!mutation.optimistic) {
continue
}
switch (mutation.type) {
case `insert`:
case `update`:
this.pendingOptimisticUpserts.set(
mutation.key,
mutation.modified as TOutput,
)
this.pendingOptimisticDeletes.delete(mutation.key)
if (isDirectTransaction) {
this.pendingOptimisticDirectUpserts.add(mutation.key)
this.pendingOptimisticDirectDeletes.delete(mutation.key)
} else {
this.pendingOptimisticDirectUpserts.delete(mutation.key)
this.pendingOptimisticDirectDeletes.delete(mutation.key)
}
break
case `delete`:
this.pendingOptimisticUpserts.delete(mutation.key)
this.pendingOptimisticDeletes.add(mutation.key)
if (isDirectTransaction) {
this.pendingOptimisticDirectUpserts.delete(mutation.key)
this.pendingOptimisticDirectDeletes.add(mutation.key)
} else {
this.pendingOptimisticDirectUpserts.delete(mutation.key)
this.pendingOptimisticDirectDeletes.delete(mutation.key)
}
break
}
}
} else if (transaction.state === `failed`) {
for (const mutation of transaction.mutations) {
if (!this.isThisCollection(mutation.collection)) {
continue
}
this.pendingLocalOrigins.delete(mutation.key)
if (mutation.optimistic) {
this.pendingOptimisticUpserts.delete(mutation.key)
this.pendingOptimisticDeletes.delete(mutation.key)
this.pendingOptimisticDirectUpserts.delete(mutation.key)
this.pendingOptimisticDirectDeletes.delete(mutation.key)
}
}
}
}
// Clear current optimistic state
this.optimisticUpserts.clear()
this.optimisticDeletes.clear()
this.pendingLocalChanges.clear()
// Seed optimistic state with pending optimistic mutations only when a sync is pending
const pendingSyncKeys = new Set<TKey>()
for (const transaction of this.pendingSyncedTransactions) {
for (const operation of transaction.operations) {
pendingSyncKeys.add(operation.key as TKey)
}
}
const staleOptimisticUpserts: Array<TKey> = []
for (const [key, value] of this.pendingOptimisticUpserts) {
if (
pendingSyncKeys.has(key) ||
this.pendingOptimisticDirectUpserts.has(key)
) {
this.optimisticUpserts.set(key, value)
} else {
staleOptimisticUpserts.push(key)
}
}
for (const key of staleOptimisticUpserts) {
this.pendingOptimisticUpserts.delete(key)
this.pendingLocalOrigins.delete(key)
}
const staleOptimisticDeletes: Array<TKey> = []
for (const key of this.pendingOptimisticDeletes) {
if (
pendingSyncKeys.has(key) ||
this.pendingOptimisticDirectDeletes.has(key)
) {
this.optimisticDeletes.add(key)
} else {
staleOptimisticDeletes.push(key)
}
}
for (const key of staleOptimisticDeletes) {
this.pendingOptimisticDeletes.delete(key)
this.pendingLocalOrigins.delete(key)
}
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 (!this.isThisCollection(mutation.collection)) {
continue
}
// Track that this key has pending local changes for $origin tracking
this.pendingLocalChanges.add(mutation.key)
if (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<InternalChangeMessage<TOutput, TKey>> = []
this.collectOptimisticChanges(
previousState,
previousDeletes,
previousRowOrigins,
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 pendingSyncKeysForFilter = new Set<TKey>()
// Collect keys from pending sync operations
for (const transaction of this.pendingSyncedTransactions) {
for (const operation of transaction.operations) {
pendingSyncKeysForFilter.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` &&
pendingSyncKeysForFilter.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) => this.isThisCollection(m.collection) && 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.indexes.updateIndexes(filteredEvents)
}
this.changes.emitEvents(filteredEvents, triggeredByUserAction)
} else {
// Update indexes for all events
if (filteredEventsBySyncStatus.length > 0) {
this.indexes.updateIndexes(filteredEventsBySyncStatus)
}
// Emit all events if no pending sync transactions
this.changes.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>,
previousRowOrigins: ReadonlyMap<TKey, VirtualOrigin>,
events: Array<InternalChangeMessage<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,
)
const previousVirtualProps = this.getVirtualPropsSnapshotForState(key, {
rowOrigins: previousRowOrigins,
optimisticUpserts: previousUpserts,
optimisticDeletes: previousDeletes,
})
const nextVirtualProps = this.getVirtualPropsSnapshotForState(key)
if (previousValue !== undefined && currentValue === undefined) {
events.push({
type: `delete`,
key,
value: previousValue,
__virtualProps: {
value: previousVirtualProps,
},
})
} else if (previousValue === undefined && currentValue !== undefined) {
events.push({
type: `insert`,
key,
value: currentValue,
__virtualProps: {
value: nextVirtualProps,
},
})
} else if (
previousValue !== undefined &&
currentValue !== undefined &&
previousValue !== currentValue
) {
events.push({
type: `update`,
key,
value: currentValue,
previousValue,
__virtualProps: {
value: nextVirtualProps,
previousValue: previousVirtualProps,
},
})
}
}
}
/**
* 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)
}
/**
* 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,
hasImmediateSync,
} = this.pendingSyncedTransactions.reduce(
(acc, t) => {
if (t.committed) {
acc.committedSyncedTransactions.push(t)
if (t.truncate) {
acc.hasTruncateSync = true
}
if (t.immediate) {
acc.hasImmediateSync = true
}
} else {
acc.uncommittedSyncedTransactions.push(t)
}
return acc
},
{
committedSyncedTransactions: [] as Array<
PendingSyncedTransaction<TOutput, TKey>
>,
uncommittedSyncedTransactions: [] as Array<
PendingSyncedTransaction<TOutput, TKey>
>,
hasTruncateSync: false,
hasImmediateSync: false,
},
)
// Process committed transactions if:
// 1. No persisting user transaction (normal sync flow), OR
// 2. There's a truncate operation (must be processed immediately), OR
// 3. There's an immediate transaction (manual writes must be processed synchronously)
//
// Note: When hasImmediateSync or hasTruncateSync is true, we process ALL committed
// sync transactions (not just the immediate/truncate ones). This is intentional for
// ordering correctness: if we only processed the immediate transaction, earlier
// non-immediate transactions would be applied later and could overwrite newer state.
// Processing all committed transactions together preserves causal ordering.
if (!hasPersistingTransaction || hasTruncateSync || hasImmediateSync) {
// Set flag to prevent redundant optimistic state recalculations
this.isCommittingSyncTransactions = true
const previousRowOrigins = new Map(this.rowOrigins)
const previousOptimisticUpserts = new Map(this.optimisticUpserts)
const previousOptimisticDeletes = new Set(this.optimisticDeletes)
// Get the optimistic snapshot from the truncate transaction (captured when truncate() was called)
const truncateOptimisticSnapshot = hasTruncateSync
? committedSyncedTransactions.find((t) => t.truncate)
?.optimisticSnapshot
: null
let truncatePendingLocalChanges: Set<TKey> | undefined
let truncatePendingLocalOrigins: Set<TKey> | undefined
// 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)
}
for (const [key] of transaction.rowMetadataWrites) {
changedKeys.add(key)
}
}
// 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`
const completedOptimisticOps = new Map<
TKey,
{ type: string; value: TOutput }
>()
for (const transaction of this.transactions.values()) {
if (transaction.state === `completed`) {
for (const mutation of transaction.mutations) {
if (this.isThisCollection(mutation.collection)) {
if (mutation.optimistic) {
completedOptimisticOps.set(mutation.key, {
type: mutation.type,
value: mutation.modified as TOutput,
})
}
}
}
}
}
for (const transaction of committedSyncedTransactions) {
// Handle truncate operations first
if (transaction.truncate) {
// TRUNCATE PHASE
// 1) Emit a delete for every visible key (synced + optimistic) 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.
// Use the snapshot to ensure we emit deletes for all items that existed at truncate start.
const visibleKeys = new Set([
...this.syncedData.keys(),
...(truncateOptimisticSnapshot?.upserts.keys() || []),
])
for (const key of visibleKeys) {
if (truncateOptimisticSnapshot?.deletes.has(key)) continue
const previousValue =
truncateOptimisticSnapshot?.upserts.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.
// Preserve pending local tracking just long enough for operations in this
// truncate batch to retain correct local origin semantics.
truncatePendingLocalChanges = new Set(this.pendingLocalChanges)
truncatePendingLocalOrigins = new Set(this.pendingLocalOrigins)
this.syncedData.clear()
this.syncedMetadata.clear()
this.syncedKeys.clear()
this.clearOriginTrackingState()
// 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)
}
// 4) Emit truncate event so subscriptions can reset their cursor tracking state
this._events.emit(`truncate`, {
type: `truncate`,
collection: this.collection,
})
}
for (const operation of transaction.operations) {
const key = operation.key as TKey
this.syncedKeys.add(key)
// Determine origin: 'local' for local-only collections or pending local changes
const origin: VirtualOrigin =
this.isLocalOnly ||
this.pendingLocalChanges.has(key) ||
this.pendingLocalOrigins.has(key) ||
truncatePendingLocalChanges?.has(key) === true ||
truncatePendingLocalOrigins?.has(key) === true
? 'local'
: 'remote'
// Update synced data
switch (operation.type) {
case `insert`:
this.syncedData.set(key, operation.value)
this.rowOrigins.set(key, origin)
// Clear pending local changes now that sync has confirmed
this.pendingLocalChanges.delete(key)
this.pendingLocalOrigins.delete(key)
this.pendingOptimisticUpserts.delete(key)
this.pendingOptimisticDeletes.delete(key)
this.pendingOptimisticDirectUpserts.delete(key)
this.pendingOptimisticDirectDeletes.delete(key)
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)
}
this.rowOrigins.set(key, origin)
// Clear pending local changes now that sync has confirmed
this.pendingLocalChanges.delete(key)
this.pendingLocalOrigins.delete(key)
this.pendingOptimisticUpserts.delete(key)
this.pendingOptimisticDeletes.delete(key)
this.pendingOptimisticDirectUpserts.delete(key)
this.pendingOptimisticDirectDeletes.delete(key)
break
}
case `delete`:
this.syncedData.delete(key)
this.syncedMetadata.delete(key)
// Clean up origin and pending tracking for deleted rows
this.rowOrigins.delete(key)
this.pendingLocalChanges.delete(key)
this.pendingLocalOrigins.delete(key)
this.pendingOptimisticUpserts.delete(key)
this.pendingOptimisticDeletes.delete(key)
this.pendingOptimisticDirectUpserts.delete(key)
this.pendingOptimisticDirectDeletes.delete(key)
break
}
}
for (const [key, metadataWrite] of transaction.rowMetadataWrites) {
if (metadataWrite.type === `delete`) {
this.syncedMetadata.delete(key)
continue
}
this.syncedMetadata.set(key, metadataWrite.value)
}
for (const [
key,
metadataWrite,
] of transaction.collectionMetadataWrites) {
if (metadataWrite.type === `delete`) {
this.syncedCollectionMetadata.delete(key)
continue
}
this.syncedCollectionMetadata.set(key, metadataWrite.value)
}
}
// 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 the snapshot taken at the start of this function.
// This prevents losing optimistic state if transactions complete during truncate processing.
const reapplyUpserts = new Map<TKey, TOutput>(
truncateOptimisticSnapshot!.upserts,
)
const reapplyDeletes = new Set<TKey>(
truncateOptimisticSnapshot!.deletes,
)
// 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.lifecycle.status !== `ready`) {
this.lifecycle.markReady()
}
}
// 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
// If we had a truncate, restore the preserved optimistic state from the snapshot
// This includes items from transactions that may have completed during processing
if (hasTruncateSync && truncateOptimisticSnapshot) {
for (const [key, value] of truncateOptimisticSnapshot.upserts) {
this.optimisticUpserts.set(key, value)
}
for (const key of truncateOptimisticSnapshot.deletes) {
this.optimisticDeletes.add(key)
}
}
// Always overlay any still-active optimistic transactions so mutations that started
// after the truncate snapshot are preserved.
for (const transaction of this.transactions.values()) {
if (![`completed`, `failed`].includes(transaction.state)) {
for (const mutation of transaction.mutations) {
if (
this.isThisCollection(mutation.collection) &&
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
}
}
}
}
}
// 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
const previousVirtualProps = this.getVirtualPropsSnapshotForState(key, {
rowOrigins: previousRowOrigins,
optimisticUpserts: previousOptimisticUpserts,
optimisticDeletes: previousOptimisticDeletes,
completedOptimisticKeys: completedOptimisticOps,
})
const nextVirtualProps = this.getVirtualPropsSnapshotForState(key)
const virtualChanged =
previousVirtualProps.$synced !== nextVirtualProps.$synced ||
previousVirtualProps.$origin !== nextVirtualProps.$origin
const previousValueWithVirtual =
previousVisibleValue !== undefined
? enrichRowWithVirtualProps(
previousVisibleValue,
key,
this.collection.id,
() => previousVirtualProps.$synced,
() => previousVirtualProps.$origin,
)
: undefined
// Check if this sync operation is redundant with a completed optimistic operation
const completedOp = completedOptimisticOps.get(key)
let isRedundantSync = false
if (completedOp) {
if (
completedOp.type === `delete` &&
previousVisibleValue !== undefined &&
newVisibleValue === undefined &&
deepEquals(completedOp.value, previousVisibleValue)
) {
isRedundantSync = true
} else if (
newVisibleValue !== undefined &&
deepEquals(completedOp.value, newVisibleValue)
) {
isRedundantSync = true
}
}
const shouldEmitVirtualUpdate =
virtualChanged &&
previousVisibleValue !== undefined &&
newVisibleValue !== undefined &&
deepEquals(previousVisibleValue, newVisibleValue)
if (isRedundantSync && !shouldEmitVirtualUpdate) {
continue
}
if (
previousVisibleValue === undefined &&
newVisibleValue !== undefined
) {
const completedOptimisticOp = completedOptimisticOps.get(key)
if (completedOptimisticOp) {
const previousValueFromCompleted = completedOptimisticOp.value
const previousValueWithVirtualFromCompleted =
enrichRowWithVirtualProps(
previousValueFromCompleted,
key,
this.collection.id,
() => previousVirtualProps.$synced,
() => previousVirtualProps.$origin,
)
events.push({
type: `update`,
key,
value: newVisibleValue,
previousValue: previousValueWithVirtualFromCompleted,
})
} else {
events.push({
type: `insert`,
key,
value: newVisibleValue,
})
}
} else if (
previousVisibleValue !== undefined &&
newVisibleValue === undefined
) {
events.push({
type: `delete`,
key,
value: previousValueWithVirtual ?? previousVisibleValue,
})
} else if (
previousVisibleValue !== undefined &&
newVisibleValue !== undefined &&
(!deepEquals(previousVisibleValue, newVisibleValue) ||
shouldEmitVirtualUpdate)
) {
events.push({
type: `update`,
key,
value: newVisibleValue,
previousValue: previousValueWithVirtual ?? previousVisibleValue,
})
}
}
// Update cached size after synced data changes
this.size = this.calculateSize()
// Update indexes for all events before emitting
if (events.length > 0) {
this.indexes.updateIndexes(events)
}
// End batching and emit all events (combines any batched events with sync events)
this.changes.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()
})
// Mark that we've received the first commit (for tracking purposes)
if (!this.hasReceivedFirstCommit) {
this.hasReceivedFirstCommit = true
}
}
}
/**
* Schedule cleanup of a transaction when it completes
*/
public 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.
// Rollback already triggers state recomputation via touchCollection().
})
}
/**
* Capture visible state for keys that will be affected by pending sync operations
* This must be called BEFORE onTransactionStateChange clears optimistic state
*/
public capturePreSyncVisibleState(): void {
if (this.pendingSyncedTransactions.length === 0) return
// Get all keys that will be affected by sync operations
const syncedKeys = new Set<TKey>()
for (const transaction of this.pendingSyncedTransactions) {
for (const operation of transaction.operations) {
syncedKeys.add(operation.key as TKey)
}
}
// Mark keys as about to be synced to suppress intermediate events from recomputeOptimisticState
for (const key of syncedKeys) {
this.recentlySyncedKeys.add(key)
}
// Only capture current visible state for keys that will be affected by sync operations
// This is much more efficient than capturing the entire collection state
// Only capture keys that haven't been captured yet to preserve earlier captures
for (const key of syncedKeys) {
if (!this.preSyncVisibleState.has(key)) {
const currentValue = this.get(key)
if (currentValue !== undefined) {
this.preSyncVisibleState.set(key, currentValue)
}
}
}
}
/**
* Trigger a recomputation when transactions change
* This method should be called by the Transaction class when state changes
*/
public onTransactionStateChange(): void {
// Check if commitPendingTransactions will be called after this
// by checking if there are pending sync transactions (same logic as in transactions.ts)
this.changes.shouldBatchEvents = this.pendingSyncedTransactions.length > 0
// CRITICAL: Capture visible state BEFORE clearing optimistic state
this.capturePreSyncVisibleState()
this.recomputeOptimisticState(false)
}
/**
* Clean up the collection by stopping sync and clearing data
* This can be called manually or automatically by garbage collection
*/
public cleanup(): void {
this.syncedData.clear()
this.syncedMetadata.clear()
this.syncedCollectionMetadata.clear()
this.optimisticUpserts.clear()
this.optimisticDeletes.clear()
this.pendingOptimisticUpserts.clear()
this.pendingOptimisticDeletes.clear()
this.pendingOptimisticDirectUpserts.clear()
this.pendingOptimisticDirectDeletes.clear()
this.clearOriginTrackingState()
this.isLocalOnly = false
this.size = 0
this.pendingSyncedTransactions = []
this.syncedKeys.clear()
this.hasReceivedFirstCommit = false
}
}