Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/confirm-write-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/offline-transactions': minor
---

Add an opt-in `OfflineConfig.confirmWrite` hook that holds optimistic state across the post-commit confirmation window **off** the serial drain path.

Previously the only way to keep a row painted until an async sync stream (e.g. ElectricSQL's `awaitTxId`) echoed the write back was to `await` that confirmation inside the `mutationFn` — which serializes the whole outbox and collapses drain throughput. `confirmWrite` runs after the write commits and its outbox entry is removed, while the library keeps the just-committed mutations' optimistic overlay painted (reusing the same hold primitive as `restoreOptimisticState`) and releases it when the hook settles. The serial chain still serializes the POSTs (preserving create-then-update ordering); only the confirmation moved off it.

The hook applies consistently to leader, non-leader, and storage-degraded online-only writes. It is never expected to roll back — the write is already durably committed, so a rejection just drops the overlay early (a possible brief flicker), never data loss. A `maxConfirmationHolds` cap (default 1000) bounds concurrent holds to avoid O(n²) optimistic recompute on a large, fast drain, and `getActiveConfirmationHoldCount()` exposes the live count for diagnostics. Setting the cap to 0 still runs the hook without retaining an overlay.

As part of this, the `mutationFn`'s return value is now threaded through to the completion promise and to `confirmWrite` (e.g. a server-assigned txid); previously it was awaited and discarded.
38 changes: 38 additions & 0 deletions packages/offline-transactions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,41 @@ Transactions are processed one at a time in the order they were created:
- **Dependency safety**: Avoids conflicts between transactions that may reference each other
- **Predictable behavior**: Transactions complete in the exact order they were created

### Post-commit Confirmation

Some sync engines do not echo a committed write back immediately. Waiting for
that echo inside a `mutationFn` keeps optimistic state visible, but also blocks
the serial outbox. Use `confirmWrite` to move that wait off the drain path:

```typescript
const electricById = new Map([[todoCollection.id, todoCollection]])

const offline = startOfflineExecutor({
collections: { todos: todoCollection },
mutationFns: {
syncTodos: async ({ transaction, idempotencyKey }) => {
// Return the server result needed by confirmWrite.
return api.saveBatch(transaction.mutations, { idempotencyKey })
},
},
confirmWrite: async ({ mutations, result }) => {
const { txid } = result as { txid: number }
const collectionIds = new Set(mutations.map((m) => m.collection.id))

await Promise.all(
[...collectionIds].map((id) =>
electricById.get(id)?.utils.awaitTxId(txid),
),
)
},
})
```

The hook runs for leader, non-leader, and online-only writes. While it is
pending, the affected optimistic rows remain visible; its completion never
blocks the next outbox write. Set `maxConfirmationHolds: 0` to run the hook
without retaining overlays.

## API Reference

### startOfflineExecutor(config)
Expand All @@ -129,6 +164,8 @@ interface OfflineConfig {
beforeRetry?: (transactions: OfflineTransaction[]) => OfflineTransaction[]
onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void
onLeadershipChange?: (isLeader: boolean) => void
confirmWrite?: (context: ConfirmWriteContext) => void | Promise<void>
maxConfirmationHolds?: number
onlineDetector?: OnlineDetector
}
```
Expand All @@ -145,6 +182,7 @@ interface OfflineConfig {
- `waitForTransactionCompletion(id)` - Wait for a specific transaction to complete
- `removeFromOutbox(id)` - Manually remove transaction from outbox
- `peekOutbox()` - View all pending transactions
- `getActiveConfirmationHoldCount()` - Count post-commit optimistic holds
- `dispose()` - Clean up resources

### Error Handling
Expand Down
31 changes: 30 additions & 1 deletion packages/offline-transactions/skills/offline/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ If the executor is not the leader tab, falls back to `createOptimisticAction` di
1. Mutation applied optimistically to collection (instant UI update)
2. Transaction serialized and persisted to storage (outbox)
3. Leader tab picks up transaction and executes `mutationFn`
4. On success: removed from outbox, optimistic state resolved
4. On success: removed from outbox; optional `confirmWrite` runs off the drain
while a temporary hold keeps optimistic state visible
5. On failure: retried with exponential backoff
6. On page reload: outbox replayed, optimistic state restored

Expand Down Expand Up @@ -165,6 +166,8 @@ interface OfflineConfig {
maxConcurrency?: number // Parallel execution limit
jitter?: boolean // Add jitter to retry delays
beforeRetry?: (txs) => txs // Transform/filter before retry
confirmWrite?: (context) => void | Promise<void> // Post-commit sync confirmation
maxConfirmationHolds?: number // Optimistic hold cap (default: 1000)
onUnknownMutationFn?: (name, tx) => void // Handle orphaned transactions
onLeadershipChange?: (isLeader) => void // Leadership state callback
onStorageFailure?: (diagnostic) => void // Storage probe failure callback
Expand All @@ -173,6 +176,32 @@ interface OfflineConfig {
}
```

### Post-commit sync confirmation

Use `confirmWrite` when a server write commits before an asynchronous sync
stream echoes it back. The hook receives the `mutationFn` result, runs for
leader and online-only paths, and does not block the serial outbox:

```ts
const executor = startOfflineExecutor({
// ...
mutationFns: {
syncTodos: ({ transaction, idempotencyKey }) =>
api.sync(transaction.mutations, { idempotencyKey }),
},
confirmWrite: async ({ mutations, result }) => {
const { txid } = result as { txid: number }
const collectionIds = new Set(mutations.map((m) => m.collection.id))
await Promise.all(
[...collectionIds].map((id) => collections[id].utils.awaitTxId(txid)),
)
},
})
```

The library holds the affected optimistic rows until the hook settles. A
rejection releases the hold but never rolls back the already-committed write.

### Custom storage adapter

```ts
Expand Down
125 changes: 81 additions & 44 deletions packages/offline-transactions/src/OfflineExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { LocalStorageAdapter } from './storage/LocalStorageAdapter'
import { OutboxManager } from './outbox/OutboxManager'
import { KeyScheduler } from './executor/KeyScheduler'
import { TransactionExecutor } from './executor/TransactionExecutor'
import { ConfirmationManager } from './executor/ConfirmationManager'

// Coordination
import { WebLocksLeader } from './coordination/WebLocksLeader'
Expand Down Expand Up @@ -48,6 +49,7 @@ export class OfflineExecutor {
private outbox: OutboxManager | null
private scheduler: KeyScheduler
private executor: TransactionExecutor | null
private confirmationManager: ConfirmationManager
private leaderElection: LeaderElection | null
private onlineDetector: OnlineDetector
private isLeaderState = false
Expand All @@ -74,11 +76,15 @@ export class OfflineExecutor {
> = new Map()

// Track restoration transactions for cleanup when offline transactions complete
private restorationTransactions: Map<string, Transaction> = new Map()
private restorationTransactions: Map<
string,
{ transaction: Transaction; release: () => void }
> = new Map()

constructor(config: OfflineConfig) {
this.config = config
this.scheduler = new KeyScheduler()
this.confirmationManager = new ConfirmationManager(config)
this.onlineDetector = config.onlineDetector ?? new WebOnlineDetector()

// Initialize as pending - will be set by async initialization
Expand Down Expand Up @@ -290,6 +296,7 @@ export class OfflineExecutor {
this.outbox,
this.config,
this,
this.confirmationManager,
)
this.leaderElection = this.createLeaderElection()

Expand Down Expand Up @@ -366,13 +373,25 @@ export class OfflineExecutor {
if (!this.isOfflineEnabled) {
// Non-leader: use createTransaction directly with the resolved mutation function
// We need to wrap it to add the idempotency key
const idempotencyKey = options.idempotencyKey ?? safeRandomUUID()
return createTransaction({
id: options.id,
autoCommit: options.autoCommit ?? true,
mutationFn: (params) =>
mutationFn({
mutationFn: async (params) => {
const result = await mutationFn({
...params,
idempotencyKey: options.idempotencyKey || safeRandomUUID(),
}),
idempotencyKey,
})
this.confirmationManager.schedule({
transactionId: params.transaction.id,
mutationFnName: options.mutationFnName,
idempotencyKey,
mutations: params.transaction.mutations,
result,
metadata: params.transaction.metadata,
})
return result
},
metadata: options.metadata,
})
}
Expand All @@ -398,13 +417,24 @@ export class OfflineExecutor {
// Check leadership when action is called, not when it's created
if (!this.isOfflineEnabled) {
// Non-leader: use createOptimisticAction directly
const idempotencyKey = safeRandomUUID()
const action = createOptimisticAction({
mutationFn: (vars, params) =>
mutationFn({
mutationFn: async (vars, params) => {
const result = await mutationFn({
...vars,
...params,
idempotencyKey: safeRandomUUID(),
}),
idempotencyKey,
})
this.confirmationManager.schedule({
transactionId: params.transaction.id,
mutationFnName: options.mutationFnName,
idempotencyKey,
mutations: params.transaction.mutations,
result,
metadata: params.transaction.metadata,
})
return result
},
onMutate: options.onMutate,
})
return action(variables)
Expand Down Expand Up @@ -498,56 +528,48 @@ export class OfflineExecutor {
this.pendingTransactionPromises.delete(transactionId)
}

// Clean up the restoration transaction and rollback optimistic state
this.cleanupRestorationTransaction(transactionId, true)
// Drop the synthetic restoration hold. The real transaction handles its
// own failure state; the hold must not cascade a rollback.
this.cleanupRestorationTransaction(transactionId)
}

// Method for TransactionExecutor to register restoration transactions
registerRestorationTransaction(
offlineTransactionId: string,
restorationTransaction: Transaction,
releaseRestorationTransaction: () => void,
): void {
this.restorationTransactions.set(
offlineTransactionId,
restorationTransaction,
)
// Replaying the same outbox id twice must not orphan the previous hold.
this.cleanupRestorationTransaction(offlineTransactionId)
this.restorationTransactions.set(offlineTransactionId, {
transaction: restorationTransaction,
release: releaseRestorationTransaction,
})
}

private cleanupRestorationTransaction(
transactionId: string,
shouldRollback = false,
): void {
const restorationTx = this.restorationTransactions.get(transactionId)
if (!restorationTx) {
private cleanupRestorationTransaction(transactionId: string): void {
const restoration = this.restorationTransactions.get(transactionId)
if (!restoration) {
return
}

this.restorationTransactions.delete(transactionId)

if (shouldRollback) {
restorationTx.rollback()
return
try {
// A restoration transaction is only an optimistic display hold. Releasing
// it is correct for both success and permanent failure and cannot cascade
// rollback into unrelated user transactions.
restoration.release()
} catch (error) {
console.warn(
`Failed to release restoration transaction ${restoration.transaction.id}:`,
error,
)
}
}

// Mark as completed so recomputeOptimisticState removes it from consideration.
// The actual data will come from the sync.
restorationTx.setState(`completed`)

// Remove from each collection's transaction map and recompute
const touchedCollections = new Set<string>()
for (const mutation of restorationTx.mutations) {
// Defensive check for corrupted deserialized data
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!mutation.collection) {
continue
}
const collectionId = mutation.collection.id
if (touchedCollections.has(collectionId)) {
continue
}
touchedCollections.add(collectionId)
mutation.collection._state.transactions.delete(restorationTx.id)
mutation.collection._state.recomputeOptimisticState(false)
private releaseRestorationTransactions(): void {
for (const transactionId of [...this.restorationTransactions.keys()]) {
this.cleanupRestorationTransaction(transactionId)
}
}

Expand All @@ -566,6 +588,8 @@ export class OfflineExecutor {
}

async clearOutbox(): Promise<void> {
this.confirmationManager.releaseAll()
this.releaseRestorationTransactions()
if (!this.outbox || !this.executor) {
return
}
Expand All @@ -587,6 +611,14 @@ export class OfflineExecutor {
return this.executor.getRunningCount()
}

/**
* Number of optimistic holds currently kept alive by `confirmWrite` (see
* `OfflineConfig.confirmWrite`). Returns 0 when the hook is unused.
*/
getActiveConfirmationHoldCount(): number {
return this.confirmationManager.getActiveHoldCount()
}

getOnlineDetector(): OnlineDetector {
return this.onlineDetector
}
Expand All @@ -596,6 +628,11 @@ export class OfflineExecutor {
}

dispose(): void {
// Drop any optimistic holds still waiting on confirmation so they don't
// outlive the executor.
this.confirmationManager.dispose()
this.releaseRestorationTransactions()

for (const collection of Object.values(this.config.collections)) {
collection.deferDataRefresh = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class OfflineTransaction {
persistTransaction: (tx: OfflineTransactionType) => Promise<void>,
executor: any,
) {
this.offlineId = safeRandomUUID()
this.offlineId = options.id ?? safeRandomUUID()
this.mutationFnName = options.mutationFnName
this.autoCommit = options.autoCommit ?? true
this.idempotencyKey = options.idempotencyKey ?? safeRandomUUID()
Expand Down
Loading