@tanstack/react-optimistic
Version:
React hooks for optimistic updates
224 lines (196 loc) • 6.68 kB
text/typescript
import {
ShapeStream,
isChangeMessage,
isControlMessage,
} from "@electric-sql/client"
import { Store } from "@tanstack/store"
import type { SyncConfig } from "@tanstack/optimistic"
import type {
ControlMessage,
Message,
Row,
ShapeStreamOptions,
} from "@electric-sql/client"
// Re-exports
export type * from "@tanstack/optimistic"
export * from "./useCollection"
export type { Collection } from "@tanstack/optimistic"
/**
* Extended SyncConfig interface with ElectricSQL-specific functionality
*/
export interface ElectricSync extends SyncConfig {
/**
* Wait for a specific transaction ID to be synced
* @param txid The transaction ID to wait for
* @param timeout Optional timeout in milliseconds (defaults to 30000ms)
* @returns Promise that resolves when the txid is synced
*/
awaitTxid: (txid: number, timeout?: number) => Promise<boolean>
/**
* Get the sync metadata for insert operations
* @returns Record containing primaryKey and relation information
*/
getSyncMetadata: () => Record<string, unknown>
}
function isUpToDateMessage<T extends Row<unknown> = Row>(
message: Message<T>
): message is ControlMessage & { up_to_date: true } {
return isControlMessage(message) && message.headers.control === `up-to-date`
}
// Check if a message contains txids in its headers
function hasTxids<T extends Row<unknown> = Row>(
message: Message<T>
): message is Message<T> & { headers: { txids?: Array<number> } } {
return (
`headers` in message &&
`txids` in message.headers &&
Array.isArray(message.headers.txids)
)
}
/**
* Creates an ElectricSQL sync configuration
*
* @param streamOptions - Configuration options for the ShapeStream
* @param options - Options for the ElectricSync configuration
* @returns ElectricSync configuration
*/
export function createElectricSync<T extends Row<unknown> = Row>(
streamOptions: ShapeStreamOptions,
options: ElectricSyncOptions
): ElectricSync {
const { primaryKey } = options
// Create a store to track seen txids
const seenTxids = new Store<Set<number>>(new Set())
// Store for the relation schema information
const relationSchema = new Store<string | undefined>(undefined)
// Function to check if a txid has been seen
const hasTxid = (txid: number): boolean => {
return seenTxids.state.has(txid)
}
// Function to await a specific txid
const awaitTxid = async (txid: number, timeout = 30000): Promise<boolean> => {
// If we've already seen this txid, resolve immediately
if (hasTxid(txid)) {
return true
}
// Otherwise, create a promise that resolves when the txid is seen
return new Promise<boolean>((resolve, reject) => {
// Set up a timeout
const timeoutId = setTimeout(() => {
unsubscribe()
reject(new Error(`Timeout waiting for txid: ${txid}`))
}, timeout)
// Subscribe to the store to watch for the txid
const unsubscribe = seenTxids.subscribe(() => {
if (hasTxid(txid)) {
clearTimeout(timeoutId)
unsubscribe()
resolve(true)
}
})
})
}
/**
* Generate a key from a row using the primaryKey columns
* @param row - The row data
* @returns A string key formed from the primary key values
*/
const generateKeyFromRow = (row: T): string => {
// eslint-disable-next-line
if (!primaryKey || primaryKey.length === 0) {
throw new Error(`Primary key is required for Electric sync`)
}
return primaryKey
.map((key) => {
const value = row[key]
if (value === undefined) {
throw new Error(`Primary key column "${key}" not found in row`)
}
return String(value)
})
.join(`|`)
}
/**
* Get the sync metadata for insert operations
* @returns Record containing primaryKey and relation information
*/
const getSyncMetadata = (): Record<string, unknown> => {
// Use the stored schema if available, otherwise default to 'public'
const schema = relationSchema.state || `public`
return {
primaryKey,
relation: streamOptions.params?.table
? [schema, streamOptions.params.table]
: undefined,
}
}
return {
sync: ({ begin, write, commit }) => {
const stream = new ShapeStream(streamOptions)
let transactionStarted = false
let newTxids = new Set<number>()
stream.subscribe((messages: Array<Message<Row>>) => {
let hasUpToDate = false
for (const message of messages) {
// Check for txids in the message and add them to our store
if (hasTxids(message) && message.headers.txids) {
message.headers.txids.forEach((txid) => newTxids.add(txid))
}
// Check if the message contains schema information
if (isChangeMessage(message) && message.headers.schema) {
// Store the schema for future use if it's a valid string
if (typeof message.headers.schema === `string`) {
const schema: string = message.headers.schema
relationSchema.setState(() => schema)
}
}
if (isChangeMessage(message)) {
if (!transactionStarted) {
begin()
transactionStarted = true
}
// Use the message's key if available, otherwise generate one from the row using primaryKey
const key =
message.key || generateKeyFromRow(message.value as unknown as T)
// Include the primary key and relation info in the metadata
const enhancedMetadata = {
...message.headers,
primaryKey,
}
write({
key,
type: message.headers.operation,
value: message.value,
metadata: enhancedMetadata,
})
} else if (isUpToDateMessage(message)) {
hasUpToDate = true
}
}
if (hasUpToDate && transactionStarted) {
commit()
seenTxids.setState((currentTxids) => {
const clonedSeen = new Set(currentTxids)
newTxids.forEach((txid) => clonedSeen.add(txid))
newTxids = new Set()
return clonedSeen
})
transactionStarted = false
}
})
},
// Expose the awaitTxid function
awaitTxid,
// Expose the getSyncMetadata function
getSyncMetadata,
}
}
/**
* Configuration options for ElectricSync
*/
export interface ElectricSyncOptions {
/**
* Array of column names that form the primary key of the shape
*/
primaryKey: Array<string>
}