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