컬렉션 옵션 생성기 만들기
컬렉션 옵션 생성기는 TanStack DB 컬렉션의 구성 옵션을 생성하는 팩토리 함수입니다. 다양한 동기화 엔진과 데이터 소스를 TanStack DB의 반응형 sync-first 아키텍처와 통합하는 표준화된 방법을 제공합니다.
개요
컬렉션 옵션 생성기는 일관된 패턴을 따릅니다:
- 동기화 엔진에 맞는 구성을 받습니다
CollectionConfig인터페이스를 충족하는 객체를 반환합니다- 동기화 초기화, 데이터 구문 분석 및 트랜잭션 관리를 처리합니다
- 동기화 엔진에 특화된 유틸리티 함수를 선택적으로 제공합니다
사용자 지정 컬렉션을 만들어야 하는 경우
다음과 같은 경우 사용자 지정 컬렉션을 만들어야 합니다:
- 전용 동기화 엔진이 있는 경우(ElectricSQL, Trailbase, Firebase, RxDB 또는 사용자 지정 WebSocket 솔루션 등)
- 쿼리 컬렉션에서 지원하지 않는 특정 동기화 동작이 필요한 경우
- 자체 동기화 프로토콜을 사용하는 백엔드와 통합하려는 경우
참고: API를 호출하고 데이터를 반환하기만 한다면 쿼리 컬렉션을 사용합니다.
핵심 요구 사항
모든 컬렉션 옵션 생성기는 다음과 같은 주요 책임을 구현해야 합니다:
1. 구성 인터페이스
표준 컬렉션 속성을 확장하거나 포함하는 구성 인터페이스를 정의합니다:
// Pattern A: User provides handlers (Query / ElectricSQL style)
interface MyCollectionConfig<TItem extends object> {
// Your sync engine specific options
connectionUrl: string
apiKey?: string
// Standard collection properties
id?: string
schema?: StandardSchemaV1
getKey: (item: TItem) => string | number
sync?: SyncConfig<TItem>
rowUpdateMode?: 'partial' | 'full'
// User provides mutation handlers
onInsert?: InsertMutationFn<TItem>
onUpdate?: UpdateMutationFn<TItem>
onDelete?: DeleteMutationFn<TItem>
}
// Pattern B: Built-in handlers (Trailbase style)
interface MyCollectionConfig<TItem extends object>
extends Omit<CollectionConfig<TItem>, 'onInsert' | 'onUpdate' | 'onDelete'> {
// Your sync engine specific options
recordApi: MyRecordApi<TItem>
connectionUrl: string
rowUpdateMode?: 'partial' | 'full'
// Note: onInsert/onUpdate/onDelete are implemented by your collection creator
}
2. 동기화 구현
sync 함수는 컬렉션의 핵심입니다. 다음을 수행해야 합니다:
sync 함수는 적절한 가비지 컬렉션을 위해 정리 함수를 반환해야 합니다:
const sync: SyncConfig<T>['sync'] = (params) => {
const { begin, write, commit, markReady, markError, collection } = params
// 1. Initialize connection to your sync engine
const connection = initializeConnection(config)
const initialSyncAbort = new AbortController()
// 2. Set up real-time subscription FIRST (prevents race conditions)
const eventBuffer: Array<any> = []
let isInitialSyncComplete = false
connection.subscribe((event) => {
if (!isInitialSyncComplete) {
// Buffer events during initial sync to prevent race conditions
eventBuffer.push(event)
return
}
// Process real-time events
begin()
switch (event.type) {
case 'insert':
write({ type: 'insert', value: event.data })
break
case 'update':
write({ type: 'update', value: event.data })
break
case 'delete':
write({ type: 'delete', value: event.data })
break
}
commit()
})
// 3. Perform initial data fetch
async function initialSync() {
try {
const data = await fetchInitialData({ signal: initialSyncAbort.signal })
begin() // Start a transaction
for (const item of data) {
write({
type: 'insert',
value: item
})
}
commit() // Commit the transaction
// 4. Process buffered events
isInitialSyncComplete = true
if (eventBuffer.length > 0) {
begin()
for (const event of eventBuffer) {
// Deduplicate if necessary based on your sync engine
write({ type: event.type, value: event.data })
}
commit()
eventBuffer.splice(0)
}
// A complete initial snapshot is now available.
markReady()
} catch (error) {
if (initialSyncAbort.signal.aborted) return
console.error('Initial sync failed:', error)
// No usable initial snapshot exists.
// Only initial startup owns collection readiness. A later refetch
// failure must keep the last ready snapshot usable.
if (collection.status === 'loading') markError(error)
}
}
initialSync()
// 4. Return cleanup function
return () => {
initialSyncAbort.abort()
connection.close()
// Clean up any timers, intervals, or other resources
}
}
3. 트랜잭션 수명 주기
올바르게 구현하려면 트랜잭션 수명 주기를 이해하는 것이 중요합니다.
동기화 프로세스는 다음 수명 주기를 따릅니다:
- begin() - 변경 사항 수집을 시작합니다
- write() - 보류 중인 트랜잭션에 변경 사항을 추가합니다(커밋될 때까지 버퍼링됨)
- commit() - 모든 변경 사항을 컬렉션 상태에 원자적으로 적용합니다
- markReady() - 사용할 수 있는 초기 또는 복구된 스냅샷이 있음을 알립니다
- markError(error?) - 사용할 수 있는 스냅샷을 생성하기 전에 초기 동기화가 실패했음을 알립니다. readiness 대기가 해당 오류와 함께 거부되도록 원인을 전달합니다
경쟁 조건 방지: 많은 동기화 엔진은 초기 동기화가 완료되기 전에 실시간 구독을 시작합니다. 초기 동기화와 동일한 데이터를 나타내는 구독 이벤트가 중복 처리되지 않도록 구현에서 반드시 중복 제거해야 합니다. 다음을 고려합니다:
- 초기 가져오기 전에 리스너를 시작하고 이벤트를 버퍼링합니다
- 타임스탬프, 시퀀스 번호 또는 문서 버전을 추적합니다
- 읽기 타임스탬프 또는 기타 순서 지정 메커니즘을 사용합니다
4. 데이터 구문 분석 및 타입 변환
동기화 엔진이 서로 다른 타입의 데이터를 반환하는 경우 특정 필드에 대한 변환 함수를 제공합니다:
interface MyCollectionConfig<TItem, TRecord> {
// ... other config
// Only specify conversions for fields that need type conversion
parse: {
created_at: (ts: number) => new Date(ts * 1000), // timestamp -> Date
updated_at: (ts: number) => new Date(ts * 1000), // timestamp -> Date
metadata?: (str: string) => JSON.parse(str) // JSON string -> object
}
serialize: {
created_at: (date: Date) => Math.floor(date.valueOf() / 1000), // Date -> timestamp
updated_at: (date: Date) => Math.floor(date.valueOf() / 1000), // Date -> timestamp
metadata?: (obj: object) => JSON.stringify(obj) // object -> JSON string
}
}
타입 변환 예시:
// Firebase Timestamp to Date
parse: {
createdAt: (timestamp) => timestamp?.toDate?.() || new Date(timestamp),
updatedAt: (timestamp) => timestamp?.toDate?.() || new Date(timestamp),
}
// PostGIS geometry to GeoJSON
parse: {
location: (wkb: string) => parseWKBToGeoJSON(wkb)
}
// JSON string to object with error handling
parse: {
metadata: (str: string) => {
try {
return JSON.parse(str)
} catch {
return {}
}
}
}
5. 스키마 및 타입 변환
사용자 지정 컬렉션을 만들 때는 백엔드의 저장 형식과 사용자가 컬렉션에서 사용하는 클라이언트 측 타입 간의 관계를 처리하는 방법을 결정해야 합니다.
두 가지 별도 관심사
백엔드 형식 - 저장소 계층에서 사용하는 타입(SQLite, Postgres, Firebase 등)
- 예시: Unix 타임스탬프, ISO 문자열, JSON 문자열, PostGIS 지오메트리
클라이언트 형식 - 사용자가 TanStack DB 컬렉션에서 사용하는 타입
- 예시: Date 객체, 구문 분석된 JSON, GeoJSON
TanStack DB의 스키마는 클라이언트 형식(뮤테이션의 TInput/TOutput)을 정의합니다. 백엔드 형식과 클라이언트 형식을 연결하는 방법은 통합 설계에 따라 달라집니다.
접근 방식 1: 통합에서 구문 분석/직렬화 헬퍼 제공
특정 저장 형식을 사용하는 백엔드의 경우 사용자가 구성하는 parse/serialize 옵션을 제공합니다:
// TrailBase example: User specifies field conversions
export function trailbaseCollectionOptions(config) {
return {
parse: config.parse, // User provides field conversions
serialize: config.serialize,
onInsert: async ({ transaction }) => {
const serialized = transaction.mutations.map(m =>
serializeFields(m.modified, config.serialize)
)
await config.recordApi.createBulk(serialized)
}
}
}
// User explicitly configures conversions
const collection = createCollection(
trailbaseCollectionOptions({
schema: todoSchema,
parse: {
created_at: (ts: number) => new Date(ts * 1000) // Unix → Date
},
serialize: {
created_at: (date: Date) => Math.floor(date.valueOf() / 1000) // Date → Unix
}
})
)
이점: 타입 변환을 명시적으로 제어할 수 있습니다. 통합이 해당 변환을 일관되게 적용합니다.
접근 방식 2: 사용자가 QueryFn/핸들러에서 모든 작업 처리
간단한 API를 사용하거나 사용자가 완전한 제어를 원할 때는 구문 분석/직렬화를 직접 처리합니다:
// Query Collection: User handles all transformations
const collection = createCollection(
queryCollectionOptions({
schema: todoSchema,
queryFn: async () => {
const response = await fetch('/api/todos')
const todos = await response.json()
// User manually parses to match their schema's TOutput
return todos.map(todo => ({
...todo,
created_at: new Date(todo.created_at) // ISO string → Date
}))
},
onInsert: async ({ transaction }) => {
// User manually serializes for their backend
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({
...transaction.mutations[0].modified,
created_at: transaction.mutations[0].modified.created_at.toISOString() // Date → ISO string
})
})
}
})
)
이점: 유연성이 가장 높고 추상화 오버헤드가 없습니다. 사용자는 정확히 무슨 일이 일어나는지 확인할 수 있습니다.
접근 방식 3: 핸들러에서 자동 직렬화
백엔드에 잘 정의된 타입이 있다면 뮤테이션 핸들러에서 자동으로 직렬화할 수 있습니다:
export function myCollectionOptions(config) {
return {
onInsert: async ({ transaction }) => {
// Automatically serialize known types for your backend
const serialized = transaction.mutations.map(m => ({
...m.modified,
// Date objects → Unix timestamps for your backend
created_at: m.modified.created_at instanceof Date
? Math.floor(m.modified.created_at.valueOf() / 1000)
: m.modified.created_at
}))
await backend.insert(serialized)
}
}
}
이점: 사용자에게 필요한 구성이 가장 적습니다. 통합이 백엔드 형식을 자동으로 처리합니다.
주요 설계 원칙
- 스키마는 클라이언트 뮤테이션만 검증합니다 - 동기화 중 백엔드 데이터의 구문 분석 방식에는 영향을 주지 않습니다
- TOutput은 애플리케이션에서 사용하는 타입입니다 - 사용자가 애플리케이션에서 다루는 타입입니다
- 백엔드 제약 조건에 따라 접근 방식을 선택합니다 - 고정된 타입 → 자동 직렬화, 가변적인 타입 → 사용자 구성
- 백엔드 형식을 명확하게 문서화합니다 - 저장소에서 사용하는 타입과 처리 방법을 설명합니다
사용자 관점의 스키마에 대한 자세한 내용은 스키마 가이드를 참조합니다.
6. 뮤테이션 핸들러 패턴
컬렉션 옵션 생성기에서 뮤테이션을 처리하는 방식에는 서로 다른 두 가지 패턴이 있습니다:
패턴 A: 사용자 제공 핸들러(ElectricSQL, Query)
사용자가 구성에 뮤테이션 핸들러를 제공합니다. 컬렉션 생성기는 이를 전달합니다:
interface MyCollectionConfig<TItem extends object> {
// ... other config
// User provides these handlers
onInsert?: InsertMutationFn<TItem>
onUpdate?: UpdateMutationFn<TItem>
onDelete?: DeleteMutationFn<TItem>
}
export function myCollectionOptions<TItem extends object>(
config: MyCollectionConfig<TItem>
) {
return {
// ... other options
rowUpdateMode: config.rowUpdateMode || 'partial',
// Pass through user-provided handlers (possibly with additional logic)
onInsert: config.onInsert ? async (params) => {
const result = await config.onInsert!(params)
// Additional sync coordination logic
return result
} : undefined
}
}
패턴 B: 기본 제공 핸들러(Trailbase, WebSocket, Firebase)
컬렉션 생성기가 동기화 엔진의 API를 사용하여 핸들러를 직접 구현합니다:
interface MyCollectionConfig<TItem extends object>
extends Omit<CollectionConfig<TItem>, 'onInsert' | 'onUpdate' | 'onDelete'> {
// ... sync engine specific config
// Note: onInsert/onUpdate/onDelete are NOT in the config
}
export function myCollectionOptions<TItem extends object>(
config: MyCollectionConfig<TItem>
) {
return {
// ... other options
rowUpdateMode: config.rowUpdateMode || 'partial',
// Implement handlers using sync engine APIs
onInsert: async ({ transaction }) => {
// Handle provider-specific batch limits (e.g., Firestore's 500 limit)
const chunks = chunkArray(transaction.mutations, PROVIDER_BATCH_LIMIT)
for (const chunk of chunks) {
const ids = await config.recordApi.createBulk(
chunk.map(m => serialize(m.modified))
)
await awaitIds(ids)
}
return transaction.mutations.map(m => m.key)
},
onUpdate: async ({ transaction }) => {
const chunks = chunkArray(transaction.mutations, PROVIDER_BATCH_LIMIT)
for (const chunk of chunks) {
await Promise.all(
chunk.map(m =>
config.recordApi.update(m.key, serialize(m.changes))
)
)
}
await awaitIds(transaction.mutations.map(m => String(m.key)))
}
}
}
많은 공급자는 배치 크기 제한을 두므로(Firestore: 500, DynamoDB: 25 등) 큰 트랜잭션을 그에 맞게 청크로 나눕니다.
사용자가 자체 API를 제공해야 하는 경우 패턴 A를 선택하고, 동기화 엔진이 쓰기를 직접 처리하는 경우 패턴 B를 선택합니다.
행 업데이트 모드
컬렉션은 두 가지 업데이트 모드를 지원합니다:
partial(기본값) - 업데이트가 기존 데이터와 병합됩니다full- 업데이트가 전체 행을 대체합니다
동기화 구성에서 이를 설정합니다:
sync: {
sync: syncFn,
rowUpdateMode: 'full' // or 'partial'
}
프로덕션 예시
완전하고 프로덕션에 바로 사용할 수 있는 예시는 TanStack DB 리포지토리의 컬렉션 패키지를 참조합니다:
- @tanstack/query-collection - 패턴 A: 전체 다시 가져오기 전략을 사용하는 사용자 제공 핸들러
- @tanstack/trailbase-collection - 패턴 B: ID 기반 추적을 사용하는 기본 제공 핸들러
- @tanstack/electric-collection - 패턴 A: 복잡한 동기화 프로토콜을 사용하는 트랜잭션 ID 추적
- @tanstack/rxdb-collection - 패턴 B: RxDB 변경 스트림을 TanStack DB의 동기화 수명 주기에 연결하는 기본 제공 핸들러
프로덕션 컬렉션의 주요 교훈
Query Collection에서:
- 가장 간단한 접근 방식: 뮤테이션 후 전체 다시 가져오기
- 적합한 경우: 실시간 동기화가 없는 API
- 패턴: 사용자가
onInsert/onUpdate/onDelete핸들러를 제공합니다
Trailbase Collection에서:
- ID 기반 낙관적 상태 관리를 보여줍니다
- 제공자의 배치 제한을 처리합니다(대규모 작업을 청크로 나눔)
- 패턴: 컬렉션이 레코드 API를 사용하는 뮤테이션 핸들러를 제공합니다
Electric Collection에서:
- 분산 동기화를 위한 복잡한 트랜잭션 ID 추적
- 고급 중복 제거 기법을 보여줍니다
- 동기화 조정을 위해 사용자 핸들러를 래핑하는 방법을 보여줍니다
RxDB Collection에서:
- RxDB의 기본 제공 쿼리와 변경 스트림을 사용합니다
RxCollection.$을 사용하여 삽입/업데이트/삭제를 구독하고 begin-write-commit을 통해 TanStack DB로 전달합니다- RxDB API(bulkUpsert, incrementalPatch, bulkRemove)를 호출하는 기본 제공 뮤테이션 핸들러(onInsert, onUpdate, onDelete)를 구현합니다
전체 예제: WebSocket 컬렉션
다음은 전체 왕복 흐름을 보여주는 WebSocket 기반 컬렉션 옵션 생성기의 전체 예제입니다:
- 클라이언트가 모든 뮤테이션을 함께 배치한 트랜잭션을 보냅니다
- 서버가 트랜잭션을 처리하고 데이터를 수정할 수 있습니다(검증, 타임스탬프 등)
- 서버가 승인과 실제 처리된 데이터를 돌려보냅니다
- 클라이언트가 낙관적 상태를 삭제하기 전에 이 왕복을 기다립니다
import type {
CollectionConfig,
SyncConfig,
InsertMutationFnParams,
UpdateMutationFnParams,
DeleteMutationFnParams,
UtilsRecord
} from '@tanstack/db'
interface WebSocketMessage<T> {
type: 'insert' | 'update' | 'delete' | 'sync' | 'transaction' | 'ack'
data?: T | T[]
mutations?: Array<{
type: 'insert' | 'update' | 'delete'
data: T
id?: string
}>
transactionId?: string
id?: string
}
interface WebSocketCollectionConfig<TItem extends object>
extends Omit<CollectionConfig<TItem>, 'onInsert' | 'onUpdate' | 'onDelete' | 'sync'> {
url: string
reconnectInterval?: number
// Note: onInsert/onUpdate/onDelete are handled by the WebSocket connection
// Users don't provide these handlers
}
interface WebSocketUtils extends UtilsRecord {
reconnect: () => void
getConnectionState: () => 'connected' | 'disconnected' | 'connecting'
}
export function webSocketCollectionOptions<TItem extends object>(
config: WebSocketCollectionConfig<TItem>
): CollectionConfig<TItem> & { utils: WebSocketUtils } {
let ws: WebSocket | null = null
let reconnectTimer: NodeJS.Timeout | null = null
let connectionState: 'connected' | 'disconnected' | 'connecting' = 'disconnected'
// Track pending transactions awaiting acknowledgment
const pendingTransactions = new Map<string, {
resolve: () => void
reject: (error: Error) => void
timeout: NodeJS.Timeout
}>()
const sync: SyncConfig<TItem>['sync'] = (params) => {
const { begin, write, commit, markReady } = params
function connect() {
connectionState = 'connecting'
ws = new WebSocket(config.url)
ws.onopen = () => {
connectionState = 'connected'
// Request initial sync
ws.send(JSON.stringify({ type: 'sync' }))
}
ws.onmessage = (event) => {
const message: WebSocketMessage<TItem> = JSON.parse(event.data)
switch (message.type) {
case 'sync':
// Initial sync with array of items
begin()
if (Array.isArray(message.data)) {
for (const item of message.data) {
write({ type: 'insert', value: item })
}
}
commit()
markReady()
break
case 'insert':
case 'update':
case 'delete':
// Real-time updates from other clients
begin()
write({
type: message.type,
value: message.data as TItem
})
commit()
break
case 'ack':
// Server acknowledged our transaction
if (message.transactionId) {
const pending = pendingTransactions.get(message.transactionId)
if (pending) {
clearTimeout(pending.timeout)
pendingTransactions.delete(message.transactionId)
pending.resolve()
}
}
break
case 'transaction':
// Server sending back the actual data after processing our transaction
if (message.mutations) {
begin()
for (const mutation of message.mutations) {
write({
type: mutation.type,
value: mutation.data
})
}
commit()
}
break
}
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
connectionState = 'disconnected'
}
ws.onclose = () => {
connectionState = 'disconnected'
// Auto-reconnect
if (!reconnectTimer) {
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, config.reconnectInterval || 5000)
}
}
}
// Start connection
connect()
// Return cleanup function
return () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) {
ws.close()
ws = null
}
}
}
// Helper function to send transaction and wait for server acknowledgment
const sendTransaction = async (
params: InsertMutationFnParams<TItem> | UpdateMutationFnParams<TItem> | DeleteMutationFnParams<TItem>
): Promise<void> => {
if (ws?.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected')
}
const transactionId = crypto.randomUUID()
// Convert all mutations in the transaction to the wire format
const mutations = params.transaction.mutations.map(mutation => ({
type: mutation.type,
id: mutation.key,
data: mutation.type === 'delete' ? undefined :
mutation.type === 'update' ? mutation.changes :
mutation.modified
}))
// Send the entire transaction at once
ws.send(JSON.stringify({
type: 'transaction',
transactionId,
mutations
}))
// Wait for server acknowledgment
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingTransactions.delete(transactionId)
reject(new Error(`Transaction ${transactionId} timed out`))
}, 10000) // 10 second timeout
pendingTransactions.set(transactionId, {
resolve,
reject,
timeout
})
})
}
// All mutation handlers use the same transaction sender
const onInsert = async (params: InsertMutationFnParams<TItem>) => {
await sendTransaction(params)
}
const onUpdate = async (params: UpdateMutationFnParams<TItem>) => {
await sendTransaction(params)
}
const onDelete = async (params: DeleteMutationFnParams<TItem>) => {
await sendTransaction(params)
}
return {
id: config.id,
schema: config.schema,
getKey: config.getKey,
sync: { sync },
onInsert,
onUpdate,
onDelete,
utils: {
reconnect: () => {
if (ws) ws.close()
connect()
},
getConnectionState: () => connectionState
}
}
}
사용 예제
import { DbClient, collectionOptions } from '@tanstack/react-db'
import { webSocketCollectionOptions } from './websocket-collection'
const db = new DbClient()
const todosCollection = collectionOptions('todos', () =>
webSocketCollectionOptions({
id: 'todos',
url: 'ws://localhost:8080/todos',
getKey: (todo) => todo.id,
schema: todoSchema,
// Note: No onInsert/onUpdate/onDelete - handled by WebSocket automatically
})
)
const todos = db.collection(todosCollection)
// Use the collection
todos.insert({ id: '1', text: 'Buy milk', completed: false })
// Access utilities
todos.utils.getConnectionState() // 'connected'
todos.utils.reconnect() // Force reconnect
고급: 낙관적 상태 관리
동기화 우선 앱의 핵심 과제는 낙관적 상태를 언제 삭제할지 아는 것입니다. 사용자가 변경하면 다음과 같이 진행됩니다:
- UI가 즉시 업데이트됩니다(낙관적 업데이트)
- 뮤테이션이 백엔드로 전송됩니다
- 백엔드가 변경 사항을 처리하고 영속화합니다
- 변경 사항이 클라이언트로 다시 동기화됩니다
- 동기화된 데이터를 사용하기 위해 낙관적 상태를 삭제해야 합니다
핵심 질문은 다음과 같습니다: 4단계가 완료되었는지 어떻게 알 수 있습니까?
전략 1: 기본 제공 프로바이더 메서드(권장)
많은 프로바이더는 동기화 완료를 기다리는 기본 제공 메서드를 제공합니다:
// Firebase
await waitForPendingWrites(firestore)
// Custom WebSocket
await websocket.waitForAck(transactionId)
전략 2: 트랜잭션 ID 추적(ElectricSQL)
ElectricSQL은 추적할 수 있는 트랜잭션 ID를 반환합니다:
// Track seen transaction IDs
const seenTxids = new Store<Set<number>>(new Set())
// In sync, track txids from incoming messages
if (message.headers.txids) {
message.headers.txids.forEach(txid => {
seenTxids.setState(prev => new Set([...prev, txid]))
})
}
// Mutation handlers return txids and wait for them
const wrappedOnInsert = async (params) => {
const result = await config.onInsert!(params)
// Wait for the txid to appear in synced data
if (result.txid) {
await awaitTxId(result.txid)
}
return result
}
// Utility function to wait for a txid
const awaitTxId = (txId: number): Promise<boolean> => {
if (seenTxids.state.has(txId)) return Promise.resolve(true)
return new Promise((resolve) => {
const unsubscribe = seenTxids.subscribe(() => {
if (seenTxids.state.has(txId)) {
unsubscribe()
resolve(true)
}
})
})
}
전략 3: ID 기반 추적(Trailbase)
Trailbase는 특정 레코드 ID가 동기화된 시점을 추적합니다:
// Track synced IDs with timestamps
const seenIds = new Store(new Map<string, number>())
// In sync, mark IDs as seen
write({ type: 'insert', value: item })
seenIds.setState(prev => new Map(prev).set(item.id, Date.now()))
// Wait for specific IDs after mutations
const wrappedOnInsert = async (params) => {
const ids = await config.recordApi.createBulk(items)
// Wait for all IDs to be synced back
await awaitIds(ids)
}
const awaitIds = (ids: string[]): Promise<void> => {
const allSynced = ids.every(id => seenIds.state.has(id))
if (allSynced) return Promise.resolve()
return new Promise((resolve) => {
const unsubscribe = seenIds.subscribe((state) => {
if (ids.every(id => state.has(id))) {
unsubscribe()
resolve()
}
})
})
}
전략 4: 버전/타임스탬프 추적
데이터가 최신인지 감지하려면 버전 번호 또는 타임스탬프를 추적합니다:
// Track latest sync timestamp
let lastSyncTime = 0
// In mutations, record when the operation was sent
const wrappedOnUpdate = async (params) => {
const mutationTime = Date.now()
await config.onUpdate(params)
// Wait for sync to catch up
await waitForSync(mutationTime)
}
const waitForSync = (afterTime: number): Promise<void> => {
if (lastSyncTime > afterTime) return Promise.resolve()
return new Promise((resolve) => {
const check = setInterval(() => {
if (lastSyncTime > afterTime) {
clearInterval(check)
resolve()
}
}, 100)
})
}
전략 5: 전체 다시 가져오기(Query Collection)
Query Collection은 뮤테이션 후 모든 데이터를 간단히 다시 가져옵니다:
const wrappedOnInsert = async (params) => {
// Perform the mutation
await config.onInsert(params)
// Refetch the entire collection
await refetch()
// The refetch will trigger sync with fresh data,
// automatically dropping optimistic state
}
전략 선택
- 기본 제공 메서드: 프로바이더가 동기화 완료 API를 제공할 때 가장 적합합니다
- 트랜잭션 ID: 백엔드가 신뢰할 수 있는 트랜잭션 추적을 제공할 때 가장 적합합니다
- ID 기반: 각 뮤테이션이 영향을 받은 ID를 반환하는 시스템에 적합합니다
- 전체 다시 가져오기: 가장 간단하지만 효율성이 가장 낮으며, 작은 데이터 세트에 적합합니다
- 버전/타임스탬프: 동기화에 신뢰할 수 있는 순서 정보가 포함될 때 작동합니다
구현 팁
- 낙관적 상태가 올바르게 관리되도록 뮤테이션 핸들러에서 항상 동기화를 기다립니다
- 시간 초과를 처리합니다 - 동기화 확인을 무한정 기다리지 않습니다
- 추적 데이터를 정리합니다 - 메모리 누수를 방지하기 위해 오래된 txid/ID를 제거합니다
- 유틸리티를 제공합니다 - 고급 사용 사례를 위해
awaitTxId또는awaitSync와 같은 함수를 내보냅니다
모범 사례
- 초기 동기화 상태를 보고합니다 - 사용 가능한 스냅샷 이후
markReady()을 호출하거나, 초기 동기화가 실패하면markError(error)을 호출합니다 - 명시적으로 복구합니다 - 오류 후 이후 동기화에서 사용 가능한 스냅샷이 생성된 경우에만
markReady()를 호출합니다 - 리소스를 정리합니다 - 메모리 누수를 방지하기 위해 동기화에서 정리 함수를 반환합니다
- 작업을 배치합니다 - 더 나은 성능을 위해 begin/commit을 사용하여 여러 변경 사항을 배치합니다
- 경쟁 조건 - 초기 가져오기 전에 리스너를 시작하고 이벤트를 버퍼링합니다
- 타입 안전성 - 전체 과정에서 타입 안전성을 유지하도록 TypeScript 제네릭을 사용합니다
- 유틸리티를 제공합니다 - 고급 사용 사례를 위해 동기화 엔진별 유틸리티를 내보냅니다
컬렉션 테스트
다음 항목으로 컬렉션 옵션 생성기를 테스트합니다:
- 단위 테스트 - 동기화 로직과 데이터 변환을 테스트합니다
- 통합 테스트 - 실제 동기화 엔진으로 테스트합니다
- 오류 시나리오 - 연결 실패와 잘못된 데이터를 테스트합니다
- 성능 - 대규모 데이터 세트와 빈번한 업데이트를 테스트합니다
결론
컬렉션 옵션 생성기를 만들면 모든 동기화 엔진을 TanStack DB의 강력한 동기화 우선 아키텍처와 통합할 수 있습니다. 여기에서 보여준 패턴을 따르면 뛰어난 개발자 경험을 제공하는 견고하고 타입 안전한 통합을 구현할 수 있습니다.