Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d89cb13
Merge pull request #2 from powersync-ja/update-main
stevensJourney Oct 2, 2025
828fb71
Merge remote-tracking branch 'upstream/main' into update-main-2
stevensJourney Oct 22, 2025
9a151ec
Merge pull request #3 from powersync-ja/update-main-2
stevensJourney Oct 22, 2025
5750fbc
Merge remote-tracking branch 'upstream/main' into upstream/update-main-4
Chriztiaan Jun 11, 2026
082ce15
Merge pull request #8 from powersync-ja/upstream/update-main-4
Chriztiaan Jun 11, 2026
3124c9e
Added attachments support.
Chriztiaan Jun 12, 2026
d18ab19
Types and tests.
Chriztiaan Jun 18, 2026
91c24c6
Docs.
Chriztiaan Jun 23, 2026
3770d3b
changeset.
Chriztiaan Jun 23, 2026
38af8c5
Rename saveFileTanStack => save, deleteFIleTanStack => delete.
Chriztiaan Jun 24, 2026
d82ffc2
Made `updateHook` synchronous and updated `AttachmentQueueRow` typing.
Chriztiaan Jun 24, 2026
cd8b191
Merge branch 'main' into feat/powersync-attachments
Chriztiaan Jun 25, 2026
2250d35
Merge branch 'main' into feat/powersync-attachments
Chriztiaan Jul 21, 2026
dc7f13e
Minor patch changeset. Updated docs to reflec sync nature of updateHook.
Chriztiaan Jul 28, 2026
6d458a8
Rollback new written attachment on transaction failure.
Chriztiaan Aug 3, 2026
f9f6823
Cleanup import formatting and removed incorrect doc line.
Chriztiaan Aug 3, 2026
2718e1b
Merge branch 'feat/powersync-attachments' of github.com:powersync-ja/…
Chriztiaan Aug 3, 2026
5d3b500
Minor coderabbit feedback.
Chriztiaan Aug 3, 2026
fc55bee
Address concurrent save race issue.
Chriztiaan Aug 11, 2026
bb7cbba
fix(powersync): load attachment IDs before mutations and guard file o…
KyleAMathews Sep 11, 2026
d34d2d4
chore: add changeset for attachment ownership fixes
KyleAMathews Sep 11, 2026
50288a4
Merge pull request #10 from TanStack/codex/powersync-attachment-revie…
Chriztiaan Sep 14, 2026
2b8e76d
Merge branch 'main' into feat/powersync-attachments
Chriztiaan Sep 14, 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
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': minor
---
Comment thread
Chriztiaan marked this conversation as resolved.

Add attachments support via `TanStackDBAttachmentQueue`. This extends the PowerSync SDK's `AttachmentQueue` and backs it with
a TanStack DB collection, so attachment metadata and related rows commit atomically. Local files and remote uploads/deletes are managed separately.
5 changes: 5 additions & 0 deletions .changeset/powersync-attachment-startup-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/powersync-db-collection': patch
---

Load attachment IDs before save/delete in eager and on-demand collections. Preserve existing files when a duplicate save is rejected, reject overlapping saves of the same ID across queues sharing a database, and clean up partial local writes.
175 changes: 174 additions & 1 deletion docs/collections/powersync-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -1097,4 +1097,177 @@ 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) that commits attachment metadata and related collection mutations (for example, setting `lists.photo_id`) in one database transaction. File I/O is separate: a failed save attempts to remove its local file, while the SDK performs remote uploads and deletes later.

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.

Both eager and on-demand collections work. Before `save` or `delete` opens its mutation, the queue loads the attachment ID through a temporary live query and retains that query until the transaction is confirmed. It does not call `preload()` inside a mutation function or require loading the entire table.

An existing ID, or a concurrent save of that ID through the same PowerSync database object, is rejected before writing the file. This is an in-process guard, not a lock across separate database handles, SDK queues, tabs, or processes. File names retain the SDK's ID-based convention so restart can find files after the app's storage directory moves.

```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 thread
Chriztiaan marked this conversation as resolved.
}
```

### 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: (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
})
},
})
```

> `updateHook` must be synchronous, it runs inside the transaction's synchronous `mutate()` block and its return value is not awaited, so any mutation after an `await` escapes the transaction. Do asynchronous work before calling `save` or `delete`.

### 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. As with `save`, the hook must be synchronous.

**Upstream limitation:** the SDK version used by this PR can overwrite a queued deletion when an already-running upload succeeds or fails. The related row is detached, but the SDK can lose the remote deletion or retry the obsolete upload. This integration does not work around that SDK completion race. The [attachment oracle notes](../../packages/powersync-db-collection/tests/ATTACHMENT-ORACLE.md) include runnable native-SDK and integration repros; a green ordinary suite does not establish safety for this overlap.

```ts
await attachmentQueue.delete({
id: photo_id,
updateHook: () => {
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,
}))
)
```
9 changes: 5 additions & 4 deletions packages/powersync-db-collection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"build": "vite build",
"dev": "vite build --watch",
"lint": "eslint . --fix",
"test": "vitest --run"
"test": "vitest --run",
"test:upstream-repros": "vitest run --config tests/upstream.config.ts --maxWorkers=2"
},
"type": "module",
"main": "dist/cjs/index.cjs",
Expand Down Expand Up @@ -59,11 +60,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
Loading