UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

1,603 lines • 57.4 kB
import { deepEquals } from '../utils'
import { SortedMap } from '../SortedMap'
import { enrichRowWithVirtualProps } from '../virtual-props.js'
import {
  SyncQueueInvariantError,
  SyncTransactionAbortedError,
} from '../errors.js'
import type { DuplicateKeySyncError } from '../errors.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,
  OperationType,
  OptimisticChangeMessage,
  PendingMutation,
} 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'
import type { Deferred } from '../deferred'

type PendingSyncOperation<
  T extends object,
  TKey extends string | number,
> = OptimisticChangeMessage<T, TKey> & {
  /** Preserve adapter intent when queue changes require reclassification. */
  originalSyncType?: `insert`
  /** A partial update admitted against a row must not become an upsert. */
}

type PendingSyncedKeyState<T extends object> = {
  exists: boolean
  value: T | undefined
}

type PendingSyncedProjection<T extends object, TKey extends string | number> = {
  states: Map<TKey, PendingSyncedKeyState<T>>
  truncated: boolean
}

type PendingInsertDisposition = `insert` | `update` | `duplicate`

interface PendingSyncedTransaction<
  T extends object = Record<string, unknown>,
  TKey extends string | number = string | number,
> {
  committed: boolean
  applicationStarted: boolean
  layoutChanged: boolean
  operations: Array<PendingSyncOperation<T, TKey>>
  truncate?: boolean
  rowMetadataWrites: Map<TKey, PendingMetadataWrite>
  explicitRowMetadataWriteKeys?: Set<TKey>
  collectionMetadataWrites: Map<string, PendingMetadataWrite>
  /** Resolves after application and rejects if canceled before application. */
  applied: Deferred<void>
  preserveHydrationSeedKeys?: boolean
  /** Builds the error for an open transaction whose insert a replay invalidates. */
  duplicateKeyError?: (key: TKey) => DuplicateKeySyncError
  /**
   * Set when a replay invalidates an open transaction, which happens when a
   * later transaction begun inside it commits first. Its commit rejects.
   */
  invalidationError?: Error
}

type PendingMetadataWrite = { type: `set`; value: unknown } | { type: `delete` }

/** The row metadata a sync operation writes unless the adapter set it explicitly. */
export function automaticRowMetadataWrite(
  operation: Pick<OptimisticChangeMessage<object>, `type` | `metadata`>,
): PendingMetadataWrite | undefined {
  if (operation.metadata !== undefined && operation.type !== `delete`) {
    return { type: `set`, value: operation.metadata }
  }
  return operation.type === `update` ? undefined : { 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, any>
  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>
  > = []
  private pendingSyncedProjection: PendingSyncedProjection<TOutput, TKey> = {
    states: new Map(),
    truncated: false,
  }
  public syncedData: SortedMap<TKey, TOutput>
  public syncedMetadata = new Map<TKey, unknown>()
  public syncedCollectionMetadata = new Map<string, unknown>()
  public hydrationSeedKeys = new Set<TKey>()
  public hydratedKeys = new Set<TKey>()
  // DbClient may receive a stale hydration chunk after an adapter delete.
  // Only DbClient collections retain these absences until cleanup because
  // hydration has no completion boundary.
  private appliedAdapterDeletedKeys?: Set<TKey>
  private hasAppliedAdapterTruncate = false

  // Optimistic state tracking - make public for testing
  public optimisticUpserts = new Map<TKey, TOutput>()
  public optimisticDeletes = new Set<TKey>()

  // A completed transaction's optimistic row is held only while a queued sync
  // transaction touches its key, so the drop and that sync transaction
  // publish together.
  // The newest completed transaction owns a held key; no row means a delete.
  public heldOptimisticRows = new Map<
    TKey,
    { owner: Transaction<any>; row?: TOutput }
  >()

  /**
   * 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>()
  // A completed mutation attributes only the sync writes committed before its
  // optimistic state dropped: those held at its completion boundary. Active or
  // failed mutations must not add to, or erase a sibling's entry in, this set.
  public pendingLocalOrigins = new Set<TKey>()

  // Keyed by row key, not row object: adding a WeakMap entry for each
  // published row cost more than the copy it saves. Sync writes, deletes,
  // and cleanup drop a key's entry.
  private virtualPropsCache = new Map<
    TKey,
    {
      row: TOutput
      synced: boolean
      origin: VirtualOrigin
      collectionId: string
      enriched: WithVirtualProps<TOutput, TKey>
    }
  >()

  // Cached size for performance
  public size = 0

  // State used for computing the change events
  // Hidden keys are captured as undefined so a later recompute cannot become
  // the baseline for a sync commit that must publish that key's change.
  public preSyncVisibleState = new Map<TKey, TOutput | undefined>()
  public preSyncVirtualState = new Map<TKey, VirtualRowProps<TKey>>()
  public recentlySyncedKeys = new Set<TKey>()
  public hasReceivedFirstCommit = false
  public isCommittingSyncTransactions = false
  private isDrainingSyncTransactions = false
  private syncRunGeneration = 0
  public isLocalOnly = false
  /**
   * Set by a local-only Collection for operation types without a user handler.
   * Their direct mutations can be written as synced rows at once.
   */
  public localOnlyDirectWrite:
    | {
        types: ReadonlySet<OperationType>
        write: (mutations: Array<PendingMutation<TOutput>>) => void
      }
    | undefined

  /**
   * Creates a new CollectionState manager
   */
  constructor(config: CollectionConfig<TOutput, TKey, TSchema, any>) {
    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 whether this row currently has no pending local optimistic writes.
   *
   * This is local mutation status, not backend confirmation: `true` means the
   * row is not currently affected by an optimistic transaction in this
   * collection's visible state.
   *
   * 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> {
    const synced = overrides?.$synced ?? this.isRowSynced(key)
    return {
      $hasPendingWrites: !synced,
      $synced: synced,
      $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'>
    },
  ): 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)

    return this.createVirtualPropsSnapshot(key, {
      $synced: !hasOptimisticChange,
      $origin: hasOptimisticChange
        ? 'local'
        : ((options?.rowOrigins ?? this.rowOrigins).get(key) ?? 'remote'),
    })
  }

  private snapshotRowOriginsForKeys(
    keys: Iterable<TKey>,
  ): Map<TKey, VirtualOrigin> {
    const rowOrigins = new Map<TKey, VirtualOrigin>()

    for (const key of keys) {
      const origin = this.rowOrigins.get(key)
      if (origin !== undefined) {
        rowOrigins.set(key, origin)
      }
    }

    return rowOrigins
  }

  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(resolvedKey)
    if (
      cached &&
      cached.row === row &&
      cached.synced === synced &&
      cached.origin === origin &&
      cached.collectionId === collectionId
    ) {
      return cached.enriched
    }

    const enriched = {
      ...row,
      $hasPendingWrites: !synced,
      $synced: synced,
      $origin: origin,
      $key: resolvedKey,
      $collectionId: collectionId,
    } as WithVirtualProps<TOutput, TKey>

    this.virtualPropsCache.set(resolvedKey, {
      row,
      synced,
      origin,
      collectionId,
      enriched,
    })

    return enriched
  }

  private clearOriginTrackingState(): void {
    this.virtualPropsCache.clear()
    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),
    )
  }

  /**
   * Visible entries whose stored row passes `prefilter`, enriched with virtual
   * properties. Rows that fail are never enriched.
   */
  public *entriesPassing(
    prefilter: (row: object) => boolean,
  ): IterableIterator<[TKey, WithVirtualProps<TOutput, TKey>]> {
    // Without optimistic state, the visible rows are the synced rows in order.
    const rows =
      this.optimisticUpserts.size === 0 && this.optimisticDeletes.size === 0
        ? this.syncedData
        : this.entries()
    for (const [key, row] of rows) {
      if (prefilter(row)) yield [key, this.enrichWithVirtualProps(row, 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>
    // The cache holds one row per key, so the previous row goes first and
    // the published value stays the row that later reads return.
    const enrichedPreviousValue = change.previousValue
      ? __virtualProps?.previousValue
        ? this.enrichWithVirtualPropsSnapshot(
            change.previousValue,
            __virtualProps.previousValue,
          )
        : this.enrichWithVirtualProps(change.previousValue, change.key)
      : undefined
    const enrichedValue = __virtualProps?.value
      ? this.enrichWithVirtualPropsSnapshot(change.value, __virtualProps.value)
      : this.enrichWithVirtualProps(change.value, change.key)
    // A deleted key, such as a rolled-back insert, has no row to read again.
    if (change.type === `delete`) this.virtualPropsCache.delete(change.key)

    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 = this.rowOrigins

    // Hold completed optimistic rows, and their local attribution, only while
    // an accepted, queued sync transaction touches them; an open one is not
    // accepted yet. That sync was committed before
    // the optimistic state dropped; a later one is remote.
    const pendingSyncKeys = new Set<TKey>()
    for (const transaction of this.pendingSyncedTransactions) {
      if (!transaction.committed) continue
      for (const operation of transaction.operations) {
        pendingSyncKeys.add(operation.key as TKey)
      }
    }
    for (const transaction of this.transactions.values()) {
      if (transaction.state !== `completed`) continue
      for (const mutation of transaction.mutations) {
        if (
          !this.isThisCollection(mutation.collection) ||
          !pendingSyncKeys.has(mutation.key)
        )
          continue
        this.pendingLocalOrigins.add(mutation.key)
        if (!mutation.optimistic) continue
        // A completed transaction leaves `transactions` before a newer one
        // settles, so keep the newest owner's row.
        const held = this.heldOptimisticRows.get(mutation.key)
        if (held && held.owner.compareCreatedAt(transaction) > 0) continue
        this.heldOptimisticRows.set(mutation.key, {
          owner: transaction,
          row: mutation.type === `delete` ? undefined : mutation.modified,
        })
      }
    }

    // Clear current optimistic state
    this.optimisticUpserts.clear()
    this.optimisticDeletes.clear()
    this.pendingLocalChanges.clear()

    for (const [key, { row }] of this.heldOptimisticRows) {
      if (!pendingSyncKeys.has(key)) {
        this.heldOptimisticRows.delete(key)
        this.pendingLocalOrigins.delete(key)
      } else if (row === undefined) this.optimisticDeletes.add(key)
      else this.optimisticUpserts.set(key, row)
    }

    this.overlayActiveTransactions()

    // Update cached size
    this.size = this.calculateSize()

    // Collect events for changes
    const events: Array<InternalChangeMessage<TOutput, TKey>> = []
    this.collectOptimisticChanges(
      previousState,
      previousDeletes,
      previousRowOrigins,
      events,
    )

    // A user action publishes all its events. Otherwise the sync drain
    // already published the recently synced keys.
    const filteredEvents = triggeredByUserAction
      ? events
      : events.filter((event) => !this.recentlySyncedKeys.has(event.key))
    if (filteredEvents.length > 0) this.indexes.updateIndexes(filteredEvents)
    this.changes.emitEvents(filteredEvents, triggeredByUserAction)
  }

  /**
   * Overlay still-active optimistic mutations on the current layers and
   * record their keys as pending local changes for $origin tracking.
   */
  private overlayActiveTransactions(): void {
    for (const transaction of this.transactions.values()) {
      if (transaction.state === `completed` || transaction.state === `failed`)
        continue
      for (const mutation of transaction.mutations) {
        if (!this.isThisCollection(mutation.collection)) continue
        this.pendingLocalChanges.add(mutation.key)
        if (!mutation.optimistic) continue
        if (mutation.type === `delete`) {
          this.optimisticUpserts.delete(mutation.key)
          this.optimisticDeletes.add(mutation.key)
        } else {
          this.optimisticUpserts.set(mutation.key, mutation.modified)
          this.optimisticDeletes.delete(mutation.key)
        }
      }
    }
  }

  /**
   * 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 &&
        (!deepEquals(previousValue, currentValue) ||
          previousVirtualProps.$origin !== nextVirtualProps.$origin ||
          previousVirtualProps.$synced !== nextVirtualProps.$synced)
      ) {
        events.push({
          type: `update`,
          key,
          value: currentValue,
          previousValue,
          __virtualProps: {
            value: nextVirtualProps,
            previousValue: previousVirtualProps,
          },
        })
      }
    }
  }

  /** Build once per output flush; queued membership excludes optimistic edits. */
  createSyncedKeyLookup(): (key: TKey) => boolean {
    if (this.pendingSyncedTransactions.length === 0)
      return (key) => this.syncedData.has(key)
    const queued = new Map<TKey, boolean>()
    let truncated = false
    for (const transaction of this.pendingSyncedTransactions) {
      if (!transaction.committed) continue
      if (transaction.truncate) {
        queued.clear()
        truncated = true
      }
      for (const operation of transaction.operations) {
        queued.set(operation.key as TKey, operation.type !== `delete`)
      }
    }
    return (key) => queued.get(key) ?? (!truncated && this.syncedData.has(key))
  }

  enableHydrationAuthorityTracking(): void {
    this.appliedAdapterDeletedKeys ??= new Set<TKey>()
  }

  /** A late hydration seed cannot supersede committed adapter work. */
  createAdapterAuthorityLookup(): (key: TKey) => boolean {
    const queuedKeys = new Set<TKey>()
    let queuedTruncate = false
    for (const transaction of this.pendingSyncedTransactions) {
      if (!transaction.committed || transaction.preserveHydrationSeedKeys)
        continue
      if (transaction.truncate) queuedTruncate = true
      for (const operation of transaction.operations) {
        queuedKeys.add(operation.key as TKey)
      }
    }
    return (key) =>
      this.hasAppliedAdapterTruncate ||
      queuedTruncate ||
      queuedKeys.has(key) ||
      this.appliedAdapterDeletedKeys?.has(key) === true ||
      (this.syncedData.has(key) && !this.hydrationSeedKeys.has(key))
  }

  private getProjectedSyncedKeyState(
    projection: PendingSyncedProjection<TOutput, TKey>,
    key: TKey,
  ): PendingSyncedKeyState<TOutput> {
    const state = projection.states.get(key)
    if (state !== undefined) return state
    if (projection.truncated) return { exists: false, value: undefined }
    return {
      exists: this.syncedData.has(key),
      value: this.syncedData.get(key),
    }
  }

  /**
   * The synced row once every accepted sync transaction applies. A queued
   * transaction is accepted before it is visible, so a direct write must
   * read this rather than `syncedData`.
   */
  getAcceptedSyncedRow(key: TKey): TOutput | undefined {
    return this.getProjectedSyncedKeyState(this.pendingSyncedProjection, key)
      .value
  }

  /**
   * Accept a committed seed transaction, such as a DbClient hydration chunk,
   * ahead of any still-open source transaction, so that source's `commit()`
   * still targets its own writes.
   */
  acceptSeedTransaction(
    transaction: PendingSyncedTransaction<TOutput, TKey>,
  ): void {
    const open = this.pendingSyncedTransactions.findIndex(
      (pending) => !pending.committed,
    )
    if (open === -1) this.pendingSyncedTransactions.push(transaction)
    else this.pendingSyncedTransactions.splice(open, 0, transaction)
    this.refreshPendingSyncedProjection()
    this.commitPendingTransactions()
  }

  /** Every synced row once every accepted sync transaction applies. */
  *acceptedSyncedEntries(): IterableIterator<[TKey, TOutput]> {
    const { states, truncated } = this.pendingSyncedProjection
    if (!truncated) {
      for (const entry of this.syncedData.entries()) {
        if (!states.has(entry[0])) yield entry
      }
    }
    for (const [key, state] of states) {
      if (state.exists) yield [key, state.value!]
    }
  }

  private classifyProjectedInsert(
    projection: PendingSyncedProjection<TOutput, TKey>,
    key: TKey,
    value: TOutput,
  ): PendingInsertDisposition {
    const current = this.getProjectedSyncedKeyState(projection, key)
    if (!current.exists) return `insert`
    if (
      (current.value !== undefined && deepEquals(current.value, value)) ||
      this.hydrationSeedKeys.has(key)
    ) {
      return `update`
    }
    return `duplicate`
  }

  /** Classify an adapter insert against retained and queued source state. */
  classifyPendingSyncedInsert(
    key: TKey,
    value: TOutput,
  ): PendingInsertDisposition {
    return this.classifyProjectedInsert(
      this.pendingSyncedProjection,
      key,
      value,
    )
  }

  private applyPendingSyncOperation(
    projection: PendingSyncedProjection<TOutput, TKey>,
    operation: PendingSyncOperation<TOutput, TKey>,
  ): void {
    const key = operation.key as TKey
    const rowUpdateMode = this.config.sync.rowUpdateMode || `partial`
    if (operation.type === `delete`) {
      projection.states.set(key, { exists: false, value: undefined })
    } else if (operation.type === `update` && rowUpdateMode === `partial`) {
      const current = this.getProjectedSyncedKeyState(projection, key)
      projection.states.set(key, {
        exists: true,
        // Spread defines own fields; assignment would run a `__proto__` setter.
        value: { ...current.value, ...operation.value },
      })
    } else {
      projection.states.set(key, { exists: true, value: operation.value })
    }
  }

  /** Extend the queued projection after admitting one sync operation. */
  stagePendingSyncOperation(
    operation: PendingSyncOperation<TOutput, TKey>,
  ): void {
    this.applyPendingSyncOperation(this.pendingSyncedProjection, operation)
  }

  /**
   * 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)
  }

  private rebuildAutomaticRowMetadataWrites(
    transaction: PendingSyncedTransaction<TOutput, TKey>,
  ): void {
    const explicitKeys = transaction.explicitRowMetadataWriteKeys ?? new Set()
    const operationKeys = new Set(
      transaction.operations.map((operation) => operation.key as TKey),
    )
    for (const key of operationKeys) {
      if (!explicitKeys.has(key)) transaction.rowMetadataWrites.delete(key)
    }
    for (const operation of transaction.operations) {
      const key = operation.key as TKey
      if (explicitKeys.has(key)) continue
      const metadataWrite = automaticRowMetadataWrite(operation)
      if (metadataWrite) transaction.rowMetadataWrites.set(key, metadataWrite)
    }
  }

  /**
   * Rebuild the queued projection after truncate, application, or an
   * accepted seed changes queue history. An open transaction can become
   * invalid when a later transaction begun inside it commits first; it then
   * holds an invalidation error that its commit returns. Only the open last
   * transaction can be canceled, so a replay that invalidates a committed
   * transaction is an invariant failure.
   */
  private rebuildPendingSyncedProjection(): void {
    const projection: PendingSyncedProjection<TOutput, TKey> = {
      states: new Map(),
      truncated: false,
    }

    for (const transaction of this.pendingSyncedTransactions) {
      if (transaction.invalidationError !== undefined) continue
      const statesBeforeTransaction = new Map(projection.states)
      const truncatedBeforeTransaction = projection.truncated
      if (transaction.truncate) {
        projection.states = new Map()
        projection.truncated = true
      }

      let invalidKey: TKey | undefined
      for (const operation of transaction.operations) {
        const key = operation.key as TKey
        if (
          operation.originalSyncType === `insert` &&
          operation.type !== `delete`
        ) {
          const disposition = this.classifyProjectedInsert(
            projection,
            key,
            operation.value,
          )
          if (disposition === `duplicate`) {
            invalidKey = key
            break
          }
          operation.type = disposition
        }
        this.applyPendingSyncOperation(projection, operation)
      }
      if (invalidKey !== undefined) {
        if (transaction.committed)
          throw new SyncQueueInvariantError(
            `replay made an accepted insert a duplicate`,
          )
        projection.states = statesBeforeTransaction
        projection.truncated = truncatedBeforeTransaction
        transaction.invalidationError =
          transaction.duplicateKeyError?.(invalidKey) ??
          new SyncTransactionAbortedError()
        continue
      }
      this.rebuildAutomaticRowMetadataWrites(transaction)
    }

    this.pendingSyncedProjection = projection
  }

  /** Rebuild after truncate, application, or an accepted seed. */
  refreshPendingSyncedProjection(): void {
    this.rebuildPendingSyncedProjection()
  }

  /**
   * 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 = () => {
    if (this.isDrainingSyncTransactions) return
    this.isDrainingSyncTransactions = true
    let failed = false
    let firstError: unknown
    try {
      let result: { processed: boolean; failure?: { error: unknown } }
      do {
        result = this.commitNextPendingTransactionBatch()
        if (result.failure && !failed) {
          failed = true
          firstError = result.failure.error
        }
      } while (result.processed)
    } finally {
      this.isDrainingSyncTransactions = false
    }
    if (failed) throw firstError
  }

  hasPersistingTransaction(): boolean {
    for (const transaction of this.transactions.values()) {
      if (transaction.state === `persisting`) return true
    }
    return false
  }

  private commitNextPendingTransactionBatch(): {
    processed: boolean
    failure?: { error: unknown }
  } {
    const syncRunGeneration = this.syncRunGeneration
    const hasPersistingTransaction = this.hasPersistingTransaction()

    // pending synced transactions could be either `committed` or still open.
    // we only want to process `committed` transactions here
    const committedSyncedTransactions: Array<
      PendingSyncedTransaction<TOutput, TKey>
    > = []
    const uncommittedSyncedTransactions: Array<
      PendingSyncedTransaction<TOutput, TKey>
    > = []
    let hasTruncateSync = false
    let layoutChanged = false
    for (const t of this.pendingSyncedTransactions) {
      if (t.committed) {
        committedSyncedTransactions.push(t)
        layoutChanged ||= t.layoutChanged
        hasTruncateSync ||= t.truncate === true
      } else {
        uncommittedSyncedTransactions.push(t)
      }
    }

    if (committedSyncedTransactions.length === 0) {
      return { processed: false }
    }

    // A persisting transaction holds sync transactions until it settles. A
    // truncate applies at once, with every committed transaction before it,
    // so later application cannot overwrite newer state.
    if (!hasPersistingTransaction || hasTruncateSync) {
      const previousLayout = layoutChanged ? [...this.keys()] : undefined
      this.pendingSyncedTransactions = uncommittedSyncedTransactions

      // Application is now the point of no return. Event listeners run before
      // the receipts resolve, so a signal aborted from one of those listeners
      // must not cancel writes that are already becoming visible.
      const deferOrder =
        committedSyncedTransactions.reduce(
          (count, transaction) => count + transaction.operations.length,
          0,
        ) >= 512
      for (const transaction of committedSyncedTransactions) {
        transaction.applicationStarted = true
      }

      // Set flag to prevent redundant optimistic state recalculations
      this.isCommittingSyncTransactions = true

      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>()
      const syncedInsertedOrUpdatedKeys = new Set<TKey>()
      const firstSyncOperations = new Map<
        TKey,
        OptimisticChangeMessage<TOutput>
      >()
      for (const transaction of committedSyncedTransactions) {
        for (const operation of transaction.operations) {
          const key = operation.key as TKey
          changedKeys.add(key)
          if (!firstSyncOperations.has(key))
            firstSyncOperations.set(key, operation)
          if (operation.type !== `delete`) syncedInsertedOrUpdatedKeys.add(key)
        }
        for (const [key] of transaction.rowMetadataWrites) {
          changedKeys.add(key)
        }
      }

      const previousRowOrigins = this.snapshotRowOriginsForKeys(changedKeys)
      const previousOptimisticUpserts = new Map(this.optimisticUpserts)
      const previousOptimisticDeletes = new Set(this.optimisticDeletes)

      // 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 | undefined>()
        for (const key of changedKeys) {
          const currentValue = this.get(key)
          if (currentValue !== undefined) {
            currentVisibleState.set(key, currentValue)
          }
        }
      }

      const events: Array<ChangeMessage<TOutput, TKey>> = []
      let reappliedKeys: ReadonlySet<TKey> | undefined
      if (hasTruncateSync) {
        // All queued transactions publish as one batch. Its clear prefix must
        // describe the prior visible rows, not intermediate queued writes.
        // Freeze metadata before replacement writes change row attribution.
        for (const [key, value] of this.entries()) {
          events.push({
            type: `delete`,
            key,
            value: this.enrichWithVirtualPropsSnapshot(
              value,
              this.getVirtualPropsSnapshotForState(key),
            ),
          })
        }
      }
      const rowUpdateMode = this.config.sync.rowUpdateMode || `partial`
      for (const transaction of committedSyncedTransactions) {
        // Handle truncate operations first
        if (transaction.truncate) {
          // TRUNCATE PHASE
          // 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.hydrationSeedKeys.clear()
          this.hydratedKeys.clear()
          if (
            !transaction.preserveHydrationSeedKeys &&
            this.appliedAdapterDeletedKeys
          ) {
            this.hasAppliedAdapterTruncate = true
            this.appliedAdapterDeletedKeys.clear()
          }
          this.clearOriginTrackingState()

          // 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)
          }

          // Emit truncate event so subscriptions can reset their cursor tracking state
          this._events.emit(`truncate`, {
            type: `truncate`,
            collection: this.collection,
          })
        }

        // Attribution belongs to the whole atomic batch. A repeated write must
        // not forget the local acknowledgement consumed by its first operation.
        const localKeys = new Set<TKey>()
        for (const operation of transaction.operations) {
          const key = operation.key as TKey

          // Determine origin: 'local' for local-only collections or pending local changes
          const retainedLocalOrigin =
            truncatePendingLocalChanges?.has(key) === true ||
            truncatePendingLocalOrigins?.has(key) === true
          const origin: VirtualOrigin =
            this.isLocalOnly ||
            this.pendingLocalChanges.has(key) ||
            this.pendingLocalOrigins.has(key) ||
            localKeys.has(key) ||
            retainedLocalOrigin
              ? 'local'
              : 'remote'
          if (origin === `local`) localKeys.add(key)

          // A sync source may reuse a live-reading row object, making an
          // enriched snapshot cached for an earlier publication stale.
          this.virtualPropsCache.delete(key)

          if (operation.type === `delete`) {
            this.syncedData.delete(key, deferOrder)
            this.syncedMetadata.delete(key)
            this.rowOrigins.delete(key)
          } else {
            this.syncedData.set(
              key,
              operation.type === `update` && rowUpdateMode === `partial`
                ? { ...this.syncedData.get(key), ...operation.value }
                : operation.value,
              deferOrder,
            )
            this.rowOrigins.set(key, origin)
          }
          // Sync has confirmed the key, so local tracking for it ends.
          this.pendingLocalChanges.delete(key)
          this.pendingLocalOrigins.delete(key)
          this.heldOptimisticRows.delete(key)
          if (!transaction.preserveHydrationSeedKeys) {
            this.hydrationSeedKeys.delete(key)
            this.hydratedKeys.delete(key)
            if (operation.type === `delete`)
              this.appliedAdapterDeletedKeys?.add(key)
            else this.appliedAdapterDeletedKeys?.delete(key)
          }
        }

        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)
        }
      }
      this.syncedData.restoreOrder()

      // The retained base now includes every removed committed transaction.
      // Rebuild only the projection for still-open transactions.
      this.refreshPendingSyncedProjection()

      // 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

      // Always overlay any still-active optimistic transactions so mutations that started
      // after the truncate snapshot are preserved. Truncate clears attribution
      // with the old base, not the still-live local requests.
      this.overlayActiveTransactions()

      // 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) {
        // Events use the same rebuilt overlay as synchronous reads. Until
        // this point the batch holds only the truncate delete prefix, so each
        // re-applied upsert publishes as an insert after it. A key is never
        // both an optimistic upsert and an optimistic delete.
        for (const [key, value] of this.optimisticUpserts) {
          events.push({ type: `insert`, key, value })
        }

        // The changed-key loop below must not publish these keys again.
        reappliedKeys = new Set(this.optimisticUpserts.keys())
      }

      // Now check what actually changed in the final visible state
      for (const key of changedKeys) {
        // Truncate already published each re-applied upsert as an insert.
        if (reappliedKeys?.has(key)) continue
        const firstSyncOperation = firstSyncOperations.get(key)
        // A live-reading source can change a reused row before this commit
        // captures it. Later writes must not substitute an intermediate value.
        const syncPreviousValue =
          firstSyncOperation?.type === `update` &&
          currentVisibleState.get(key) === firstSyncOperation.value
            ? firstSyncOperation.previousValue
            : undefined
        const previousVisibleValue =
          !hasTruncateSync &&
          !previousOptimisticUpserts.has(key) &&
          !previousOptimisticDeletes.has(key) &&
          syncPreviousValue !== undefined
            ? syncPreviousValue
            : currentVisibleState.get(key)
        const newVisibleValue = this.get(key) // This returns the new derived state
        const previousVirtualProps =
          this.preSyncVirtualState.get(key) ??
          this.getVirtualPropsSnapshotForState(key, {
            rowOrigins: previousRowOrigins,
            optimisticUpserts: previousOptimisticUpserts,
            optimisticDeletes: previousOptimisticDeletes,
          })
        const nextVirtualProps = this.getVirtualPropsSnapshotForState(key)
        const virtualChanged =
          previousVirtualProps.$synced !== nextVirtualProps.$synced ||
          previousVirtualProps.$origin !== nextVirtualProps.$origin
        const withPreviousVirtualProps = (value: TOutput) =>
          enrichRowWithVirtualProps(
            value,
            key,
            this.collection.id,
            () => previousVirtualProps.$synced,
            () => previousVirtualProps.$origin,
          )

        if (previousVisibleValue === undefined) {
          if (newVisibleValue === undefined) continue
          // Subscribers last saw this key absent, whatever a completed
          // optimistic request held. Batching composes a buffered delete with
          // this insert into an update.
          events.push({ type: `insert`, key, value: newVisibleValue })
        } else if (newVisibleValue === undefined) {
          events.push({
            type: `delete`,
            key,
            value: withPreviousVirtualProps(previousVisibleValue),
          })
        } else if (
          virtualChanged ||
          !deepEquals(previousVisibleValue, newVisibleValue)
        ) {
          events.push({
            type: `update`,
            key,
            value: newVisibleValue,
            previousValue: withPreviousVirtualProps(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)
      // Subscribers and ready callbacks can throw. Keep the first error so
      // the applied receipts below still settle.
      let failure: { error: unknown } | undefined
      const capture = (step: () => void) => {
        try {
          step()
        } catch (error) {
          failure ??= { error }
        }
      }
      const visibleLayoutChanged =
        previousLayout !== undefined &&
        (previousLayout.length !== this.size ||
          [...this.keys()].some((key, index) => key !== previousLayout[index]))
      // markReady may skip its transition, and then the batch still emits.
      let emitted = false as boolean
      const emit = () => {
        emitted = true
        capture(() =>
          this.changes.emitEvents(events, true, visibleLayoutChanged),
        )
      }
      // A truncate that makes the Collection ready publishes its batch after
      // the status reads ready and before status listeners and ready
      // callbacks run, so their writes follow the batch they describe.
      if (hasTruncateSync && this.lifecycle.status !== `ready`)
        capture(() => this.lifecycle.markReady(emit))
      if (!emitted) emit()

      if (this.syncRunGeneration === syncRunGeneration) {
        this.preSyncVisibleState.clear()
        this.preSyncVirtualState.clear()
        Promise.resolve().then(() => {
          if (this.syncRunGeneration === syncRunGeneration) {
            this.recentlySyncedKeys.clear()
          }
        })
        this.hasReceivedFirstCommit = true
      }

      for (const transaction of committedSyncedTransactions) {
        transaction.applied.resolve()
      }
      return { processed: true, failure }
    }

    return { processed: false }
  }

  /**
   * Abandons the open last sync transaction before acceptance. Only
   * `commit(signal)` with an aborted signal reaches this, so no other
   * transaction can depend on the one canceled.
   */
  public cancelPendingSyncedTransaction(
    transaction: PendingSyncedTransaction<TOutput, TKey>,
    reason: Error = new SyncTransactionAbortedError(),
  ): void {
    if (
      transaction.committed ||
      this.pendingSyncedTransactions.at(-1) !== transaction
    )
      throw new SyncQueueInvariantError(
        `only the open last sync transaction can be canceled`,
      )
    this.pendingSyncedTransactions.pop()
    const canceledKeys = new Set<TKey>()
    for (const operation of transaction.operations) {
      canceledKeys.add(operation.key as TKey)
    }
    transaction.applied.reject(reason)
    this.rebuildPendingSyncedProjection()

    const remainingPendingKeys = new Set<TKey>()
    for (const pending of this.pendingSyncedTransactions) {
      if (pending.invalidationError !== undefined) continue
      for (const operation of pending.operations) {
        remainingPendingKeys.add(operation.key as TKey)
      }
    }
    for (const key of canceledKeys) {
      if (!remainingPendingKeys.has(key)) {
        this.recentlySyncedKeys.delete(key)
        this.preSyncVisibleState.delete(key)
        this.preSyncVirtualState.delete(key)
      }
    }

    if (this.pendingSyncedTransactions.length === 0) {
      this.preSyncVisibleState.clear()
      this.preSyncVirtualState.clear()
      this.recentlySyncedKeys.clear()
      this.changes.emitEvents([], true)
    } else {
      // Recompute after removing the canceled keys so optimistic cleanup is
      // no longer suppressed by a sync transaction that will never publish.
      this.recomputeOptimisticState(false)
    }
  }

  /**
   * 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, including
    // metadata-only writes, which the drain also compares.
    const syncedKeys = new Set<TKey>()
    for (const transaction of this.pendingSyncedTransactions) {
      // An open transaction does not publish in this drain.
      if (!transaction.committed) continue
      for (const operation of transaction.operations) {
        syncedKeys.add(operation.key as TKey)
      }
      for (const key of transaction.rowMetadataWrites.keys()) {
        syncedKeys.add(key)
      }
    }

    // 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)
        this.preSyncVisibleState.set(key, currentValue)
        if (currentValue !== undefined) {
          this.preSyncVirtualState.set(
            key,
            this.getVirtualPropsSnapshotForState(key),
          )
        }
      }
    }
  }

  /**
   * Trigger a recomputation when transactions change
   * This method should be called by the Transaction class when state changes
   */
  public onTransactionStateChange(): void {
    // Batch only when the next sync drain can actually publish. A persisting
    // sibling can keep normal sync queued; it must not hide this rollback.
    const hasPersistingTransaction = this.hasPersistingTransaction()
    this.changes.shouldBatchEvents = this.pendingSyncedTransactions.some(
      (transaction) =>
        transaction.committed &&
        (!hasPersistingTransaction || transaction.truncate),
    )

    // CRITICAL: Capture visible state BEFORE clearing optimistic state
    if (this.changes.shouldBatchEvents) 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.syncRunGeneration++
    for (const transaction of this.pendingSyncedTransactions) {
      transaction.applied.reject(new SyncTransactionAbortedError())
    }
    this.syncedData.clear()
    this.syncedMetadata.clear()
    this.syncedCollectionMetadata.clear()
    this.optimisticUpserts.clear()
    this.optimisticDeletes.clear()
    this.heldOptimisticRows.clear()
    this.hydrationSeedKeys.clear()
    this.hydratedKeys.clear()
    this.appliedAdapterDeletedKeys?.clear()
    this.hasAppliedAdapterTruncate = false
    this.clearOriginTrackingState()
    this.isLocalOnly = false
    this.localOnlyDirectWrite = undefined
    this.size = 0
    this.pendingSyncedTransactions = []
    this.pendingSyncedProjection = { states: new Map(), truncated: false }
    this.preSyncVisibleState.clear()
    this.preSyncVirtualState.clear()
    this.recentlySyncedKeys.clear()
    this.hasReceivedFirstCommit = false
  }
}