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
---

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

> 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,
}))
)
```
48 changes: 48 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -2551,6 +2551,54 @@ Add two numbers:
add(user.salary, user.bonus)
```

#### `subtract(left, right)`
Subtract two numbers:
```ts
subtract(user.salary, user.deductions)
```

#### `multiply(left, right)`
Multiply two numbers:
```ts
multiply(item.price, item.quantity)
```

#### `divide(left, right)`
Divide two numbers (returns `null` on divide-by-zero):
```ts
divide(order.total, order.itemCount)
```

#### Computed Columns in orderBy

You can use math functions directly in `orderBy` to sort by computed values. This is useful for ranking algorithms that combine multiple factors:

```ts
import { subtract, multiply, divide } from '@tanstack/db'

// HN-style ranking: balance rating with recency
// Date.now() is captured when this query is created. Recreate the query if
// you need the recency score to advance as time passes.
const rankedRecipes = createLiveQueryCollection((q) =>
q
.from({ r: recipesCollection })
.orderBy(
({ r }) =>
subtract(
multiply(r.rating, r.timesMade), // weighted rating
divide(
subtract(Date.now(), r.lastMadeAt), // time since last made
3600000 * 24 // convert ms to days
)
),
'desc'
)
.limit(20)
)
```

> **Note:** When using computed expressions in `orderBy` with `limit()`, lazy loading optimization is skipped (all matching data is loaded first, then sorted). For large collections where this matters, consider pre-computing the ranking score as a stored field.

### Utility Functions

#### `coalesce(...values)`
Expand Down
4 changes: 2 additions & 2 deletions examples/angular/todos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
"@angular/forms": "^20.3.16",
"@angular/platform-browser": "^20.3.16",
"@angular/router": "^20.3.16",
"@tanstack/angular-db": "^0.1.68",
"@tanstack/db": "^0.6.8",
"@tanstack/angular-db": "^0.1.70",
"@tanstack/db": "^0.6.10",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"zone.js": "~0.15.0"
Expand Down
10 changes: 5 additions & 5 deletions examples/electron/offline-first/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
"postinstall": "prebuild-install --runtime electron --target 40.2.1 --arch arm64 || echo 'prebuild-install failed, try: npx @electron/rebuild'"
},
"dependencies": {
"@tanstack/electron-db-sqlite-persistence": "^0.1.12",
"@tanstack/node-db-sqlite-persistence": "^0.2.0",
"@tanstack/offline-transactions": "^1.0.33",
"@tanstack/query-db-collection": "^1.0.40",
"@tanstack/react-db": "^0.1.86",
"@tanstack/electron-db-sqlite-persistence": "^0.1.14",
"@tanstack/node-db-sqlite-persistence": "^0.2.2",
"@tanstack/offline-transactions": "^1.0.35",
"@tanstack/query-db-collection": "^1.0.42",
"@tanstack/react-db": "^0.1.88",
"@tanstack/react-query": "^5.90.20",
"better-sqlite3": "^12.6.2",
"react": "^19.2.4",
Expand Down
10 changes: 5 additions & 5 deletions examples/react-native/offline-transactions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
"@op-engineering/op-sqlite": "^15.2.5",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-community/netinfo": "11.4.1",
"@tanstack/db": "^0.6.8",
"@tanstack/offline-transactions": "^1.0.33",
"@tanstack/query-db-collection": "^1.0.40",
"@tanstack/react-db": "^0.1.86",
"@tanstack/react-native-db-sqlite-persistence": "^0.2.0",
"@tanstack/db": "^0.6.10",
"@tanstack/offline-transactions": "^1.0.35",
"@tanstack/query-db-collection": "^1.0.42",
"@tanstack/react-db": "^0.1.88",
"@tanstack/react-native-db-sqlite-persistence": "^0.2.2",
"@tanstack/react-query": "^5.90.20",
"expo": "~53.0.26",
"expo-constants": "~17.1.0",
Expand Down
10 changes: 5 additions & 5 deletions examples/react-native/shopping-list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
"@op-engineering/op-sqlite": "^15.2.5",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-community/netinfo": "11.4.1",
"@tanstack/db": "^0.6.8",
"@tanstack/electric-db-collection": "^0.3.6",
"@tanstack/offline-transactions": "^1.0.33",
"@tanstack/react-db": "^0.1.86",
"@tanstack/react-native-db-sqlite-persistence": "^0.2.0",
"@tanstack/db": "^0.6.10",
"@tanstack/electric-db-collection": "^0.3.8",
"@tanstack/offline-transactions": "^1.0.35",
"@tanstack/react-db": "^0.1.88",
"@tanstack/react-native-db-sqlite-persistence": "^0.2.2",
"@tanstack/react-query": "^5.90.20",
"expo": "~53.0.26",
"expo-constants": "~17.1.0",
Expand Down
10 changes: 5 additions & 5 deletions examples/react/offline-transactions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
"build": "vite build && tsc --noEmit"
},
"dependencies": {
"@tanstack/browser-db-sqlite-persistence": "^0.2.0",
"@tanstack/db": "^0.6.8",
"@tanstack/offline-transactions": "^1.0.33",
"@tanstack/query-db-collection": "^1.0.40",
"@tanstack/react-db": "^0.1.86",
"@tanstack/browser-db-sqlite-persistence": "^0.2.2",
"@tanstack/db": "^0.6.10",
"@tanstack/offline-transactions": "^1.0.35",
"@tanstack/query-db-collection": "^1.0.42",
"@tanstack/react-db": "^0.1.88",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-router": "^1.159.5",
"@tanstack/react-router-devtools": "^1.159.5",
Expand Down
4 changes: 2 additions & 2 deletions examples/react/paced-mutations-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/db": "^0.6.8",
"@tanstack/react-db": "^0.1.86",
"@tanstack/db": "^0.6.10",
"@tanstack/react-db": "^0.1.88",
"mitt": "^3.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
Expand Down
4 changes: 2 additions & 2 deletions examples/react/projects/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/query-core": "^5.90.20",
"@tanstack/query-db-collection": "^1.0.40",
"@tanstack/react-db": "^0.1.86",
"@tanstack/query-db-collection": "^1.0.42",
"@tanstack/react-db": "^0.1.88",
"@tanstack/react-router": "^1.159.5",
"@tanstack/react-router-devtools": "^1.159.5",
"@tanstack/react-router-with-query": "^1.130.17",
Expand Down
8 changes: 4 additions & 4 deletions examples/react/todo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
"private": true,
"version": "0.1.25",
"dependencies": {
"@tanstack/electric-db-collection": "^0.3.6",
"@tanstack/electric-db-collection": "^0.3.8",
"@tanstack/query-core": "^5.90.20",
"@tanstack/query-db-collection": "^1.0.40",
"@tanstack/react-db": "^0.1.86",
"@tanstack/query-db-collection": "^1.0.42",
"@tanstack/react-db": "^0.1.88",
"@tanstack/react-router": "^1.159.5",
"@tanstack/react-start": "^1.159.5",
"@tanstack/trailbase-db-collection": "^0.1.86",
"@tanstack/trailbase-db-collection": "^0.1.88",
"cors": "^2.8.6",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
Expand Down
8 changes: 4 additions & 4 deletions examples/solid/todo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
"private": true,
"version": "0.0.35",
"dependencies": {
"@tanstack/electric-db-collection": "^0.3.6",
"@tanstack/electric-db-collection": "^0.3.8",
"@tanstack/query-core": "^5.90.20",
"@tanstack/query-db-collection": "^1.0.40",
"@tanstack/solid-db": "^0.2.22",
"@tanstack/query-db-collection": "^1.0.42",
"@tanstack/solid-db": "^0.2.24",
"@tanstack/solid-router": "^1.159.5",
"@tanstack/solid-start": "^1.159.5",
"@tanstack/trailbase-db-collection": "^0.1.86",
"@tanstack/trailbase-db-collection": "^0.1.88",
"cors": "^2.8.6",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
Expand Down
14 changes: 14 additions & 0 deletions packages/angular-db/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# @tanstack/angular-db

## 0.1.70

### Patch Changes

- Updated dependencies [[`307fdf8`](https://github.com/TanStack/db/commit/307fdf80f522a39a50e316316b3b75ba27fd5e84)]:
- @tanstack/db@0.6.10

## 0.1.69

### Patch Changes

- Updated dependencies [[`2147345`](https://github.com/TanStack/db/commit/2147345236ceee6e73d9fc6c0cdc2385833199fc), [`00389a4`](https://github.com/TanStack/db/commit/00389a47b258ad58fc3a03c5cc6f66957b9bd2d1)]:
- @tanstack/db@0.6.9

## 0.1.68

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/angular-db/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tanstack/angular-db",
"version": "0.1.68",
"version": "0.1.70",
"description": "Angular integration for @tanstack/db",
"author": "Ethan McDaniel",
"license": "MIT",
Expand Down
Loading
Loading