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
6 changes: 6 additions & 0 deletions .changeset/curly-planets-lead.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/powersync-db-collection': patch
---
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

patch is too small for this release.

This PR adds a new public export and raises the package's @powersync/common peer minimum from ^1.41.0 to ^1.57.0. Shipping that as a patch hides a new surface area and can break consumers that have not upgraded their PowerSync peer yet. Please bump this changeset to at least minor (or whatever level your peer-dependency policy treats as breaking).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/curly-planets-lead.md around lines 1 - 3, The changeset for
`@tanstack/powersync-db-collection` is labeled patch, but this release introduces
a new public export and raises the `@powersync/common` peer minimum, so update the
changeset in curly-planets-lead.md to at least minor (or the appropriate
breaking level per your policy). Keep the package name the same and change only
the release type so the generated release notes reflect the expanded surface
area and peer dependency bump.


Add attachments support via `TanStackDBAttachmentQueue`. This extends the PowerSync SDK's `AttachmentQueue` and backs it with
a TanStack DB collection, so file uploads/deletes are managed atomically alongside the relational data.
167 changes: 167 additions & 0 deletions docs/collections/powersync-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -1099,4 +1099,171 @@ const liveQuery = createLiveQueryCollection({
completed: todo.completed,
})),
})
```

## Attachments

`@tanstack/powersync-db-collection` ships `TanStackDBAttachmentQueue`, an [`AttachmentQueue`](https://docs.powersync.com/usage/use-case-examples/attachments-files) whose file operations commit inside a TanStack DB collection transaction. This lets you create (or delete) an attachment and mutate a related collection row (for example, setting `lists.photo_id`) atomically in a single transaction, instead of issuing two independent writes.

The queue extends PowerSync's `AttachmentQueue`, so the generic concepts are unchanged and documented once in the SDK.

> This section only covers what is specific to the TanStack DB integration. For storage adapters (local and remote), the `AttachmentTable` schema primitive, error-handling/retry semantics, and the `startSync()` / `stopSync()` lifecycle, see the [PowerSync attachments documentation](https://docs.powersync.com/usage/use-case-examples/attachments-files).

### Prerequisites

These are standard PowerSync attachment requirements. See the SDK attachments docs for details.

- An `AttachmentTable` in your schema:

```ts
import { AttachmentTable, Schema } from "@powersync/web"

const APP_SCHEMA = new Schema({
// ...your tables
attachments: new AttachmentTable(),
})
```

- A local storage adapter (such as `IndexDBFileSystemStorageAdapter` on web) and a remote storage adapter (an implementation of the SDK's `RemoteStorageAdapter`, for example backed by Supabase Storage). Both are generic to all attachment users. See the SDK docs for the available adapters and the remote-adapter contract.

### 1. Create the attachments collection

This is the piece that makes the integration TanStack-aware: a normal PowerSync collection over the attachments table. The queue reads and writes attachment records through it.

```ts
import { createCollection } from "@tanstack/react-db"
import { powerSyncCollectionOptions } from "@tanstack/powersync-db-collection"

const attachmentsCollection = createCollection(
powerSyncCollectionOptions({
database: db,
table: APP_SCHEMA.props.attachments,
})
)
```

### 2. Construct the queue

Pass your collection as `attachmentsCollection` alongside the standard `AttachmentQueue` options. Only `attachmentsCollection` and `watchAttachments` (below) are specific to this package; `db`, `localStorage`, `remoteStorage`, and `errorHandler` are the usual SDK options.

```ts
import { TanStackDBAttachmentQueue } from "@tanstack/powersync-db-collection"

const attachmentQueue = new TanStackDBAttachmentQueue({
db,
attachmentsCollection, // TanStack DB collection over your AttachmentTable
localStorage, // SDK local storage adapter
remoteStorage, // your RemoteStorageAdapter (see SDK docs)
watchAttachments, // see step 3
errorHandler, // standard AttachmentQueue error handler (see SDK docs)
})
```

Start and stop syncing with the standard `attachmentQueue.startSync()` / `attachmentQueue.stopSync()` lifecycle (see SDK docs), typically inside a React effect or provider.

### 3. Tell the queue which attachments exist (`watchAttachments`)

`watchAttachments` reports the set of attachment IDs your data currently references, so the queue knows what to download and what to archive. With TanStack DB you drive it from a live query: emit the initial state, then re-emit the complete set on every change, and clean up on abort.

```ts
import {
createCollection,
isNull,
liveQueryCollectionOptions,
not,
} from "@tanstack/db"
import { WatchedAttachmentItem } from "@powersync/web"

const watchAttachments = async (onUpdate, abortSignal) => {
// Every row in your data model that references an attachment.
const livePhotoIds = createCollection(
liveQueryCollectionOptions({
query: (q) =>
q
.from({ document: listsCollection })
.where(({ document }) => not(isNull(document.photo_id)))
.select(({ document }) => ({ photo_id: document.photo_id })),
})
)

const mapper = (item) =>
({
id: item.photo_id,
fileExtension: "jpg",
}) satisfies WatchedAttachmentItem

// 1. Report the initial set of referenced attachment IDs.
const initialState = await livePhotoIds.stateWhenReady()
onUpdate(Array.from(initialState.values()).map(mapper))

// 2. Re-emit the whole set on every change (the queue expects the holistic state).
livePhotoIds.subscribeChanges(() => {
onUpdate(livePhotoIds.map(mapper))
})

// 3. Clean up when sync stops.
abortSignal.addEventListener("abort", () => livePhotoIds.cleanup(), {
once: true,
})
Comment on lines +1195 to +1207

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Serialize the async onUpdate calls in this example.

The watcher example fire-and-forgets onUpdate for both the initial state and subsequent changes. If two updates arrive close together, those promises can resolve out of order and stale attachment sets can win. Please show an awaited/serialized pattern here; the test helper currently mirrors the same race.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/collections/powersync-collection.md` around lines 1195 - 1207, The
watcher example in the referenced attachment-ID flow is fire-and-forgetting
onUpdate, which can let overlapping updates resolve out of order. Update the
example around livePhotoIds.stateWhenReady, livePhotoIds.subscribeChanges, and
the onUpdate callback to serialize each async invocation by awaiting the
previous one (for example through a chained promise or equivalent queue) so the
initial state and later change notifications are processed in order. Also mirror
the same awaited pattern in the test helper that currently reproduces this race.

}
```

> A `watchAttachmentsFromQuery(...)` convenience helper that collapses this boilerplate into a single call is planned. Until then, use the pattern above.

### 4. Save an attachment atomically with related data

`save` writes the file, inserts the attachment record into your collection, and runs your `updateHook` mutations in the same transaction. Use the hook to insert or update the row that references the new attachment, so both land together or not at all.

```ts
await attachmentQueue.save({
data, // file bytes (ArrayBuffer / base64, per your local adapter)
fileExtension: "jpg",
updateHook: async (attachmentRecord) => {
// Runs in the same transaction as the attachment insert.
listsCollection.insert({
id: crypto.randomUUID(),
name,
created_at: new Date(),
owner_id: userID,
photo_id: attachmentRecord.id, // associate the row with the attachment
})
},
})
```

### 5. Delete an attachment and detach it from the row

`delete` queues the file for deletion and runs your `updateHook` in the same transaction. Clear the foreign key so the row and the attachment stay consistent.

```ts
await attachmentQueue.delete({
id: photo_id,
updateHook: async () => {
listsCollection.update(listId, (draft) => {
draft.photo_id = null
})
},
})
```

### 6. Display attachments via a live-query join

Join your attachments collection into a live query to read the local URI (the locally cached file path) alongside your domain rows:

```ts
import { eq } from "@tanstack/db"

const { data } = useLiveQuery((q) =>
q
.from({ lists: listsCollection })
.leftJoin({ attachment: attachmentsCollection }, ({ lists, attachment }) =>
eq(lists.photo_id, attachment.id)
)
.select(({ lists, attachment }) => ({
id: lists.id,
name: lists.name,
photo_id: lists.photo_id,
attachment_local_uri: attachment?.local_uri,
}))
)
```
6 changes: 3 additions & 3 deletions packages/powersync-db-collection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@
"p-defer": "^4.0.1"
},
"peerDependencies": {
"@powersync/common": "^1.41.0"
"@powersync/common": "^1.57.0"
},
"devDependencies": {
"@powersync/common": "1.49.0",
"@powersync/node": "0.18.1",
"@powersync/common": "1.57.0",
"@powersync/node": "0.19.2",
"@types/debug": "^4.1.12",
"@vitest/coverage-istanbul": "^3.2.4",
"better-sqlite3": "^12.6.2"
Expand Down
156 changes: 156 additions & 0 deletions packages/powersync-db-collection/src/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
AttachmentQueue,
AttachmentState
} from '@powersync/common'
import { createTransaction } from '@tanstack/db'
import { PowerSyncTransactor } from './PowerSyncTransactor'

import type {
AbstractPowerSyncDatabase,
AttachmentData,
AttachmentQueueOptions,

AttachmentTable} from '@powersync/common'
import type { Collection } from '@tanstack/db'
import type { OptionalExtractedTable } from './helpers'

export type TanStackDBAttachmentQueueOptions = AttachmentQueueOptions & {
/**
* For TanStack, we want access to the synced TanStackDB collection.
* In order to have the same relational data be set in a single transaction.
* This also allows for joining both TanStackDB collections.
*/
attachmentsCollection: Collection<AttachmentQueueRow, string>
}

export interface SaveOptions {
data: AttachmentData
fileExtension: string
mediaType?: string
metaData?: string
id?: string
/**
* Called within the same TanStackDB transaction as the attachment write,
* so any mutations made to other collections are committed atomically with it.
*/
updateHook?: (attachment: AttachmentQueueRow) => void
}

export interface DeleteOptions {
id: string
/**
* Called within the same TanStackDB transaction as the attachment write,
* so any mutations made to other collections are committed atomically with it.
*/
updateHook?: (attachment: AttachmentQueueRow) => void
}

export type AttachmentQueueRow = OptionalExtractedTable<AttachmentTable>

/**
* A custom extension of the PowerSyncAttachmentQueue for TanStackDB.
*/
export class TanStackDBAttachmentQueue extends AttachmentQueue {
readonly powersync: AbstractPowerSyncDatabase
readonly collection: Collection<AttachmentQueueRow, string>

constructor(params: TanStackDBAttachmentQueueOptions) {
super(params)
this.powersync = params.db
this.collection = params.attachmentsCollection
}

/**
* Saves a file to local storage and queues it for upload to remote storage.
*
* Exposes an `updateHook` option which is called inside a TanStackDB transaction,
* relational associations with the provided attachment ID should be made in this hook.
*/
async save({
data,
fileExtension,
mediaType,
metaData,
id,
updateHook,
}: SaveOptions): Promise<AttachmentQueueRow> {
const resolvedId = id ?? (await this.generateAttachmentId())
const filename = `${resolvedId}.${fileExtension}`
const localUri = this.localStorage.getLocalUri(filename)
const size = await this.localStorage.saveFile(localUri, data)
Comment on lines +79 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Roll back the saved file when the transaction fails.

saveFile() runs before the collection transaction starts. If insert, updateHook, or commit() throws, the DB work is rolled back but the blob stays on disk, so this API is no longer atomic and leaks orphaned local files. Please clean up the just-written file on every failure path and add a regression test that throws from updateHook.

Also applies to: 99-116

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/powersync-db-collection/src/attachments.ts` around lines 79 - 80,
The `addAttachment` flow in `Attachments` writes the blob with
`localStorage.saveFile()` before the transaction, but failures in `insert`,
`updateHook`, or `commit()` leave the file orphaned on disk. Update the
attachment write path to track the just-saved local URI and delete it on every
exception path, including transaction rollback and any thrown hook/commit error,
while keeping successful saves intact. Also add a regression test around
`updateHook` that throws to verify the DB changes roll back and the local file
is cleaned up.


const attachment: AttachmentQueueRow = {
id: resolvedId,
filename,
media_type: mediaType ?? null,
local_uri: localUri,
state: AttachmentState.QUEUED_UPLOAD,
has_synced: 0,
size,
timestamp: new Date().getTime(),
meta_data: metaData ?? null,
}

/**
* We use the attachmentService lock to prevent attachment queue race conditions — specifically,
* it stops the watcher from treating a newly inserted attachment record as one that needs
* to be downloaded.
* */
await this.withAttachmentContext(async (ctx) => {
const tanStackDBTransaction = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
await new PowerSyncTransactor({
database: ctx.db,
}).applyTransaction(transaction)
},
})

tanStackDBTransaction.mutate(() => {
this.collection.insert(attachment)
// allow the user to associate values in this transaction
updateHook?.(attachment)
})

await tanStackDBTransaction.commit()
})

return attachment
}

/**
* Queues a file for deletion from local and remote storage.
*
* Exposes an `updateHook` option which is called inside a TanStackDB transaction,
* relational associations with the provided attachment ID should be cleaned up in this hook.
*/
async delete({ id, updateHook }: DeleteOptions): Promise<void> {
await this.withAttachmentContext(async (ctx) => {
const tanStackDBTransaction = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
await new PowerSyncTransactor({
database: ctx.db,
}).applyTransaction(transaction)
},
})

tanStackDBTransaction.mutate(() => {
const attachment = this.collection.get(id)
if (!attachment) {
throw new Error(`Attachment with id ${id} not found`)
}

this.collection.update(id, (draft) => {
draft.state = AttachmentState.QUEUED_DELETE
draft.has_synced = 0
})

// allow the user to associate values in this transaction
updateHook?.(attachment)
})

await tanStackDBTransaction.commit()
})
}
}
1 change: 1 addition & 0 deletions packages/powersync-db-collection/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './attachments'
export * from './definitions'
export * from './powersync'
export * from './PowerSyncTransactor'
Expand Down
Loading