Skip to content

Awaiting txid across multiple collections. #337

@thruflo

Description

@thruflo

I'd just like to query a pattern I documented in https://electric-sql.com/blog/2025/07/29/local-first-sync-with-tanstack-db#transactions-and-optimistic-actions and https://electric-sql.com/blog/2025/07/29/local-first-sync-with-tanstack-db#write-path-sync

Specifically, is there a better way we can monitor the txid syncing back when writes are made to multiple collections?

The context being that transactions are explicitly designed to allow you to write atomically across collections. With the example as documented:

import { createOptimisticAction } from '@tanstack/react-db'

const createUserWithDefaultWorkspace = createOptimisticAction({
  (loginName) => {
    const userId = crypto.randomUUID()
    const workspaceId = crypto.randomUUID()

    // These inserts are applied atomically
    userCollection.insert({
      id: userId,
      name: loginName
    })
    workspaceCollection.insert({
      id: workspaceId,
      name: 'Default'
    })
    membershipCollection.insert({
      role: 'owner',
      userId,
      workspaceId
    })
  },
  mutationFn: async (_loginName, { transaction }) => {
    // In this case, the `transaction` contains all three mutations.

    // ... handle sending to the server ...
  }
})

The local optimistic state is applied atomically (I believe?) and the mutationFn is invoked atomically (i.e.: once, with a single transaction). However, when then monitoring the replication stream for the write, the example awaits the transaction arriving in all three collections:

export const mutationFn = async (_variables, { transaction }) => {
  const mutations = transaction.mutations

  const collections = new Set(mutations.map(mutation => mutation.collection))
  const payloadData = mutations.map(mutation => {
    const { collection, ...result } = mutation

    return result
  })

  // Post the mutations data to the ingest endpoint.
  const txid = await api.post('/ingest', { mutations: payloadData })

  // Monitor the collections for the transaction to sync back in.
  const promises = [...collections].map(({ utils }) => utils.awaitTxId(txid))
  await Promise.all(promises)
}

Now, the happy path is collections all sync the write back roughly at the same time and there's no real gap between updating the synced state in each collection and dropping the optimistic state. However, obviously there can be race conditions and it's easy for one of the collections to hang, potentially for a long time.

I'm not sure whether this ever would result in flickering / temporary anomalies? But generally it feels like this weakens the transactional guarantees and leaves the door open to oddness. As well as not being very ergonomic DX.

We've obviously discussed various approaches to transactional streams across multiple shapes. I'm not sure what's possible or sane here. Just flagging it up because I was eyeballing it when writing the docs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationquestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions