Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/clarify-write-status-apis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Add `tx.when('settled')` to await transaction completion and `$hasPendingWrites` to identify rows with local optimistic writes. Deprecate `isPersisted.promise` and `$synced` while keeping them available for existing code.
14 changes: 7 additions & 7 deletions docs/guides/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ try {
completed: false,
})

await tx.isPersisted.promise
await tx.when('settled')
} catch (error) {
// The optimistic update has been automatically rolled back
console.error("Failed to create todo:", error)
Expand Down Expand Up @@ -312,7 +312,7 @@ try {
draft.completed = true
})

await tx.isPersisted.promise
await tx.when('settled')
} catch (error) {
// Transaction has been rolled back
console.log(tx.state) // "failed"
Expand Down Expand Up @@ -344,14 +344,14 @@ try {

Explicit cancellation is different from a mutation failure. If you call
`tx.rollback()` while `mutationFn` is pending, the rollback settles
`tx.isPersisted.promise` as rejected. A later result or rejection from that
`tx.when('settled')` as rejected. A later result or rejection from that
mutation function is ignored: the outstanding `commit()` call resolves and
`tx.error` is not populated by that late rejection. Observe `isPersisted.promise`
`tx.error` is not populated by that late rejection. Observe `tx.when('settled')`
when you need the transaction's outcome, including explicit cancellation.

After the mutation function succeeds, a publication listener can still throw
while the completed transaction updates its collections. In that case
`commit()` rejects with the listener error, but `isPersisted.promise` resolves
`commit()` rejects with the listener error, but `tx.when('settled')` resolves
and the transaction remains completed. This is not a persistence failure.

## Collection Operation Errors
Expand Down Expand Up @@ -815,7 +815,7 @@ Thrown when calling `commit()` on a sync transaction that's already committed.
2. **Import specific error types** - Import only the error classes you need for better tree-shaking
3. **Always handle SchemaValidationError** - Provide clear feedback for validation failures
4. **Check collection status** - Use `isError`, `isLoading`, `isReady` flags in React components
5. **Handle transaction promises** - Always handle `isPersisted.promise` rejections
5. **Handle transaction promises** - Always handle `when('settled')` rejections

## Example: Complete Error Handling

Expand Down Expand Up @@ -875,7 +875,7 @@ const TodoApp = () => {
})

// Wait for persistence
await tx.isPersisted.promise
await tx.when('settled')
} catch (error) {
if (error instanceof SchemaValidationError) {
alert(`Validation error: ${error.issues[0]?.message}`)
Expand Down
3 changes: 2 additions & 1 deletion docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ The result types are automatically inferred from your query structure, providing

Live query results include computed, read-only virtual properties on every row:

- `$synced`: `true` when the row is confirmed by sync; `false` when it is still optimistic.
- `$hasPendingWrites`: `true` while a pending local optimistic mutation affects the row; otherwise `false`. It is always `false` for local-only collections. It does not indicate server acknowledgement.
- `$synced` (deprecated): the inverse of `$hasPendingWrites`. It will be removed in the 1.0 RC. Replace `row.$synced` with `!row.$hasPendingWrites`, and `eq(row.$synced, true)` with `eq(row.$hasPendingWrites, false)`.
- `$origin`: `"local"` if the last confirmed change came from this client, otherwise `"remote"`.
- `$key`: the row key for the result.
- `$collectionId`: the source collection ID.
Expand Down
32 changes: 18 additions & 14 deletions docs/guides/mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,14 @@ todoCollection.update(todo.id, (draft) => {

If the handler throws an error during persistence, the optimistic state is automatically rolled back.

`tx.isPersisted.promise` observes this transaction-settlement boundary. Despite
the property name, it does not by itself prove that a server uploaded,
confirmed, or returned the write. It proves those stronger guarantees only
when the handler waits for that backend observation before returning.
`tx.when('settled')` observes this transaction-settlement boundary. It resolves
with the transaction on success and rejects with the original error on failure
(or `undefined` for a rollback without an error). It does not by itself prove
that a server uploaded, confirmed, or returned the write. It proves those
stronger guarantees only when the handler waits for that backend observation
before returning. The old `tx.isPersisted.promise` remains available for the
pre-RC releases but is deprecated and will be removed in the 1.0 RC; replace
each use with `tx.when('settled')`.

### Concurrent Optimistic Transactions

Expand Down Expand Up @@ -1063,7 +1067,7 @@ const tx = createTransaction({
})

// Wait for the transaction handler to settle
tx.isPersisted.promise.then(() => {
tx.when('settled').then(() => {
console.log("Transaction completed!")
})

Expand Down Expand Up @@ -1248,9 +1252,9 @@ function FileUploader() {

**Error handling**:
- If a mutation fails, **it is not automatically retried** - the transaction transitions to "failed" state
- Failed mutations surface their error via `transaction.isPersisted.promise` (which will reject)
- Queue overflow rejects `transaction.isPersisted.promise` with `QueueCapacityExceededError`
- A call after queue cleanup rejects `transaction.isPersisted.promise` with `QueueDisposedError` and rolls back its optimistic mutation
- Failed mutations surface their error via `transaction.when('settled')` (which will reject)
- Queue overflow rejects `transaction.when('settled')` with `QueueCapacityExceededError`
- A call after queue cleanup rejects `transaction.when('settled')` with `QueueDisposedError` and rolls back its optimistic mutation
- **Subsequent mutations continue processing** - a single failure does not block the queue
- Each mutation is independent; there is no all-or-nothing transaction semantics across multiple mutations
- To implement retry logic, see [Retry Behavior](#retry-behavior)
Expand Down Expand Up @@ -1305,7 +1309,7 @@ function MyComponent({ itemId }: { itemId: string }) {

// Optionally wait for handler settlement
try {
await tx.isPersisted.promise
await tx.when('settled')
console.log('Transaction completed!')
} catch (error) {
console.error('Save failed:', error)
Expand Down Expand Up @@ -1505,7 +1509,7 @@ const handleCreatePost = async (postData) => {

try {
// Wait for this transaction's handler to complete.
await tx.isPersisted.promise
await tx.when('settled')

// This is confirmed and published only if the handler awaited both.
navigate(`/posts/${postData.id}`)
Expand Down Expand Up @@ -1540,12 +1544,12 @@ const tx = todoCollection.update(todoId, (draft) => {
console.log(tx.state) // 'pending'

// Wait for specific states
await tx.isPersisted.promise
await tx.when('settled')
console.log(tx.state) // 'completed'; a rejection takes the transaction to 'failed'

// Handle errors
try {
await tx.isPersisted.promise
await tx.when('settled')
console.log("Success!")
} catch (error) {
console.log("Failed:", error)
Expand Down Expand Up @@ -1651,7 +1655,7 @@ This is the cleanest approach when your backend supports it, as the ID never cha

Configure the mutation handler to wait for the server response and the
authoritative row to sync before it returns. You can then await handler
settlement before enabling subsequent operations. `isPersisted.promise` does
settlement before enabling subsequent operations. `when('settled')` does
not expose or translate the real ID; read it from the synced row or an
application-owned response mapping. With a non-optimistic insert, the pending
item stays out of the view until the collection publishes synced data.
Expand All @@ -1667,7 +1671,7 @@ const handleCreateTodo = async (text: string) => {
})

// This is an authoritative-sync gate only if onInsert waits for that sync.
await tx.isPersisted.promise
await tx.when('settled')

// Do not infer ID readiness from transaction state. Read the synced row or
// application-owned mapping, then enable operations with that real ID.
Expand Down
8 changes: 4 additions & 4 deletions docs/guides/offline-transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const transaction = addTodo({
})

// Report final failure. This promise can stay pending offline.
void transaction.isPersisted.promise.catch((error) => console.error(error))
void transaction.when('settled').catch((error) => console.error(error))
```

`onMutate` must be synchronous. It applies the optimistic change before the executor writes the outbox entry. If that write fails, the transaction fails. A visible optimistic change alone does not prove durable storage.
Expand All @@ -92,15 +92,15 @@ The executor sends a stable `idempotencyKey` with each attempt. A server can rec

`waitForInit()` waits for storage setup, leader election, and the initial outbox read. It does not wait for every pending mutation to reach the server.

`transaction.isPersisted.promise` settles when the offline transaction completes
`transaction.when('settled')` settles when the offline transaction completes
or fails. Successful settlement means the configured `mutationFn` returned and
the storage adapter acknowledged outbox deletion. It proves server confirmation
only if that function waits for a server acknowledgement, read-back, or sync
observation. If deletion fails, the promise rejects with the storage error.
A server must honor the supplied idempotency key because a crash before
the executor records provider completion can replay the request.

Do not await `isPersisted.promise` before you show an offline page. A pending mutation can keep that promise open until connectivity returns. Use it to update submission status or report a final error.
Do not await `transaction.when('settled')` before you show an offline page. A pending mutation can keep that promise open until connectivity returns. Use it to update submission status or report a final error.

For a multi-step manual transaction, use `createOfflineTransaction`:

Expand Down Expand Up @@ -277,7 +277,7 @@ const transaction = addTodo({
title: 'Buy milk',
completed: false,
})
void transaction.isPersisted.promise.catch((error) => console.error(error))
void transaction.when('settled').catch((error) => console.error(error))
```

For the leader, `waitForInit()` waits for the initial outbox read and optimistic restoration attempt. It does not make `todos` ready. The separate `preload()` call starts Collection loading without blocking offline actions. A screen that needs initial rows can await `todos.preload()` and handle a rejection.
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/sqlite-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const transaction = todos.insert({
title: 'Buy milk',
completed: false,
})
await transaction.isPersisted.promise
await transaction.when('settled')
```

This Collection has no `sync` option. Its normal insert, update, and delete handlers save local mutations to SQLite. It does not contact a server. The browser must support OPFS and run in a secure context. If multiple tabs can open the same database, use the [multi-tab setup](#browser-tabs-and-electron-renderers).
Expand Down
Loading
Loading