diff --git a/.changeset/paced-mutations.md b/.changeset/paced-mutations.md new file mode 100644 index 000000000..6329c2963 --- /dev/null +++ b/.changeset/paced-mutations.md @@ -0,0 +1,49 @@ +--- +"@tanstack/db": patch +"@tanstack/react-db": patch +--- + +Add paced mutations with pluggable timing strategies + +Introduces a new paced mutations system that enables optimistic mutations with pluggable timing strategies. This provides fine-grained control over when and how mutations are persisted to the backend. Powered by [TanStack Pacer](https://github.com/TanStack/pacer). + +**Key Design:** + +- **Debounce/Throttle**: Only one pending transaction (collecting mutations) and one persisting transaction (writing to backend) at a time. Multiple rapid mutations automatically merge together. +- **Queue**: Each mutation creates a separate transaction, guaranteed to run in the order they're made (FIFO by default, configurable to LIFO). + +**Core Features:** + +- **Pluggable Strategy System**: Choose from debounce, queue, or throttle strategies to control mutation timing +- **Auto-merging Mutations**: Multiple rapid mutations on the same item automatically merge for efficiency (debounce/throttle only) +- **Transaction Management**: Full transaction lifecycle tracking (pending → persisting → completed/failed) +- **React Hook**: `usePacedMutations` for easy integration in React applications + +**Available Strategies:** + +- `debounceStrategy`: Wait for inactivity before persisting. Only final state is saved. (ideal for auto-save, search-as-you-type) +- `queueStrategy`: Each mutation becomes a separate transaction, processed sequentially in order (defaults to FIFO, configurable to LIFO). All mutations are guaranteed to persist. (ideal for sequential workflows, rate-limited APIs) +- `throttleStrategy`: Ensure minimum spacing between executions. Mutations between executions are merged. (ideal for analytics, progress updates) + +**Example Usage:** + +```ts +import { usePacedMutations, debounceStrategy } from "@tanstack/react-db" + +const mutate = usePacedMutations({ + mutationFn: async ({ transaction }) => { + await api.save(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 500 }), +}) + +// Trigger a mutation +const tx = mutate(() => { + collection.update(id, (draft) => { + draft.value = newValue + }) +}) + +// Optionally await persistence +await tx.isPersisted.promise +``` diff --git a/SERIALIZED_TRANSACTION_PLAN.md b/SERIALIZED_TRANSACTION_PLAN.md new file mode 100644 index 000000000..1736c2afd --- /dev/null +++ b/SERIALIZED_TRANSACTION_PLAN.md @@ -0,0 +1,365 @@ +# Implementation Plan for `useSerializedTransaction` with TanStack Pacer + +Based on [GitHub issue #35](https://github.com/TanStack/db/issues/35), using @tanstack/pacer for strategy implementation across all 5 framework integrations. + +## Overview + +Create a framework-agnostic core in `@tanstack/db` that manages optimistic transactions with pluggable queuing strategies powered by TanStack Pacer. Each framework package wraps the core with framework-specific reactive primitives. + +## Architecture Pattern + +The core transaction logic stays in one place (`@tanstack/db`) while each framework provides its own wrapper using framework-specific reactive primitives. + +```typescript +// Core in @tanstack/db (framework-agnostic) +createSerializedTransaction(config) // Returns { mutate, cleanup } + +// React wrapper +useSerializedTransaction(config) // Uses React hooks, returns mutate function + +// Solid wrapper +useSerializedTransaction(config) // Uses Solid signals, matches useLiveQuery pattern + +// Svelte/Vue wrappers +useSerializedTransaction(config) // Framework-specific implementations + +// Angular wrapper +injectSerializedTransaction(config) // Uses Angular DI, follows injectLiveQuery pattern +``` + +## Available Strategies (Based on Pacer Utilities) + +### 1. **debounceStrategy({ wait, leading?, trailing? })** + +- Uses Pacer's `Debouncer` class +- Waits for pause in activity before committing +- **Best for:** Search inputs, auto-save fields + +### 2. **queueStrategy({ wait?, maxSize?, addItemsTo?, getItemsFrom? })** + +- Uses Pacer's `Queuer` class +- Processes all transactions in order (FIFO/LIFO) +- FIFO: `{ addItemsTo: 'back', getItemsFrom: 'front' }` +- LIFO: `{ addItemsTo: 'back', getItemsFrom: 'back' }` +- **Best for:** Sequential operations that must all complete + +### 3. **throttleStrategy({ wait, leading?, trailing? })** + +- Uses Pacer's `Throttler` class +- Evenly spaces transaction executions over time +- **Best for:** Sliders, scroll handlers, progress bars + +### 4. **batchStrategy({ maxSize?, wait?, getShouldExecute? })** + +- Uses Pacer's `Batcher` class +- Groups multiple mutations into batches +- Triggers on size or time threshold +- **Best for:** Bulk operations, reducing network calls + +## File Structure + +``` +packages/db/src/ + ├── serialized-transaction.ts # Core framework-agnostic logic + └── strategies/ + ├── index.ts # Export all strategies + ├── debounceStrategy.ts # Wraps Pacer Debouncer + ├── queueStrategy.ts # Wraps Pacer Queuer + ├── throttleStrategy.ts # Wraps Pacer Throttler + ├── batchStrategy.ts # Wraps Pacer Batcher + └── types.ts # Strategy type definitions + +packages/db/package.json # Add @tanstack/pacer dependency + +packages/react-db/src/ + └── useSerializedTransaction.ts # React hook wrapper + +packages/solid-db/src/ + └── useSerializedTransaction.ts # Solid wrapper (matches useLiveQuery pattern) + +packages/svelte-db/src/ + └── useSerializedTransaction.svelte.ts # Svelte wrapper + +packages/vue-db/src/ + └── useSerializedTransaction.ts # Vue wrapper + +packages/angular-db/src/ + └── injectSerializedTransaction.ts # Angular wrapper (DI pattern) + +packages/*/tests/ + └── serialized-transaction.test.ts # Tests per package +``` + +## Core API Design + +```typescript +// Framework-agnostic core (packages/db) +import { debounceStrategy } from '@tanstack/db' + +const { mutate, cleanup } = createSerializedTransaction({ + mutationFn: async ({ transaction }) => { + await api.save(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 500 }), + metadata?: Record, +}) + +// mutate() executes mutations according to strategy and returns Transaction +const transaction = mutate(() => { + collection.update(id, draft => { draft.value = newValue }) +}) + +// Await persistence and handle errors +try { + await transaction.isPersisted.promise + console.log('Transaction committed successfully') +} catch (error) { + console.error('Transaction failed:', error) +} + +// cleanup() when done (frameworks handle this automatically) +cleanup() +``` + +## React Hook Wrapper + +```typescript +// packages/react-db +import { debounceStrategy } from "@tanstack/react-db" + +const mutate = useSerializedTransaction({ + mutationFn: async ({ transaction }) => { + await api.save(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 1000 }), +}) + +// Usage in component +const handleChange = async (value) => { + const tx = mutate(() => { + collection.update(id, (draft) => { + draft.value = value + }) + }) + + // Optional: await persistence or handle errors + try { + await tx.isPersisted.promise + } catch (error) { + console.error("Update failed:", error) + } +} +``` + +## Example: Slider with Different Strategies + +```typescript +// Debounce - wait for user to stop moving slider +const mutate = useSerializedTransaction({ + mutationFn: async ({ transaction }) => { + await api.updateVolume(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 500 }), +}) + +// Throttle - update every 200ms while sliding +const mutate = useSerializedTransaction({ + mutationFn: async ({ transaction }) => { + await api.updateVolume(transaction.mutations) + }, + strategy: throttleStrategy({ wait: 200 }), +}) + +// Debounce with leading/trailing - save first + final value only +const mutate = useSerializedTransaction({ + mutationFn: async ({ transaction }) => { + await api.updateVolume(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 0, leading: true, trailing: true }), +}) + +// Queue - save every change in order (FIFO) +const mutate = useSerializedTransaction({ + mutationFn: async ({ transaction }) => { + await api.updateVolume(transaction.mutations) + }, + strategy: queueStrategy({ + wait: 200, + addItemsTo: "back", + getItemsFrom: "front", + }), +}) +``` + +## Implementation Steps + +### Phase 1: Core Package (@tanstack/db) + +1. Add `@tanstack/pacer` dependency to packages/db/package.json +2. Create strategy type definitions in strategies/types.ts +3. Implement strategy factories: + - `debounceStrategy.ts` - wraps Pacer Debouncer + - `queueStrategy.ts` - wraps Pacer Queuer + - `throttleStrategy.ts` - wraps Pacer Throttler + - `batchStrategy.ts` - wraps Pacer Batcher +4. Create core `createSerializedTransaction()` function +5. Export strategies + core function from packages/db/src/index.ts + +### Phase 2: Framework Wrappers + +6. **React** - Create `useSerializedTransaction` using useRef/useEffect/useCallback +7. **Solid** - Create `useSerializedTransaction` using createSignal/onCleanup (matches `useLiveQuery` pattern) +8. **Svelte** - Create `useSerializedTransaction` using Svelte stores +9. **Vue** - Create `useSerializedTransaction` using ref/onUnmounted +10. **Angular** - Create `injectSerializedTransaction` using inject/DestroyRef (matches `injectLiveQuery` pattern) + +### Phase 3: Testing & Documentation + +11. Write tests for core logic in packages/db +12. Write tests for each framework wrapper +13. Update README with examples +14. Add TypeScript examples to docs + +## Strategy Type System + +```typescript +export type Strategy = + | DebounceStrategy + | QueueStrategy + | ThrottleStrategy + | BatchStrategy + +interface BaseStrategy { + _type: TName // Discriminator for type narrowing + execute: (fn: () => void) => void | Promise + cleanup: () => void +} + +export function debounceStrategy(opts: { + wait: number + leading?: boolean + trailing?: boolean +}): DebounceStrategy + +export function queueStrategy(opts?: { + wait?: number + maxSize?: number + addItemsTo?: "front" | "back" + getItemsFrom?: "front" | "back" +}): QueueStrategy + +export function throttleStrategy(opts: { + wait: number + leading?: boolean + trailing?: boolean +}): ThrottleStrategy + +export function batchStrategy(opts?: { + maxSize?: number + wait?: number + getShouldExecute?: (items: any[]) => boolean +}): BatchStrategy +``` + +## Technical Implementation Details + +### Core createSerializedTransaction + +The core function will: + +1. Accept a strategy and mutationFn +2. Create a wrapper around `createTransaction` from existing code +3. Use the strategy's `execute()` method to control when transactions are committed +4. Return `{ mutate, cleanup }` where: + - `mutate(callback): Transaction` - executes mutations according to strategy and returns the Transaction object + - `cleanup()` - cleans up strategy resources + +**Important:** The `mutate()` function returns a `Transaction` object so callers can: + +- Await `transaction.isPersisted.promise` to know when persistence completes +- Handle errors via try/catch or `.catch()` +- Access transaction state and metadata + +### Strategy Factories + +Each strategy factory returns an object with: + +- `execute(fn)` - wraps the function with Pacer's utility +- `cleanup()` - cleans up the Pacer instance + +Example for debounceStrategy: + +```typescript +// NOTE: Import path needs validation - Pacer may export from main entry point +// Likely: import { Debouncer } from '@tanstack/pacer' or similar +import { Debouncer } from "@tanstack/pacer" // TODO: Validate actual export path + +export function debounceStrategy(opts: { + wait: number + leading?: boolean + trailing?: boolean +}) { + const debouncer = new Debouncer(opts) + + return { + _type: "debounce" as const, + execute: (fn: () => void) => { + debouncer.execute(fn) + }, + cleanup: () => { + debouncer.cancel() + }, + } +} +``` + +### React Hook Implementation + +```typescript +export function useSerializedTransaction(config) { + // Include strategy in dependencies to handle strategy changes + const { mutate, cleanup } = useMemo(() => { + return createSerializedTransaction(config) + }, [config.mutationFn, config.metadata, config.strategy]) + + // Cleanup on unmount or when dependencies change + useEffect(() => { + return () => cleanup() + }, [cleanup]) + + // Use useCallback to provide stable reference + const stableMutate = useCallback(mutate, [mutate]) + + return stableMutate +} +``` + +**Key fixes:** + +- Include `config.strategy` in `useMemo` dependencies to handle strategy changes +- Properly cleanup when strategy changes (via useEffect cleanup) +- Return stable callback reference via `useCallback` + +## Benefits + +- ✅ Leverages battle-tested TanStack Pacer utilities +- ✅ Reduces backend write contention +- ✅ Framework-agnostic core promotes consistency +- ✅ Type-safe, composable API +- ✅ Aligns with TanStack ecosystem patterns +- ✅ Supports all 5 framework integrations +- ✅ Simple, declarative API for users +- ✅ Easy to add custom strategies + +## Open Questions + +1. Should we support custom strategies? (i.e., users passing their own strategy objects) +2. Do we need lifecycle callbacks like `onSuccess`, `onError` for each mutate call? +3. Should batching strategy automatically merge mutations or keep them separate? +4. Rate limiting strategy - useful or skip for now? + +## Notes + +- ❌ Dropped merge strategy for now (more complex to design, less clear use case) +- The pattern follows existing TanStack patterns where core is framework-agnostic +- Similar to how `useLiveQuery` wraps core query logic per framework diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 24e8aba92..f81fea8e6 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -100,6 +100,7 @@ The benefits: - [Operation Handlers](#operation-handlers) - [Creating Custom Actions](#creating-custom-actions) - [Manual Transactions](#manual-transactions) +- [Paced Mutations](#paced-mutations) - [Mutation Merging](#mutation-merging) - [Controlling Optimistic Behavior](#controlling-optimistic-behavior) - [Transaction States](#transaction-states) @@ -892,6 +893,238 @@ tx.isPersisted.promise.then(() => { console.log(tx.state) // 'pending', 'persisting', 'completed', or 'failed' ``` +## Paced Mutations + +Paced mutations provide fine-grained control over **when and how** mutations are persisted to your backend. Instead of persisting every mutation immediately, you can use timing strategies to batch, delay, or queue mutations based on your application's needs. + +Powered by [TanStack Pacer](https://github.com/TanStack/pacer), paced mutations are ideal for scenarios like: +- **Auto-save forms** that wait for the user to stop typing +- **Slider controls** that need smooth updates without overwhelming the backend +- **Sequential workflows** where order matters and every mutation must persist + +### Key Design + +The fundamental difference between strategies is how they handle transactions: + +**Debounce/Throttle**: Only one pending transaction (collecting mutations) and one persisting transaction (writing to backend) at a time. Multiple rapid mutations automatically merge together into a single transaction. + +**Queue**: Each mutation creates a separate transaction, guaranteed to run in the order they're made (FIFO by default, configurable to LIFO). All mutations are guaranteed to persist. + +### Available Strategies + +| Strategy | Behavior | Best For | +|----------|----------|----------| +| **`debounceStrategy`** | Wait for inactivity before persisting. Only final state is saved. | Auto-save forms, search-as-you-type | +| **`throttleStrategy`** | Ensure minimum spacing between executions. Mutations between executions are merged. | Sliders, progress updates, analytics | +| **`queueStrategy`** | Each mutation becomes a separate transaction, processed sequentially in order (FIFO by default, configurable to LIFO). All mutations guaranteed to persist. | Sequential workflows, file uploads, rate-limited APIs | + +### Debounce Strategy + +The debounce strategy waits for a period of inactivity before persisting. This is perfect for auto-save scenarios where you want to wait until the user stops typing before saving their work. + +```tsx +import { usePacedMutations, debounceStrategy } from "@tanstack/react-db" + +function AutoSaveForm() { + const mutate = usePacedMutations({ + mutationFn: async ({ transaction }) => { + // Persist the final merged state to the backend + await api.forms.save(transaction.mutations) + }, + // Wait 500ms after the last change before persisting + strategy: debounceStrategy({ wait: 500 }), + }) + + const handleChange = (field: string, value: string) => { + // Multiple rapid changes merge into a single transaction + mutate(() => { + formCollection.update(formId, (draft) => { + draft[field] = value + }) + }) + } + + return ( +
+ handleChange('title', e.target.value)} /> +