diff --git a/SERIALIZED_TRANSACTION_PLAN.md b/SERIALIZED_TRANSACTION_PLAN.md deleted file mode 100644 index 1736c2afd..000000000 --- a/SERIALIZED_TRANSACTION_PLAN.md +++ /dev/null @@ -1,365 +0,0 @@ -# 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/feedback-1.md b/feedback-1.md deleted file mode 100644 index 92cbdebc7..000000000 --- a/feedback-1.md +++ /dev/null @@ -1,7 +0,0 @@ -- High – The proposed core API drops the `Transaction` surface returned by `createTransaction`; `mutate` is shown as `void` with only a `cleanup()` companion (`SERIALIZED_TRANSACTION_PLAN.md:95`). Without propagating the `Transaction` object (or at least its `isPersisted` promise/error handling), callers lose the only way to await persistence or detect failures, which is central to today’s transaction workflow. Please plan to surface the existing `Transaction` or equivalent signals instead of returning bare callbacks. - -- High – The React hook sketch keeps a stale strategy instance: it creates `strategyRef` but never reads it, and `useMemo` ignores `config.strategy` in its dependency array (`SERIALIZED_TRANSACTION_PLAN.md:234`). Any consumer that swaps strategies (or mutates strategy options) would continue using the first instance and never trigger the tear-down path. The plan should either require stable strategy instances or include strategy in the memo/effect dependencies and actually dispose/recreate when it changes. - -- Medium – The Angular deliverable is listed as `useSerializedTransaction.ts`, but the Angular package currently follows the `inject…` pattern (`packages/angular-db/src/index.ts` exports `injectLiveQuery`). Shipping a `use`-prefixed API would stick out and miss Angular’s DI guards; please align the plan with the existing injection-based naming/structure (`SERIALIZED_TRANSACTION_PLAN.md:84`). - -- Medium – The strategy example imports `Debouncer` via the `@tanstack/pacer/debouncer` subpath (`SERIALIZED_TRANSACTION_PLAN.md:266`), but Pacer’s public API is bundled behind its main entry points. Depending on subpath files risks breaking when the package layout shifts. I’d recommend validating the actual export surface (likely `import { createDebouncer } from '@tanstack/pacer'`) before committing to this shape. diff --git a/feedback-2.md b/feedback-2.md deleted file mode 100644 index e9e90f2f2..000000000 --- a/feedback-2.md +++ /dev/null @@ -1 +0,0 @@ -- High – The Solid deliverable name still conflicts with the core export. `packages/solid-db/src/index.ts` re-exports `createSerializedTransaction` from `@tanstack/db`, so shipping a Solid wrapper with the same name (`createSerializedTransaction(config)` in the plan) will cause the barrel to re-export the identifier twice and TypeScript will emit TS2308. Please rename the Solid-specific helper or stop re-exporting the core helper from the Solid package to avoid the collision. diff --git a/feedback-3.md b/feedback-3.md deleted file mode 100644 index 3c5729294..000000000 --- a/feedback-3.md +++ /dev/null @@ -1,5 +0,0 @@ -- High – Trailing strategies leave earlier transactions permanently pending. Each `mutate` call in `createSerializedTransaction` creates a fresh transaction and immediately returns it (`packages/db/src/serialized-transaction.ts:80-103`), but the debouncer/throttler only runs the _latest_ scheduled callback (`packages/db/src/strategies/debounceStrategy.ts:31-45`, `packages/db/src/strategies/throttleStrategy.ts:45-58`). When a later call supersedes an earlier one, the earlier transaction never commits or rolls back, so `tx.isPersisted.promise` never resolves and the transaction stays in the global pending list. We need to either reuse a single transaction per manager or explicitly cancel/rollback superseded transactions whenever a strategy drops them. - -- High – `cleanup()` can leave optimistic mutations stuck in a pending transaction. If the consumer unmounts while a trailing debounce/throttle call is waiting, `cleanup()` only cancels the strategy (`packages/db/src/serialized-transaction.ts:109-111`). The transaction we already created remains `pending`, its optimistic changes stay applied, and `isPersisted.promise` never settles. `cleanup()` should flush or rollback any in-flight transaction before returning. - -- High – The queue strategy does not serialize commits. `queueStrategy` wires `Queuer`’s synchronous `onGetNextItem` callback directly to `transaction.commit()` (`packages/db/src/strategies/queueStrategy.ts:48-65`), but `Queuer` keeps pulling items without waiting for that callback to finish. As a result, every queued transaction starts committing immediately and we lose sequencing guarantees. Switching to `AsyncQueuer` (or awaiting the `commit()` promise before processing the next item) should fix this. diff --git a/feedback-4.md b/feedback-4.md deleted file mode 100644 index fc6dfb1b5..000000000 --- a/feedback-4.md +++ /dev/null @@ -1,5 +0,0 @@ -- High – `queueStrategy` now runs every transaction’s `commit()` a second time. `createSerializedMutations` already calls `transaction.commit()` inside the scheduled callback (`packages/db/src/serialized-mutations.ts:101-127`), but the queue worker awaits `transaction.commit()` again (`packages/db/src/strategies/queueStrategy.ts:61-66`). The second call happens while the transaction state is `persisting`, so the queue immediately hits `TransactionNotPendingCommitError` and aborts the item. The queue should wait on `transaction.isPersisted.promise` (or let the strategy callback return the commit promise) instead of invoking `commit()` itself. - -- High – `cleanup()` can throw and/or corrupt completed transactions. If the active commit finishes right before cleanup runs, the transaction is already in the `completed` state when we call `executingTransaction.rollback()` (`packages/db/src/serialized-mutations.ts:149-152`), so `TransactionAlreadyCompletedRollbackError` bubbles out of cleanup. Even when the commit is still in flight, forcing a rollback races with the resolver `then` handlers. Please guard the rollback with a state check (or await `isPersisted.promise`) so teardown stays safe. - -- High – Strategies that drop callbacks leave returned transactions pending forever. We only remove/rollback entries inside the strategy callback (`packages/db/src/serialized-mutations.ts:101-113`), but Pacer’s debouncer/throttler simply skip calling `fn` when `trailing` is disabled (see `packages/db/src/strategies/debounceStrategy.ts:31-40`). In that configuration every suppressed `mutate` returns a transaction whose `isPersisted.promise` never settles unless another call eventually fires or cleanup runs. We need to settle those transactions immediately when the strategy decides not to run them (e.g. cancel/rollback on reschedule, or reuse a single transaction per manager).