@tanstack/query-db-collection
Version:
TanStack Query collection for TanStack DB
339 lines (300 loc) • 9.58 kB
text/typescript
import {
DeleteOperationItemNotFoundError,
DuplicateKeyInBatchError,
SyncNotInitializedError,
UpdateOperationItemNotFoundError,
} from "./errors"
import type { QueryClient } from "@tanstack/query-core"
import type { ChangeMessage, Collection } from "@tanstack/db"
// Track active batch operations per context to prevent cross-collection contamination
const activeBatchContexts = new WeakMap<
SyncContext<any, any>,
{
operations: Array<SyncOperation<any, any, any>>
isActive: boolean
}
>()
// Types for sync operations
export type SyncOperation<
TRow extends object,
TKey extends string | number = string | number,
TInsertInput extends object = TRow,
> =
| { type: `insert`; data: TInsertInput | Array<TInsertInput> }
| { type: `update`; data: Partial<TRow> | Array<Partial<TRow>> }
| { type: `delete`; key: TKey | Array<TKey> }
| { type: `upsert`; data: Partial<TRow> | Array<Partial<TRow>> }
export interface SyncContext<
TRow extends object,
TKey extends string | number = string | number,
> {
collection: Collection<TRow>
queryClient: QueryClient
queryKey: Array<unknown>
getKey: (item: TRow) => TKey
begin: () => void
write: (message: Omit<ChangeMessage<TRow>, `key`>) => void
commit: () => void
}
interface NormalizedOperation<
TRow extends object,
TKey extends string | number = string | number,
> {
type: `insert` | `update` | `delete` | `upsert`
key: TKey
data?: TRow | Partial<TRow>
}
// Normalize operations into a consistent format
function normalizeOperations<
TRow extends object,
TKey extends string | number = string | number,
TInsertInput extends object = TRow,
>(
ops:
| SyncOperation<TRow, TKey, TInsertInput>
| Array<SyncOperation<TRow, TKey, TInsertInput>>,
ctx: SyncContext<TRow, TKey>
): Array<NormalizedOperation<TRow, TKey>> {
const operations = Array.isArray(ops) ? ops : [ops]
const normalized: Array<NormalizedOperation<TRow, TKey>> = []
for (const op of operations) {
if (op.type === `delete`) {
const keys = Array.isArray(op.key) ? op.key : [op.key]
for (const key of keys) {
normalized.push({ type: `delete`, key })
}
} else {
const items = Array.isArray(op.data) ? op.data : [op.data]
for (const item of items) {
let key: TKey
if (op.type === `update`) {
// For updates, we need to get the key from the partial data
key = ctx.getKey(item as TRow)
} else {
// For insert/upsert, validate and resolve the full item first
const resolved = ctx.collection.validateData(
item,
op.type === `upsert` ? `insert` : op.type
)
key = ctx.getKey(resolved)
}
normalized.push({ type: op.type, key, data: item })
}
}
}
return normalized
}
// Validate operations before executing
function validateOperations<
TRow extends object,
TKey extends string | number = string | number,
>(
operations: Array<NormalizedOperation<TRow, TKey>>,
ctx: SyncContext<TRow, TKey>
): void {
const seenKeys = new Set<TKey>()
for (const op of operations) {
// Check for duplicate keys within the batch
if (seenKeys.has(op.key)) {
throw new DuplicateKeyInBatchError(op.key)
}
seenKeys.add(op.key)
// Validate operation-specific requirements
if (op.type === `update`) {
if (!ctx.collection.has(op.key)) {
throw new UpdateOperationItemNotFoundError(op.key)
}
} else if (op.type === `delete`) {
if (!ctx.collection.has(op.key)) {
throw new DeleteOperationItemNotFoundError(op.key)
}
}
}
}
// Execute a batch of operations
export function performWriteOperations<
TRow extends object,
TKey extends string | number = string | number,
TInsertInput extends object = TRow,
>(
operations:
| SyncOperation<TRow, TKey, TInsertInput>
| Array<SyncOperation<TRow, TKey, TInsertInput>>,
ctx: SyncContext<TRow, TKey>
): void {
const normalized = normalizeOperations(operations, ctx)
validateOperations(normalized, ctx)
ctx.begin()
for (const op of normalized) {
switch (op.type) {
case `insert`: {
const resolved = ctx.collection.validateData(op.data, `insert`)
ctx.write({
type: `insert`,
value: resolved,
})
break
}
case `update`: {
const currentItem = ctx.collection.get(op.key)!
const updatedItem = {
...currentItem,
...op.data,
}
const resolved = ctx.collection.validateData(
updatedItem,
`update`,
op.key
)
ctx.write({
type: `update`,
value: resolved,
})
break
}
case `delete`: {
const currentItem = ctx.collection.get(op.key)!
ctx.write({
type: `delete`,
value: currentItem,
})
break
}
case `upsert`: {
const resolved = ctx.collection.validateData(
op.data,
ctx.collection.has(op.key) ? `update` : `insert`,
op.key
)
if (ctx.collection.has(op.key)) {
ctx.write({
type: `update`,
value: resolved,
})
} else {
ctx.write({
type: `insert`,
value: resolved,
})
}
break
}
}
}
ctx.commit()
// Update query cache after successful commit
const updatedData = ctx.collection.toArray
ctx.queryClient.setQueryData(ctx.queryKey, updatedData)
}
// Factory function to create write utils
export function createWriteUtils<
TRow extends object,
TKey extends string | number = string | number,
TInsertInput extends object = TRow,
>(getContext: () => SyncContext<TRow, TKey> | null) {
function ensureContext(): SyncContext<TRow, TKey> {
const context = getContext()
if (!context) {
throw new SyncNotInitializedError()
}
return context
}
return {
writeInsert(data: TInsertInput | Array<TInsertInput>) {
const operation: SyncOperation<TRow, TKey, TInsertInput> = {
type: `insert`,
data,
}
const ctx = ensureContext()
const batchContext = activeBatchContexts.get(ctx)
// If we're in a batch, just add to the batch operations
if (batchContext?.isActive) {
batchContext.operations.push(operation)
return
}
// Otherwise, perform the operation immediately
performWriteOperations(operation, ctx)
},
writeUpdate(data: Partial<TRow> | Array<Partial<TRow>>) {
const operation: SyncOperation<TRow, TKey, TInsertInput> = {
type: `update`,
data,
}
const ctx = ensureContext()
const batchContext = activeBatchContexts.get(ctx)
if (batchContext?.isActive) {
batchContext.operations.push(operation)
return
}
performWriteOperations(operation, ctx)
},
writeDelete(key: TKey | Array<TKey>) {
const operation: SyncOperation<TRow, TKey, TInsertInput> = {
type: `delete`,
key,
}
const ctx = ensureContext()
const batchContext = activeBatchContexts.get(ctx)
if (batchContext?.isActive) {
batchContext.operations.push(operation)
return
}
performWriteOperations(operation, ctx)
},
writeUpsert(data: Partial<TRow> | Array<Partial<TRow>>) {
const operation: SyncOperation<TRow, TKey, TInsertInput> = {
type: `upsert`,
data,
}
const ctx = ensureContext()
const batchContext = activeBatchContexts.get(ctx)
if (batchContext?.isActive) {
batchContext.operations.push(operation)
return
}
performWriteOperations(operation, ctx)
},
writeBatch(callback: () => void) {
const ctx = ensureContext()
// Check if we're already in a batch (nested batch)
const existingBatch = activeBatchContexts.get(ctx)
if (existingBatch?.isActive) {
throw new Error(
`Cannot nest writeBatch calls. Complete the current batch before starting a new one.`
)
}
// Set up the batch context for this specific collection
const batchContext = {
operations: [] as Array<SyncOperation<TRow, TKey, TInsertInput>>,
isActive: true,
}
activeBatchContexts.set(ctx, batchContext)
try {
// Execute the callback - any write operations will be collected
const result = callback()
// Check if callback returns a promise (async function)
if (
// @ts-expect-error - Runtime check for async callback, callback is typed as () => void but user might pass async
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
result &&
typeof result === `object` &&
`then` in result &&
// @ts-expect-error - Runtime check for async callback, callback is typed as () => void but user might pass async
typeof result.then === `function`
) {
throw new Error(
`writeBatch does not support async callbacks. The callback must be synchronous.`
)
}
// Perform all collected operations
if (batchContext.operations.length > 0) {
performWriteOperations(batchContext.operations, ctx)
}
} finally {
// Always clear the batch context
batchContext.isActive = false
activeBatchContexts.delete(ctx)
}
},
}
}