Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1395490
docs(db-adapter-conformance): plan the mutations and confirmation family
Nov 3, 2026
da23ed3
test(db): generate field removal in the optimistic-history oracle
Nov 3, 2026
30c3bfc
feat(db-adapter-conformance): the mutations family, with a reference …
Nov 3, 2026
8977b7b
test(db-adapter-conformance): model held sync copies and a provider's…
Nov 3, 2026
9443fb2
fix(rxdb-db-collection): persist writes in author order
Nov 3, 2026
af5df89
test(db-adapter-conformance): generate provider copies that differ fr…
Nov 3, 2026
1cf77ad
fix(rxdb-db-collection): require RxDB 17.6.0
Nov 3, 2026
c660428
test(db-adapter-conformance): field updates and confirmation through …
Nov 3, 2026
56dc133
fix(trailbase-db-collection): send removals, order writes, confirm by…
Nov 3, 2026
9ebd847
test(db-adapter-conformance): count only plan rewrites as server-rewr…
Nov 3, 2026
85e978e
fix(powersync-db-collection): persist writes in author order
Nov 3, 2026
2cbae92
test(db-adapter-conformance): a write is decided when the provider an…
Nov 3, 2026
6bc495b
fix(trailbase-db-collection): start the next write at the earlier res…
Nov 3, 2026
24bf6f0
revert(powersync-db-collection): keep writes pipelined behind the wri…
Nov 3, 2026
226b429
test(db-adapter-conformance): application handlers, log-ordered echoe…
Nov 3, 2026
76a00a9
test(electric-db-collection): run the mutation oracle over Electric's…
Nov 3, 2026
6dc0a3f
test(convex-db-collection): run the mutation oracle over the Convex c…
Nov 3, 2026
29681ad
test(db-adapter-conformance): generate same-key writes while one is p…
Nov 3, 2026
91c55a6
fix(trailbase-db-collection): confirm each write by its own change event
Nov 3, 2026
2b64a4a
fix(trailbase-db-collection): send a transaction's requests in order
Nov 3, 2026
1f8b749
fix(rxdb-db-collection): reject an update whose document is gone
Nov 3, 2026
a63ea94
test(db-adapter-conformance): model merged updates for RxDB
Nov 4, 2026
cba2ca6
docs(trailbase-db-collection): a removed field is NULL, not serialized
Nov 4, 2026
921888d
test(db-adapter-conformance): apply code simplification from review
Nov 4, 2026
46cc12e
Merge remote-tracking branch 'origin/main' into feat/adapter-conforma…
Nov 4, 2026
43cb8c9
chore(changeset): describe TrailBase's final write behavior
Nov 4, 2026
78e995b
ci: apply automated fixes
autofix-ci[bot] Nov 4, 2026
a4bbb87
test(rxdb-db-collection): rename the driver's storage-instance field
Nov 4, 2026
04aa70b
Merge autofix formatting into feat/adapter-conformance-mutations
Nov 4, 2026
08035f0
test(db): measure removed-field reach in each optimistic-history camp…
Nov 4, 2026
8f33ad1
fix(trailbase): number a write's answer even when its transaction is …
Nov 4, 2026
8dd89f9
test(adapter-conformance): close the review's mutation-oracle gaps
Nov 4, 2026
fd10424
test(adapter-conformance): generate other clients' commits over pendi…
Nov 4, 2026
338862f
fix(rxdb, trailbase, electric): keep writes correct across reloads an…
Nov 4, 2026
5065086
test(adapter-conformance): generate transactions with several mutations
Nov 4, 2026
354d597
fix(trailbase, rxdb)!: make TrailBase transactions atomic and RxDB's …
Nov 4, 2026
39a5b3d
docs(adapter-conformance): record the 2026-11-04 mutation decisions i…
Nov 4, 2026
a35c27d
ci: apply automated fixes
autofix-ci[bot] Nov 4, 2026
91a0bbe
test(db): count only optimistic field removals toward removed-held-fi…
Nov 4, 2026
0e36e43
Merge autofix formatting into feat/adapter-conformance-mutations
Nov 4, 2026
3774168
docs(trailbase): make the edited guide and skill examples typecheck
Nov 4, 2026
a7d5c12
fix(powersync): give each sync run its own diff-tracking table
Nov 4, 2026
629577f
test(adapter-conformance): address CodeRabbit's reviews of 354d597 an…
Nov 4, 2026
1f06fd7
ci: apply automated fixes
autofix-ci[bot] Nov 4, 2026
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/electric-reload-keeps-waits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/electric-db-collection': patch
---

Keep `awaitTxId` and `awaitMatch` waits across `collection.reload()`. Before, a reload rejected every pending wait with `StreamAbortedError`, so a write that the backend had accepted rolled back. The reloaded stream resumes the same shape log, so the waits now carry over and resolve when their txid or match arrives. Cleaning up the collection, or aborting its stream, still rejects them.
5 changes: 5 additions & 0 deletions .changeset/powersync-reload-tracking-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/powersync-db-collection': patch
---

Fix `collection.reload()` sometimes failing to start the new sync run. Each sync run now tracks changes in its own table. Before, a reload's run reused the retired run's table name, so it failed with "table already exists" when the retired run had not dropped its table yet.
5 changes: 5 additions & 0 deletions .changeset/rxdb-delete-retries-conflict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/rxdb-db-collection': patch
---

Retry a delete when another client changes the document while the delete waits. Before, a delete that met a newer revision, for example from another tab or a replication pull, failed with a conflict and its row came back. The adapter now removes each document with `incrementalRemove`, which retries over the newer revision, as updates already did.
5 changes: 5 additions & 0 deletions .changeset/rxdb-insert-in-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/rxdb-db-collection': patch
---

Insert a transaction's documents one at a time, in mutation order, and stop at the first rejected insert, as updates and deletes already do. Before, RxDB wrote the documents after a rejected one anyway, so a rejected transaction could leave later rows saved. It could also write the documents out of order.
5 changes: 5 additions & 0 deletions .changeset/rxdb-ordered-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/rxdb-db-collection': patch
---

Persist writes to RxDB in the order the application made them. A write now starts only after the earlier write on the same collection fulfills or rejects. Before, a later write could reach RxDB while an earlier one was still pending. A rejected delete now rejects with RxDB's `RxError` for the write error, as a rejected insert does, instead of with an array of write errors.
5 changes: 5 additions & 0 deletions .changeset/rxdb-reject-missing-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/rxdb-db-collection': patch
---

Reject an update whose document no longer exists in RxDB, for example because another tab deleted it while the update waited. The update now fails with `UpdateKeyNotFoundError` and its optimistic change rolls back. Before, the adapter skipped it and reported success, so the write was silently lost.
5 changes: 5 additions & 0 deletions .changeset/rxdb-reload-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/rxdb-db-collection': patch
---

Fix writes made around a `collection.reload()`. A write now returns only once its change is in the collection's synced rows. Before, after a reload, a write could return while its change was still buffered, so its row briefly disappeared, or the write never settled. A reload's initial read now replaces the kept rows at once.
5 changes: 5 additions & 0 deletions .changeset/rxdb-require-17-6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/rxdb-db-collection': patch
---

Require RxDB 17.6.0 or later. In RxDB 17.1.0 to 17.5.0, a storage write that throws stops RxDB's incremental write queue (pubkey/rxdb#9025), so the adapter's update never settles and every later update on the collection waits forever. RxDB 17.6.0 fixes this, and the adapter's update now rejects with the storage error.
12 changes: 12 additions & 0 deletions .changeset/trailbase-atomic-transactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/trailbase-db-collection': minor
---

**Breaking:** `trailBaseCollectionOptions` now requires `client`, the TrailBase client your record API comes from.

```ts
const client = initClient(url)
trailBaseCollectionOptions({ client, recordApi: client.records('todos'), … })
```

Each transaction's writes now go to TrailBase in one `client.execute(operations, true)` call, so they commit together or not at all. Before, a transaction's updates and deletes went out one at a time, and when TrailBase rejected one, the earlier ones stayed saved while the transaction rolled back in the app.
9 changes: 9 additions & 0 deletions .changeset/trailbase-ordered-confirmed-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/trailbase-db-collection': patch
---

Fix three write bugs.

- Removing a field in an update (for example `draft.note = undefined`) now sends `null`, so TrailBase clears the column. Before, the update omitted the field, and TrailBase kept the old value. The field's `serialize` function is not called for a removal.
- Writes now reach TrailBase in the order the application made them. A write starts only after TrailBase has answered the earlier write on the same collection. It does not wait for the earlier write's change event.
- A write is no longer confirmed by an earlier write's change event. Before, an earlier event for the same record could confirm a later write, so its optimistic value dropped and the earlier value showed until the later event arrived. The adapter now numbers this client's answered writes and the change events for each record. A change another client makes to the same record while a write waits still counts as an event. This change also removes the adapter's periodic cleanup timer.
5 changes: 5 additions & 0 deletions .changeset/trailbase-reload-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/trailbase-db-collection': patch
---

Fix a write that never settled after `collection.reload()`. A write whose change event was still on the way when the reload replaced the change stream waited for an event that the new stream never delivers. The new stream now opens only after every earlier write is answered, and the reload's listing confirms those writes. A change event from another client for a record with a pending write still counts toward that write, because TrailBase events do not say which write made them.
91 changes: 38 additions & 53 deletions docs/collections/trailbase-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ import { initClient } from 'trailbase'

const trailBaseClient = initClient(`https://your-trailbase-instance.com`)

type Todo = { id: string; text: string; completed: boolean }

const todosCollection = createCollection(
trailBaseCollectionOptions({
trailBaseCollectionOptions<Todo>({
id: 'todos',
client: trailBaseClient,
recordApi: trailBaseClient.records('todos'),
getKey: (item) => item.id,
parse: {},
serialize: {},
})
)
```
Expand All @@ -45,17 +50,15 @@ The `trailBaseCollectionOptions` function accepts the following options:
### Required Options

- `id`: Unique identifier for the collection
- `client`: The TrailBase client the record API comes from. Each transaction's writes go through its `execute` as one TrailBase transaction, so they commit together or not at all.
- `recordApi`: TrailBase Record API instance created via `trailBaseClient.records()`
- `getKey`: Function to extract the unique key from an item
- `parse`: Object mapping field names to parsing functions that transform data coming from TrailBase. Use `{}` when the record and the item have the same shape.
- `serialize`: Object mapping field names to serialization functions that transform data going to TrailBase. An update that removes a field (for example `draft.tags = undefined`) sends `null`, which clears the column, without calling that field's function. A `NOT NULL` column rejects such an update. Use `{}` when the record and the item have the same shape.

### Optional Options

- `schema`: [Standard Schema](https://standardschema.dev) compatible schema (e.g., Zod, Effect) for client-side validation
- `parse`: Object mapping field names to parsing functions that transform data coming from TrailBase
- `serialize`: Object mapping field names to serialization functions that transform data going to TrailBase
- `onInsert`: Handler function called when items are inserted
- `onUpdate`: Handler function called when items are updated
- `onDelete`: Handler function called when items are deleted
- `syncMode`: `eager` (the default) loads every record at startup, and `on-demand` loads the records that queries ask for

## Data Transformation

Expand All @@ -76,12 +79,12 @@ type Todo = {
completed: boolean
}

const todosCollection = createCollection<SelectTodo, Todo>(
trailBaseCollectionOptions({
const todosCollection = createCollection(
trailBaseCollectionOptions<Todo, SelectTodo>({
id: 'todos',
client: trailBaseClient,
recordApi: trailBaseClient.records('todos'),
getKey: (item) => item.id,
schema: todoSchema,
// Transform TrailBase data to application format
parse: {
created_at: (ts) => new Date(ts * 1000),
Expand All @@ -99,11 +102,16 @@ const todosCollection = createCollection<SelectTodo, Todo>(
TrailBase supports real-time subscriptions when enabled on the server. The collection automatically subscribes to changes and updates in real-time:

```typescript
type Todo = { id: string; text: string; completed: boolean }

const todosCollection = createCollection(
trailBaseCollectionOptions({
trailBaseCollectionOptions<Todo>({
id: 'todos',
client: trailBaseClient,
recordApi: trailBaseClient.records('todos'),
getKey: (item) => item.id,
parse: {},
serialize: {},
// Real-time updates work automatically when
// enable_subscriptions is set in TrailBase config
})
Expand All @@ -113,80 +121,57 @@ const todosCollection = createCollection(
// the collection in real-time
```

## Mutation Handlers
## Writes and Transactions

Handle inserts, updates, and deletes by providing mutation handlers:
The adapter persists inserts, updates, and deletes itself, so it takes no `onInsert`, `onUpdate`, or `onDelete` handler. Each transaction goes to TrailBase as one `client.execute` call, so its writes commit together or not at all. A write settles when its change event arrives through the subscription:

```typescript
const todosCollection = createCollection(
trailBaseCollectionOptions({
id: 'todos',
recordApi: trailBaseClient.records('todos'),
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const newTodo = transaction.mutations[0].modified
// TrailBase handles the persistence automatically
// Add custom logic here if needed
},
onUpdate: async ({ transaction }) => {
const { original, modified } = transaction.mutations[0]
// TrailBase handles the persistence automatically
// Add custom logic here if needed
},
onDelete: async ({ transaction }) => {
const deletedTodo = transaction.mutations[0].original
// TrailBase handles the persistence automatically
// Add custom logic here if needed
},
})
)
// One transaction: TrailBase saves both updates or neither.
const tx = todosCollection.update([firstId, secondId], (drafts) => {
for (const draft of drafts) draft.completed = true
})
await tx.when('settled')
```

If TrailBase rejects the transaction, `tx.when('settled')` rejects with its error, and the optimistic changes roll back.

## Complete Example

```typescript
import { createCollection } from '@tanstack/react-db'
import { createCollection, useLiveQuery } from '@tanstack/react-db'
import { not } from '@tanstack/db'
import { trailBaseCollectionOptions } from '@tanstack/trailbase-db-collection'
import { initClient } from 'trailbase'
import { z } from 'zod'

const trailBaseClient = initClient(`https://your-trailbase-instance.com`)

// Define schema
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
created_at: z.date(),
})

type SelectTodo = {
id: string
text: string
completed: boolean
created_at: number
}

type Todo = z.infer<typeof todoSchema>
type Todo = {
id: string
text: string
completed: boolean
created_at: Date
}

// Create collection
export const todosCollection = createCollection<SelectTodo, Todo>(
trailBaseCollectionOptions({
export const todosCollection = createCollection(
trailBaseCollectionOptions<Todo, SelectTodo>({
id: 'todos',
client: trailBaseClient,
recordApi: trailBaseClient.records('todos'),
getKey: (item) => item.id,
schema: todoSchema,
parse: {
created_at: (ts) => new Date(ts * 1000),
},
serialize: {
created_at: (date) => Math.floor(date.valueOf() / 1000),
},
onInsert: async ({ transaction }) => {
const newTodo = transaction.mutations[0].modified
console.log('Todo created:', newTodo)
},
})
)

Expand Down
Loading
Loading