UNPKG

@tanstack/electric-db-collection

Version:

ElectricSQL collection for TanStack DB

1,526 lines (1,382 loc) 49.9 kB
import { ShapeStream, isChangeMessage, isControlMessage, isVisibleInSnapshot, } from '@electric-sql/client' import { Store } from '@tanstack/store' import DebugModule from 'debug' import { DeduplicatedLoadSubset, and } from '@tanstack/db' import { ExpectedNumberInAwaitTxIdError, StreamAbortedError, TimeoutWaitingForMatchError, TimeoutWaitingForTxIdError, } from './errors' import { compileSQL } from './sql-compiler' import { addTagToIndex, findRowsMatchingPattern, getTagLength, isMoveOutMessage, removeTagFromIndex, tagMatchesPattern, } from './tag-index' import type { MoveOutPattern, MoveTag, ParsedMoveTag, RowId, TagIndex, } from './tag-index' import type { BaseCollectionConfig, ChangeMessageOrDeleteKeyMessage, CollectionConfig, DeleteMutationFnParams, InsertMutationFnParams, LoadSubsetOptions, SyncConfig, SyncMode, UpdateMutationFnParams, UtilsRecord, } from '@tanstack/db' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { ControlMessage, GetExtensions, Message, PostgresSnapshot, Row, ShapeStreamOptions, } from '@electric-sql/client' // Re-export for user convenience in custom match functions export { isChangeMessage, isControlMessage } from '@electric-sql/client' const debug = DebugModule.debug(`ts/db:electric`) /** * Symbol for internal test hooks (hidden from public API) */ export const ELECTRIC_TEST_HOOKS = Symbol(`electricTestHooks`) /** * Internal test hooks interface (for testing only) */ export interface ElectricTestHooks { /** * Called before marking collection ready after first up-to-date in progressive mode * Allows tests to pause and validate snapshot phase before atomic swap completes */ beforeMarkingReady?: () => Promise<void> } /** * Type representing a transaction ID in ElectricSQL */ export type Txid = number /** * Custom match function type - receives stream messages and returns boolean * indicating if the mutation has been synchronized */ export type MatchFunction<T extends Row<unknown>> = ( message: Message<T>, ) => boolean /** * Matching strategies for Electric synchronization * Handlers can return: * - Txid strategy: { txid: number | number[], timeout?: number } (recommended) * - Void (no return value) - mutation completes without waiting * * The optional timeout property specifies how long to wait for the txid(s) in milliseconds. * If not specified, defaults to 5000ms. */ export type MatchingStrategy = { txid: Txid | Array<Txid> timeout?: number } | void /** * Type representing a snapshot end message */ type SnapshotEndMessage = ControlMessage & { headers: { control: `snapshot-end` } } // The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package // but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>` // This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema type InferSchemaOutput<T> = T extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<T> extends Row<unknown> ? StandardSchemaV1.InferOutput<T> : Record<string, unknown> : Record<string, unknown> /** * The mode of sync to use for the collection. * @default `eager` * @description * - `eager`: * - syncs all data immediately on preload * - collection will be marked as ready once the sync is complete * - there is no incremental sync * - `on-demand`: * - syncs data in incremental snapshots when the collection is queried * - collection will be marked as ready immediately after the first snapshot is synced * - `progressive`: * - syncs all data for the collection in the background * - uses incremental snapshots during the initial sync to provide a fast path to the data required for queries * - collection will be marked as ready once the full sync is complete */ export type ElectricSyncMode = SyncMode | `progressive` /** * Configuration interface for Electric collection options * @template T - The type of items in the collection * @template TSchema - The schema type for validation */ export interface ElectricCollectionConfig< T extends Row<unknown> = Row<unknown>, TSchema extends StandardSchemaV1 = never, > extends Omit< BaseCollectionConfig< T, string | number, TSchema, ElectricCollectionUtils<T>, any >, `onInsert` | `onUpdate` | `onDelete` | `syncMode` > { /** * Configuration options for the ElectricSQL ShapeStream */ shapeOptions: ShapeStreamOptions<GetExtensions<T>> syncMode?: ElectricSyncMode /** * Internal test hooks (for testing only) * Hidden via Symbol to prevent accidental usage in production */ [ELECTRIC_TEST_HOOKS]?: ElectricTestHooks /** * Optional asynchronous handler function called before an insert operation * @param params Object containing transaction and collection information * @returns Promise resolving to { txid, timeout? } or void * @example * // Basic Electric insert handler with txid (recommended) * onInsert: async ({ transaction }) => { * const newItem = transaction.mutations[0].modified * const result = await api.todos.create({ * data: newItem * }) * return { txid: result.txid } * } * * @example * // Insert handler with custom timeout * onInsert: async ({ transaction }) => { * const newItem = transaction.mutations[0].modified * const result = await api.todos.create({ * data: newItem * }) * return { txid: result.txid, timeout: 10000 } // Wait up to 10 seconds * } * * @example * // Insert handler with multiple items - return array of txids * onInsert: async ({ transaction }) => { * const items = transaction.mutations.map(m => m.modified) * const results = await Promise.all( * items.map(item => api.todos.create({ data: item })) * ) * return { txid: results.map(r => r.txid) } * } * * @example * // Use awaitMatch utility for custom matching * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * await api.todos.create({ data: newItem }) * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'insert' && * message.value.name === newItem.name * ) * } */ onInsert?: ( params: InsertMutationFnParams< T, string | number, ElectricCollectionUtils<T> >, ) => Promise<MatchingStrategy> /** * Optional asynchronous handler function called before an update operation * @param params Object containing transaction and collection information * @returns Promise resolving to { txid, timeout? } or void * @example * // Basic Electric update handler with txid (recommended) * onUpdate: async ({ transaction }) => { * const { original, changes } = transaction.mutations[0] * const result = await api.todos.update({ * where: { id: original.id }, * data: changes * }) * return { txid: result.txid } * } * * @example * // Use awaitMatch utility for custom matching * onUpdate: async ({ transaction, collection }) => { * const { original, changes } = transaction.mutations[0] * await api.todos.update({ where: { id: original.id }, data: changes }) * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'update' && * message.value.id === original.id * ) * } */ onUpdate?: ( params: UpdateMutationFnParams< T, string | number, ElectricCollectionUtils<T> >, ) => Promise<MatchingStrategy> /** * Optional asynchronous handler function called before a delete operation * @param params Object containing transaction and collection information * @returns Promise resolving to { txid, timeout? } or void * @example * // Basic Electric delete handler with txid (recommended) * onDelete: async ({ transaction }) => { * const mutation = transaction.mutations[0] * const result = await api.todos.delete({ * id: mutation.original.id * }) * return { txid: result.txid } * } * * @example * // Use awaitMatch utility for custom matching * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * await api.todos.delete({ id: mutation.original.id }) * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'delete' && * message.value.id === mutation.original.id * ) * } */ onDelete?: ( params: DeleteMutationFnParams< T, string | number, ElectricCollectionUtils<T> >, ) => Promise<MatchingStrategy> } function isUpToDateMessage<T extends Row<unknown>>( message: Message<T>, ): message is ControlMessage & { up_to_date: true } { return isControlMessage(message) && message.headers.control === `up-to-date` } function isMustRefetchMessage<T extends Row<unknown>>( message: Message<T>, ): message is ControlMessage & { headers: { control: `must-refetch` } } { return isControlMessage(message) && message.headers.control === `must-refetch` } function isSnapshotEndMessage<T extends Row<unknown>>( message: Message<T>, ): message is SnapshotEndMessage { return isControlMessage(message) && message.headers.control === `snapshot-end` } function isSubsetEndMessage<T extends Row<unknown>>( message: Message<T>, ): message is ControlMessage & { headers: { control: `subset-end` } } { return ( isControlMessage(message) && (message.headers.control as string) === `subset-end` ) } function parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot { return { xmin: message.headers.xmin, xmax: message.headers.xmax, xip_list: message.headers.xip_list, } } // Check if a message contains txids in its headers function hasTxids<T extends Row<unknown>>( message: Message<T>, ): message is Message<T> & { headers: { txids?: Array<Txid> } } { return `txids` in message.headers && Array.isArray(message.headers.txids) } /** * Creates a deduplicated loadSubset handler for progressive/on-demand modes * Returns null for eager mode, or a DeduplicatedLoadSubset instance for other modes. * Handles fetching snapshots in progressive mode during buffering phase, * and requesting snapshots in on-demand mode. * * When cursor expressions are provided (whereFrom/whereCurrent), makes two * requestSnapshot calls: * - One for whereFrom (rows > cursor) with limit * - One for whereCurrent (rows = cursor, for tie-breaking) without limit */ function createLoadSubsetDedupe<T extends Row<unknown>>({ stream, syncMode, isBufferingInitialSync, begin, write, commit, collectionId, }: { stream: ShapeStream<T> syncMode: ElectricSyncMode isBufferingInitialSync: () => boolean begin: () => void write: (mutation: { type: `insert` | `update` | `delete` value: T metadata: Record<string, unknown> }) => void commit: () => void collectionId?: string }): DeduplicatedLoadSubset | null { // Eager mode doesn't need subset loading if (syncMode === `eager`) { return null } const loadSubset = async (opts: LoadSubsetOptions) => { // In progressive mode, use fetchSnapshot during snapshot phase if (isBufferingInitialSync()) { // Progressive mode snapshot phase: fetch and apply immediately const snapshotParams = compileSQL<T>(opts) try { const { data: rows } = await stream.fetchSnapshot(snapshotParams) // Check again if we're still buffering - we might have received up-to-date // and completed the atomic swap while waiting for the snapshot if (!isBufferingInitialSync()) { debug( `${collectionId ? `[${collectionId}] ` : ``}Ignoring snapshot - sync completed while fetching`, ) return } // Apply snapshot data in a sync transaction (only if we have data) if (rows.length > 0) { begin() for (const row of rows) { write({ type: `insert`, value: row.value, metadata: { ...row.headers, }, }) } commit() debug( `${collectionId ? `[${collectionId}] ` : ``}Applied snapshot with ${rows.length} rows`, ) } } catch (error) { debug( `${collectionId ? `[${collectionId}] ` : ``}Error fetching snapshot: %o`, error, ) throw error } } else if (syncMode === `progressive`) { // Progressive mode after full sync complete: no need to load more return } else { // On-demand mode: use requestSnapshot // When cursor is provided, make two calls: // 1. whereCurrent (all ties, no limit) // 2. whereFrom (rows > cursor, with limit) const { cursor, where, orderBy, limit } = opts if (cursor) { // Make parallel requests for cursor-based pagination const promises: Array<Promise<unknown>> = [] // Request 1: All rows matching whereCurrent (ties at boundary, no limit) // Combine main where with cursor.whereCurrent const whereCurrentOpts: LoadSubsetOptions = { where: where ? and(where, cursor.whereCurrent) : cursor.whereCurrent, orderBy, // No limit - get all ties } const whereCurrentParams = compileSQL<T>(whereCurrentOpts) promises.push(stream.requestSnapshot(whereCurrentParams)) debug( `${collectionId ? `[${collectionId}] ` : ``}Requesting cursor.whereCurrent snapshot (all ties)`, ) // Request 2: Rows matching whereFrom (rows > cursor, with limit) // Combine main where with cursor.whereFrom const whereFromOpts: LoadSubsetOptions = { where: where ? and(where, cursor.whereFrom) : cursor.whereFrom, orderBy, limit, } const whereFromParams = compileSQL<T>(whereFromOpts) promises.push(stream.requestSnapshot(whereFromParams)) debug( `${collectionId ? `[${collectionId}] ` : ``}Requesting cursor.whereFrom snapshot (with limit ${limit})`, ) // Wait for both requests to complete await Promise.all(promises) } else { // No cursor - standard single request const snapshotParams = compileSQL<T>(opts) await stream.requestSnapshot(snapshotParams) } } } return new DeduplicatedLoadSubset({ loadSubset }) } /** * Type for the awaitTxId utility function */ export type AwaitTxIdFn = (txId: Txid, timeout?: number) => Promise<boolean> /** * Type for the awaitMatch utility function */ export type AwaitMatchFn<T extends Row<unknown>> = ( matchFn: MatchFunction<T>, timeout?: number, ) => Promise<boolean> /** * Electric collection utilities type */ export interface ElectricCollectionUtils< T extends Row<unknown> = Row<unknown>, > extends UtilsRecord { awaitTxId: AwaitTxIdFn awaitMatch: AwaitMatchFn<T> } /** * Creates Electric collection options for use with a standard Collection * * @template T - The explicit type of items in the collection (highest priority) * @template TSchema - The schema type for validation and type inference (second priority) * @template TFallback - The fallback type if no explicit or schema type is provided * @param config - Configuration options for the Electric collection * @returns Collection options with utilities */ // Overload for when schema is provided export function electricCollectionOptions<T extends StandardSchemaV1>( config: ElectricCollectionConfig<InferSchemaOutput<T>, T> & { schema: T }, ): Omit<CollectionConfig<InferSchemaOutput<T>, string | number, T>, `utils`> & { id?: string utils: ElectricCollectionUtils<InferSchemaOutput<T>> schema: T } // Overload for when no schema is provided export function electricCollectionOptions<T extends Row<unknown>>( config: ElectricCollectionConfig<T> & { schema?: never // prohibit schema }, ): Omit<CollectionConfig<T, string | number>, `utils`> & { id?: string utils: ElectricCollectionUtils<T> schema?: never // no schema in the result } export function electricCollectionOptions<T extends Row<unknown>>( config: ElectricCollectionConfig<T, any>, ): Omit< CollectionConfig<T, string | number, any, ElectricCollectionUtils<T>>, `utils` > & { id?: string utils: ElectricCollectionUtils<T> schema?: any } { const seenTxids = new Store<Set<Txid>>(new Set([])) const seenSnapshots = new Store<Array<PostgresSnapshot>>([]) const internalSyncMode = config.syncMode ?? `eager` const finalSyncMode = internalSyncMode === `progressive` ? `on-demand` : internalSyncMode const pendingMatches = new Store< Map< string, { matchFn: (message: Message<any>) => boolean resolve: (value: boolean) => void reject: (error: Error) => void timeoutId: ReturnType<typeof setTimeout> matched: boolean } > >(new Map()) // Buffer messages since last up-to-date to handle race conditions const currentBatchMessages = new Store<Array<Message<any>>>([]) // Track whether the current batch has been committed (up-to-date received) // This allows awaitMatch to resolve immediately for messages from committed batches const batchCommitted = new Store<boolean>(false) /** * Helper function to remove multiple matches from the pendingMatches store */ const removePendingMatches = (matchIds: Array<string>) => { if (matchIds.length > 0) { pendingMatches.setState((current) => { const newMatches = new Map(current) matchIds.forEach((id) => newMatches.delete(id)) return newMatches }) } } /** * Helper function to resolve and cleanup matched pending matches */ const resolveMatchedPendingMatches = () => { const matchesToResolve: Array<string> = [] pendingMatches.state.forEach((match, matchId) => { if (match.matched) { clearTimeout(match.timeoutId) match.resolve(true) matchesToResolve.push(matchId) debug( `${config.id ? `[${config.id}] ` : ``}awaitMatch resolved on up-to-date for match %s`, matchId, ) } }) removePendingMatches(matchesToResolve) } const sync = createElectricSync<T>(config.shapeOptions, { seenTxids, seenSnapshots, syncMode: internalSyncMode, pendingMatches, currentBatchMessages, batchCommitted, removePendingMatches, resolveMatchedPendingMatches, collectionId: config.id, testHooks: config[ELECTRIC_TEST_HOOKS], }) /** * Wait for a specific transaction ID to be synced * @param txId The transaction ID to wait for as a number * @param timeout Optional timeout in milliseconds (defaults to 5000ms) * @returns Promise that resolves when the txId is synced */ const awaitTxId: AwaitTxIdFn = async ( txId: Txid, timeout: number = 5000, ): Promise<boolean> => { debug( `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`, txId, ) if (typeof txId !== `number`) { throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id) } // First check if the txid is in the seenTxids store const hasTxid = seenTxids.state.has(txId) if (hasTxid) return true // Then check if the txid is in any of the seen snapshots const hasSnapshot = seenSnapshots.state.some((snapshot) => isVisibleInSnapshot(txId, snapshot), ) if (hasSnapshot) return true return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { unsubscribeSeenTxids() unsubscribeSeenSnapshots() reject(new TimeoutWaitingForTxIdError(txId, config.id)) }, timeout) const unsubscribeSeenTxids = seenTxids.subscribe(() => { if (seenTxids.state.has(txId)) { debug( `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`, txId, ) clearTimeout(timeoutId) unsubscribeSeenTxids() unsubscribeSeenSnapshots() resolve(true) } }) const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => { const visibleSnapshot = seenSnapshots.state.find((snapshot) => isVisibleInSnapshot(txId, snapshot), ) if (visibleSnapshot) { debug( `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`, txId, visibleSnapshot, ) clearTimeout(timeoutId) unsubscribeSeenSnapshots() unsubscribeSeenTxids() resolve(true) } }) }) } /** * Wait for a custom match function to find a matching message * @param matchFn Function that returns true when a message matches * @param timeout Optional timeout in milliseconds (defaults to 5000ms) * @returns Promise that resolves when a matching message is found */ const awaitMatch: AwaitMatchFn<any> = async ( matchFn: MatchFunction<any>, timeout: number = 3000, ): Promise<boolean> => { debug( `${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function`, ) return new Promise((resolve, reject) => { const matchId = Math.random().toString(36) const cleanupMatch = () => { pendingMatches.setState((current) => { const newMatches = new Map(current) newMatches.delete(matchId) return newMatches }) } const onTimeout = () => { cleanupMatch() reject(new TimeoutWaitingForMatchError(config.id)) } const timeoutId = setTimeout(onTimeout, timeout) // We need access to the stream messages to check against the match function // This will be handled by the sync configuration const checkMatch = (message: Message<any>) => { if (matchFn(message)) { debug( `${config.id ? `[${config.id}] ` : ``}awaitMatch found matching message, waiting for up-to-date`, ) // Mark as matched but don't resolve yet - wait for up-to-date pendingMatches.setState((current) => { const newMatches = new Map(current) const existing = newMatches.get(matchId) if (existing) { newMatches.set(matchId, { ...existing, matched: true }) } return newMatches }) return true } return false } // Check against current batch messages first to handle race conditions for (const message of currentBatchMessages.state) { if (matchFn(message)) { // If batch is committed (up-to-date already received), resolve immediately // just like awaitTxId does when it finds a txid in seenTxids if (batchCommitted.state) { debug( `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in committed batch, resolving immediately`, ) clearTimeout(timeoutId) resolve(true) return } // If batch is not yet committed, register match and wait for up-to-date debug( `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in current batch, waiting for up-to-date`, ) pendingMatches.setState((current) => { const newMatches = new Map(current) newMatches.set(matchId, { matchFn: checkMatch, resolve, reject, timeoutId, matched: true, // Already matched, will resolve on up-to-date }) return newMatches }) return } } // Store the match function for the sync process to use // We'll add this to a pending matches store pendingMatches.setState((current) => { const newMatches = new Map(current) newMatches.set(matchId, { matchFn: checkMatch, resolve, reject, timeoutId, matched: false, }) return newMatches }) }) } /** * Process matching strategy and wait for synchronization */ const processMatchingStrategy = async ( result: MatchingStrategy, ): Promise<void> => { // Only wait if result contains txid if (result && `txid` in result) { const timeout = result.timeout // Handle both single txid and array of txids if (Array.isArray(result.txid)) { await Promise.all(result.txid.map((txid) => awaitTxId(txid, timeout))) } else { await awaitTxId(result.txid, timeout) } } // If result is void/undefined, don't wait - mutation completes immediately } // Create wrapper handlers for direct persistence operations that handle different matching strategies const wrappedOnInsert = config.onInsert ? async ( params: InsertMutationFnParams< any, string | number, ElectricCollectionUtils<T> >, ) => { const handlerResult = await config.onInsert!(params) await processMatchingStrategy(handlerResult) return handlerResult } : undefined const wrappedOnUpdate = config.onUpdate ? async ( params: UpdateMutationFnParams< any, string | number, ElectricCollectionUtils<T> >, ) => { const handlerResult = await config.onUpdate!(params) await processMatchingStrategy(handlerResult) return handlerResult } : undefined const wrappedOnDelete = config.onDelete ? async ( params: DeleteMutationFnParams< any, string | number, ElectricCollectionUtils<T> >, ) => { const handlerResult = await config.onDelete!(params) await processMatchingStrategy(handlerResult) return handlerResult } : undefined // Extract standard Collection config properties const { shapeOptions: _shapeOptions, onInsert: _onInsert, onUpdate: _onUpdate, onDelete: _onDelete, ...restConfig } = config return { ...restConfig, syncMode: finalSyncMode, sync, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, utils: { awaitTxId, awaitMatch, }, } } /** * Internal function to create ElectricSQL sync configuration */ function createElectricSync<T extends Row<unknown>>( shapeOptions: ShapeStreamOptions<GetExtensions<T>>, options: { syncMode: ElectricSyncMode seenTxids: Store<Set<Txid>> seenSnapshots: Store<Array<PostgresSnapshot>> pendingMatches: Store< Map< string, { matchFn: (message: Message<T>) => boolean resolve: (value: boolean) => void reject: (error: Error) => void timeoutId: ReturnType<typeof setTimeout> matched: boolean } > > currentBatchMessages: Store<Array<Message<T>>> batchCommitted: Store<boolean> removePendingMatches: (matchIds: Array<string>) => void resolveMatchedPendingMatches: () => void collectionId?: string testHooks?: ElectricTestHooks }, ): SyncConfig<T> { const { seenTxids, seenSnapshots, syncMode, pendingMatches, currentBatchMessages, batchCommitted, removePendingMatches, resolveMatchedPendingMatches, collectionId, testHooks, } = options const MAX_BATCH_MESSAGES = 1000 // Safety limit for message buffer // Store for the relation schema information const relationSchema = new Store<string | undefined>(undefined) const tagCache = new Map<MoveTag, ParsedMoveTag>() // Parses a tag string into a MoveTag. // It memoizes the result parsed tag such that future calls // for the same tag string return the same MoveTag array. const parseTag = (tag: MoveTag): ParsedMoveTag => { const cachedTag = tagCache.get(tag) if (cachedTag) { return cachedTag } const parsedTag = tag.split(`|`) tagCache.set(tag, parsedTag) return parsedTag } // Tag tracking state const rowTagSets = new Map<RowId, Set<MoveTag>>() const tagIndex: TagIndex = [] let tagLength: number | undefined = undefined /** * Initialize the tag index with the correct length */ const initializeTagIndex = (length: number): void => { if (tagIndex.length < length) { // Extend the index array to the required length for (let i = tagIndex.length; i < length; i++) { tagIndex[i] = new Map() } } } /** * Add tags to a row and update the tag index */ const addTagsToRow = ( tags: Array<MoveTag>, rowId: RowId, rowTagSet: Set<MoveTag>, ): void => { for (const tag of tags) { const parsedTag = parseTag(tag) // Infer tag length from first tag if (tagLength === undefined) { tagLength = getTagLength(parsedTag) initializeTagIndex(tagLength) } // Validate tag length matches const currentTagLength = getTagLength(parsedTag) if (currentTagLength !== tagLength) { debug( `${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`, ) continue } rowTagSet.add(tag) addTagToIndex(parsedTag, rowId, tagIndex, tagLength) } } /** * Remove tags from a row and update the tag index */ const removeTagsFromRow = ( removedTags: Array<MoveTag>, rowId: RowId, rowTagSet: Set<MoveTag>, ): void => { if (tagLength === undefined) { return } for (const tag of removedTags) { const parsedTag = parseTag(tag) rowTagSet.delete(tag) removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength) // We aggresively evict the tag from the cache // if this tag is shared with another row // and is not removed from that other row // then next time we encounter the tag it will be parsed again tagCache.delete(tag) } } /** * Process tags for a change message (add and remove tags) */ const processTagsForChangeMessage = ( tags: Array<MoveTag> | undefined, removedTags: Array<MoveTag> | undefined, rowId: RowId, ): Set<MoveTag> => { // Initialize tag set for this row if it doesn't exist (needed for checking deletion) if (!rowTagSets.has(rowId)) { rowTagSets.set(rowId, new Set()) } const rowTagSet = rowTagSets.get(rowId)! // Add new tags if (tags) { addTagsToRow(tags, rowId, rowTagSet) } // Remove tags if (removedTags) { removeTagsFromRow(removedTags, rowId, rowTagSet) } return rowTagSet } /** * Clear all tag tracking state (used when truncating) */ const clearTagTrackingState = (): void => { rowTagSets.clear() tagIndex.length = 0 tagLength = undefined } /** * Remove all tags for a row from both the tag set and the index * Used when a row is deleted */ const clearTagsForRow = (rowId: RowId): void => { if (tagLength === undefined) { return } const rowTagSet = rowTagSets.get(rowId) if (!rowTagSet) { return } // Remove each tag from the index for (const tag of rowTagSet) { const parsedTag = parseTag(tag) const currentTagLength = getTagLength(parsedTag) if (currentTagLength === tagLength) { removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength) } tagCache.delete(tag) } // Remove the row from the tag sets map rowTagSets.delete(rowId) } /** * Remove matching tags from a row based on a pattern * Returns true if the row's tag set is now empty */ const removeMatchingTagsFromRow = ( rowId: RowId, pattern: MoveOutPattern, ): boolean => { const rowTagSet = rowTagSets.get(rowId) if (!rowTagSet) { return false } // Find tags that match this pattern and remove them for (const tag of rowTagSet) { const parsedTag = parseTag(tag) if (tagMatchesPattern(parsedTag, pattern)) { rowTagSet.delete(tag) removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength!) } } // Check if row's tag set is now empty if (rowTagSet.size === 0) { rowTagSets.delete(rowId) return true } return false } /** * Process move-out event: remove matching tags from rows and delete rows with empty tag sets */ const processMoveOutEvent = ( patterns: Array<MoveOutPattern>, begin: () => void, write: (message: ChangeMessageOrDeleteKeyMessage<T>) => void, transactionStarted: boolean, ): boolean => { if (tagLength === undefined) { debug( `${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`, ) return transactionStarted } let txStarted = transactionStarted // Process all patterns and collect rows to delete for (const pattern of patterns) { // Find all rows that match this pattern const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex) for (const rowId of affectedRowIds) { if (removeMatchingTagsFromRow(rowId, pattern)) { // Delete rows with empty tag sets if (!txStarted) { begin() txStarted = true } write({ type: `delete`, key: rowId, }) } } } return txStarted } /** * Get the sync metadata for insert operations * @returns Record containing relation information */ const getSyncMetadata = (): Record<string, unknown> => { // Use the stored schema if available, otherwise default to 'public' const schema = relationSchema.state || `public` return { relation: shapeOptions.params?.table ? [schema, shapeOptions.params.table] : undefined, } } let unsubscribeStream: () => void return { sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => { const { begin, write, commit, markReady, truncate, collection } = params // Wrap markReady to wait for test hook in progressive mode let progressiveReadyGate: Promise<void> | null = null const wrappedMarkReady = (isBuffering: boolean) => { // Only create gate if we're in buffering phase (first up-to-date) if ( isBuffering && syncMode === `progressive` && testHooks?.beforeMarkingReady ) { // Create a new gate promise for this sync cycle progressiveReadyGate = testHooks.beforeMarkingReady() progressiveReadyGate.then(() => { markReady() }) } else { // No hook, not buffering, or already past first up-to-date markReady() } } // Abort controller for the stream - wraps the signal if provided const abortController = new AbortController() if (shapeOptions.signal) { shapeOptions.signal.addEventListener( `abort`, () => { abortController.abort() }, { once: true, }, ) if (shapeOptions.signal.aborted) { abortController.abort() } } // Cleanup pending matches on abort abortController.signal.addEventListener(`abort`, () => { pendingMatches.setState((current) => { current.forEach((match) => { clearTimeout(match.timeoutId) match.reject(new StreamAbortedError()) }) return new Map() // Clear all pending matches }) }) const stream = new ShapeStream({ ...shapeOptions, // In on-demand mode, we only want to sync changes, so we set the log to `changes_only` log: syncMode === `on-demand` ? `changes_only` : undefined, // In on-demand mode, we only need the changes from the point of time the collection was created // so we default to `now` when there is no saved offset. offset: shapeOptions.offset ?? (syncMode === `on-demand` ? `now` : undefined), signal: abortController.signal, onError: (errorParams) => { // Just immediately mark ready if there's an error to avoid blocking // apps waiting for `.preload()` to finish. // Note that Electric sends a 409 error on a `must-refetch` message, but the // ShapeStream handled this and it will not reach this handler, therefor // this markReady will not be triggers by a `must-refetch`. markReady() if (shapeOptions.onError) { return shapeOptions.onError(errorParams) } else { console.error( `An error occurred while syncing collection: ${collection.id}, \n` + `it has been marked as ready to avoid blocking apps waiting for '.preload()' to finish. \n` + `You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`, errorParams, ) } return }, }) let transactionStarted = false const newTxids = new Set<Txid>() const newSnapshots: Array<PostgresSnapshot> = [] let hasReceivedUpToDate = false // Track if we've completed initial sync in progressive mode // Progressive mode state // Helper to determine if we're buffering the initial sync const isBufferingInitialSync = () => syncMode === `progressive` && !hasReceivedUpToDate const bufferedMessages: Array<Message<T>> = [] // Buffer change messages during initial sync /** * Process a change message: handle tags and write the mutation */ const processChangeMessage = (changeMessage: Message<T>) => { if (!isChangeMessage(changeMessage)) { return } // Process tags if present const tags = changeMessage.headers.tags const removedTags = changeMessage.headers.removed_tags const hasTags = tags || removedTags const rowId = collection.getKeyFromItem(changeMessage.value) const operation = changeMessage.headers.operation if (operation === `delete`) { clearTagsForRow(rowId) } else if (hasTags) { processTagsForChangeMessage(tags, removedTags, rowId) } write({ type: changeMessage.headers.operation, value: changeMessage.value, // Include the primary key and relation info in the metadata metadata: { ...changeMessage.headers, }, }) } // Create deduplicated loadSubset wrapper for non-eager modes // This prevents redundant snapshot requests when multiple concurrent // live queries request overlapping or subset predicates const loadSubsetDedupe = createLoadSubsetDedupe({ stream, syncMode, isBufferingInitialSync, begin, write, commit, collectionId, }) unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => { // Track commit point type - up-to-date takes precedence as it also triggers progressive mode atomic swap let commitPoint: `up-to-date` | `subset-end` | null = null // Don't clear the buffer between batches - this preserves messages for awaitMatch // to find even if multiple batches arrive before awaitMatch is called. // The buffer is naturally limited by MAX_BATCH_MESSAGES (oldest messages are dropped). // Reset batchCommitted since we're starting a new batch batchCommitted.setState(() => false) for (const message of messages) { // Add message to current batch buffer (for race condition handling) if (isChangeMessage(message) || isMoveOutMessage(message)) { currentBatchMessages.setState((currentBuffer) => { const newBuffer = [...currentBuffer, message] // Limit buffer size for safety if (newBuffer.length > MAX_BATCH_MESSAGES) { newBuffer.splice(0, newBuffer.length - MAX_BATCH_MESSAGES) } return newBuffer }) } // Check for txids in the message and add them to our store // Skip during buffered initial sync in progressive mode (txids will be extracted during atomic swap) if (hasTxids(message) && !isBufferingInitialSync()) { message.headers.txids?.forEach((txid) => newTxids.add(txid)) } // Check pending matches against this message // Note: matchFn will mark matches internally, we don't resolve here const matchesToRemove: Array<string> = [] pendingMatches.state.forEach((match, matchId) => { if (!match.matched) { try { match.matchFn(message) } catch (err) { // If matchFn throws, clean up and reject the promise clearTimeout(match.timeoutId) match.reject( err instanceof Error ? err : new Error(String(err)), ) matchesToRemove.push(matchId) debug(`matchFn error: %o`, err) } } }) // Remove matches that errored removePendingMatches(matchesToRemove) if (isChangeMessage(message)) { // Check if the message contains schema information const schema = message.headers.schema if (schema && typeof schema === `string`) { // Store the schema for future use if it's a valid string relationSchema.setState(() => schema) } // In buffered initial sync of progressive mode, buffer messages instead of writing if (isBufferingInitialSync()) { bufferedMessages.push(message) } else { // Normal processing: write changes immediately if (!transactionStarted) { begin() transactionStarted = true } processChangeMessage(message) } } else if (isSnapshotEndMessage(message)) { // Track postgres snapshot metadata for resolving awaiting mutations // Skip during buffered initial sync (will be extracted during atomic swap) if (!isBufferingInitialSync()) { newSnapshots.push(parseSnapshotMessage(message)) } } else if (isUpToDateMessage(message)) { // up-to-date takes precedence - also triggers progressive mode atomic swap commitPoint = `up-to-date` } else if (isSubsetEndMessage(message)) { // subset-end triggers commit but not progressive mode atomic swap if (commitPoint !== `up-to-date`) { commitPoint = `subset-end` } } else if (isMoveOutMessage(message)) { // Handle move-out event: buffer if buffering, otherwise process immediately if (isBufferingInitialSync()) { bufferedMessages.push(message) } else { // Normal processing: process move-out immediately transactionStarted = processMoveOutEvent( message.headers.patterns, begin, write, transactionStarted, ) } } else if (isMustRefetchMessage(message)) { debug( `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`, ) // Start a transaction and truncate the collection if (!transactionStarted) { begin() transactionStarted = true } truncate() // Clear tag tracking state clearTagTrackingState() // Reset the loadSubset deduplication state since we're starting fresh // This ensures that previously loaded predicates don't prevent refetching after truncate loadSubsetDedupe?.reset() // Reset flags so we continue accumulating changes until next up-to-date commitPoint = null hasReceivedUpToDate = false // Reset for progressive mode (isBufferingInitialSync will reflect this) bufferedMessages.length = 0 // Clear buffered messages } } if (commitPoint !== null) { // PROGRESSIVE MODE: Atomic swap on first up-to-date (not subset-end) if (isBufferingInitialSync() && commitPoint === `up-to-date`) { debug( `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`, ) // Start atomic swap transaction begin() // Truncate to clear all snapshot data truncate() // Clear tag tracking state for atomic swap clearTagTrackingState() // Apply all buffered change messages and extract txids/snapshots for (const bufferedMsg of bufferedMessages) { if (isChangeMessage(bufferedMsg)) { processChangeMessage(bufferedMsg) // Extract txids from buffered messages (will be committed to store after transaction) if (hasTxids(bufferedMsg)) { bufferedMsg.headers.txids?.forEach((txid) => newTxids.add(txid), ) } } else if (isSnapshotEndMessage(bufferedMsg)) { // Extract snapshots from buffered messages (will be committed to store after transaction) newSnapshots.push(parseSnapshotMessage(bufferedMsg)) } else if (isMoveOutMessage(bufferedMsg)) { // Process buffered move-out messages during atomic swap processMoveOutEvent( bufferedMsg.headers.patterns, begin, write, transactionStarted, ) } } // Commit the atomic swap commit() // Exit buffering phase by marking that we've received up-to-date // isBufferingInitialSync() will now return false bufferedMessages.length = 0 debug( `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Atomic swap complete, now in normal sync mode`, ) } else { // Normal mode or on-demand: commit transaction if one was started // Both up-to-date and subset-end trigger a commit if (transactionStarted) { commit() transactionStarted = false } } wrappedMarkReady(isBufferingInitialSync()) // Track that we've received the first up-to-date for progressive mode if (commitPoint === `up-to-date`) { hasReceivedUpToDate = true } // Always commit txids when we receive up-to-date, regardless of transaction state seenTxids.setState((currentTxids) => { const clonedSeen = new Set<Txid>(currentTxids) if (newTxids.size > 0) { debug( `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`, Array.from(newTxids), ) } newTxids.forEach((txid) => clonedSeen.add(txid)) newTxids.clear() return clonedSeen }) // Always commit snapshots when we receive up-to-date, regardless of transaction state seenSnapshots.setState((currentSnapshots) => { const seen = [...currentSnapshots, ...newSnapshots] newSnapshots.forEach((snapshot) => debug( `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`, snapshot, ), ) newSnapshots.length = 0 return seen }) // Resolve all matched pending matches on up-to-date or subset-end // Set batchCommitted BEFORE resolving to avoid timing window where late awaitMatch // calls could register as "matched" after resolver pass already ran batchCommitted.setState(() => true) resolveMatchedPendingMatches() } }) // Return the deduplicated loadSubset if available (on-demand or progressive mode) // The loadSubset method is auto-bound, so it can be safely returned directly return { loadSubset: loadSubsetDedupe?.loadSubset, cleanup: () => { // Unsubscribe from the stream unsubscribeStream() // Abort the abort controller to stop the stream abortController.abort() // Reset deduplication tracking so collection can load fresh data if restarted loadSubsetDedupe?.reset() }, } }, // Expose the getSyncMetadata function getSyncMetadata, } }