From 3124c9e49e831a0a012295d4e82a55cfda95f848 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Fri, 12 Jun 2026 16:01:44 +0200 Subject: [PATCH 1/6] Added attachments support. --- packages/powersync-db-collection/package.json | 4 +- .../src/attachments.ts | 181 ++++++++++++++++++ pnpm-lock.yaml | 23 ++- 3 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 packages/powersync-db-collection/src/attachments.ts diff --git a/packages/powersync-db-collection/package.json b/packages/powersync-db-collection/package.json index 93444c4f2..66f2af4ae 100644 --- a/packages/powersync-db-collection/package.json +++ b/packages/powersync-db-collection/package.json @@ -59,10 +59,10 @@ "p-defer": "^4.0.1" }, "peerDependencies": { - "@powersync/common": "^1.41.0" + "@powersync/common": "^1.54.0" }, "devDependencies": { - "@powersync/common": "1.49.0", + "@powersync/common": "1.54.0", "@powersync/node": "0.18.1", "@types/debug": "^4.1.12", "@vitest/coverage-istanbul": "^3.2.4", diff --git a/packages/powersync-db-collection/src/attachments.ts b/packages/powersync-db-collection/src/attachments.ts new file mode 100644 index 000000000..9359abfc9 --- /dev/null +++ b/packages/powersync-db-collection/src/attachments.ts @@ -0,0 +1,181 @@ +import { + AttachmentQueue, + AttachmentState, + AttachmentTable, + Schema, +} from '@powersync/common' +import { createTransaction } from '@tanstack/db' +import { PowerSyncTransactor } from './PowerSyncTransactor' + +import type { + AbstractPowerSyncDatabase, + AttachmentData, + AttachmentErrorHandler, + ILogger, + LocalStorageAdapter, + RemoteStorageAdapter, + WatchedAttachmentItem, +} from '@powersync/common' +import type { Collection } from '@tanstack/db' + +type AttachmentQueueRow = (typeof _tmpSchema)['types']['attachments'] + +/** + * This extends the default AttachmentQueue constructor params + * FIXME(powersync) we should export this type from the common SDK. + */ +type TanStackDBAttachmentQueueOptions = { + db: AbstractPowerSyncDatabase + /** + * 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 + remoteStorage: RemoteStorageAdapter + localStorage: LocalStorageAdapter + watchAttachments: ( + onUpdate: (attachment: Array) => Promise, + signal: AbortSignal, + ) => void + tableName?: string + logger?: ILogger + syncIntervalMs?: number + syncThrottleDuration?: number + downloadAttachments?: boolean + archivedCacheLimit?: number + errorHandler?: AttachmentErrorHandler +} + +interface SaveFileTanStackOptions { + data: AttachmentData + fileExtension: string + mediaType?: string + metaData?: string + id?: string + /** + * Note that this is called inside a synchronous TanStackDB transaction, + * any mutations made to other collections will be in the same transaction. + */ + updateHook?: (attachment: AttachmentQueueRow) => Promise +} + +interface DeleteFileTanStackOptions { + id: string + updateHook?: (attachment: AttachmentQueueRow) => Promise +} + +const _tmpSchema = new Schema({ + attachments: new AttachmentTable(), +}) + +/** + * A custom extension of the PowerSyncAttachmentQueue for TanStackDB. + */ +export class TanStackDBAttachmentQueue extends AttachmentQueue { + readonly powersync: AbstractPowerSyncDatabase + readonly collection: Collection + + 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 saveFileTanStack({ + data, + fileExtension, + mediaType, + metaData, + id, + updateHook, + }: SaveFileTanStackOptions): Promise { + const resolvedId = id ?? (await this.generateAttachmentId()) + const filename = `${resolvedId}.${fileExtension}` + const localUri = this.localStorage.getLocalUri(filename) + const size = await this.localStorage.saveFile(localUri, data) + + 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 deleteFileTanStack({ + id, + updateHook, + }: DeleteFileTanStackOptions): Promise { + 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() + }) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad174b46b..c4562916d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1338,11 +1338,11 @@ importers: version: 4.0.1 devDependencies: '@powersync/common': - specifier: 1.49.0 - version: 1.49.0 + specifier: 1.54.0 + version: 1.54.0 '@powersync/node': specifier: 0.18.1 - version: 0.18.1(@powersync/common@1.49.0)(better-sqlite3@12.8.0) + version: 0.18.1(@powersync/common@1.54.0)(better-sqlite3@12.8.0) '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -4831,8 +4831,8 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@powersync/common@1.49.0': - resolution: {integrity: sha512-g6uonubvtmtyx8hS/G5trg9LsBvzHY3tAKHiV7SIQV3Xyz9ONM6NNnjDMP2vcLZVmsOSi8x/QJZmy/ig1YtBMg==} + '@powersync/common@1.54.0': + resolution: {integrity: sha512-/gzitw4iQL4UI7ILf7TUzCy/cfbDJGU3/aiN/ciaLtDd2Uts3wYARVKclSW0OJhPPisKCX0E8Ev/iZGQPbTgDA==} '@powersync/node@0.18.1': resolution: {integrity: sha512-fcTICgs61CAEb39xiC7pedYsPgbjUInJ/47dr7RIdnEHpAgjWH8bW95/b70qK1fQUANy9lKBBF3PcmfswVgfCw==} @@ -9372,6 +9372,9 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-logger@1.6.1: + resolution: {integrity: sha512-yTgMCPXVjhmg28CuUH8CKjU+cIKL/G+zTu4Fn4lQxs8mRFH/03QTNvEFngcxfg/gRDiQAOoyCKmMTOm9ayOzXA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -16480,14 +16483,14 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@powersync/common@1.49.0': + '@powersync/common@1.54.0': dependencies: - async-mutex: 0.5.0 event-iterator: 2.0.0 + js-logger: 1.6.1 - '@powersync/node@0.18.1(@powersync/common@1.49.0)(better-sqlite3@12.8.0)': + '@powersync/node@0.18.1(@powersync/common@1.54.0)(better-sqlite3@12.8.0)': dependencies: - '@powersync/common': 1.49.0 + '@powersync/common': 1.54.0 async-mutex: 0.5.0 bson: 6.10.4 comlink: 4.4.2 @@ -22133,6 +22136,8 @@ snapshots: js-base64@3.7.8: {} + js-logger@1.6.1: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} From d18ab196cd1b1c320b990e47fde5c8044d82082d Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 18 Jun 2026 16:13:35 +0200 Subject: [PATCH 2/6] Types and tests. --- packages/powersync-db-collection/package.json | 4 +- .../src/attachments.ts | 36 +- packages/powersync-db-collection/src/index.ts | 1 + .../tests/attachments.test.ts | 401 ++++++++++++++++++ pnpm-lock.yaml | 22 +- 5 files changed, 419 insertions(+), 45 deletions(-) create mode 100644 packages/powersync-db-collection/tests/attachments.test.ts diff --git a/packages/powersync-db-collection/package.json b/packages/powersync-db-collection/package.json index 66f2af4ae..937437182 100644 --- a/packages/powersync-db-collection/package.json +++ b/packages/powersync-db-collection/package.json @@ -59,10 +59,10 @@ "p-defer": "^4.0.1" }, "peerDependencies": { - "@powersync/common": "^1.54.0" + "@powersync/common": "^1.55.0" }, "devDependencies": { - "@powersync/common": "1.54.0", + "@powersync/common": "1.55.0", "@powersync/node": "0.18.1", "@types/debug": "^4.1.12", "@vitest/coverage-istanbul": "^3.2.4", diff --git a/packages/powersync-db-collection/src/attachments.ts b/packages/powersync-db-collection/src/attachments.ts index 9359abfc9..c3c9d8f67 100644 --- a/packages/powersync-db-collection/src/attachments.ts +++ b/packages/powersync-db-collection/src/attachments.ts @@ -10,44 +10,22 @@ import { PowerSyncTransactor } from './PowerSyncTransactor' import type { AbstractPowerSyncDatabase, AttachmentData, - AttachmentErrorHandler, - ILogger, - LocalStorageAdapter, - RemoteStorageAdapter, - WatchedAttachmentItem, + AttachmentQueueOptions, } from '@powersync/common' import type { Collection } from '@tanstack/db' -type AttachmentQueueRow = (typeof _tmpSchema)['types']['attachments'] +export type AttachmentQueueRow = (typeof _tmpSchema)['types']['attachments'] -/** - * This extends the default AttachmentQueue constructor params - * FIXME(powersync) we should export this type from the common SDK. - */ -type TanStackDBAttachmentQueueOptions = { - db: AbstractPowerSyncDatabase +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 - remoteStorage: RemoteStorageAdapter - localStorage: LocalStorageAdapter - watchAttachments: ( - onUpdate: (attachment: Array) => Promise, - signal: AbortSignal, - ) => void - tableName?: string - logger?: ILogger - syncIntervalMs?: number - syncThrottleDuration?: number - downloadAttachments?: boolean - archivedCacheLimit?: number - errorHandler?: AttachmentErrorHandler + attachmentsCollection: Collection } -interface SaveFileTanStackOptions { +export interface SaveFileTanStackOptions { data: AttachmentData fileExtension: string mediaType?: string @@ -60,7 +38,7 @@ interface SaveFileTanStackOptions { updateHook?: (attachment: AttachmentQueueRow) => Promise } -interface DeleteFileTanStackOptions { +export interface DeleteFileTanStackOptions { id: string updateHook?: (attachment: AttachmentQueueRow) => Promise } @@ -74,7 +52,7 @@ const _tmpSchema = new Schema({ */ export class TanStackDBAttachmentQueue extends AttachmentQueue { readonly powersync: AbstractPowerSyncDatabase - readonly collection: Collection + readonly collection: Collection constructor(params: TanStackDBAttachmentQueueOptions) { super(params) diff --git a/packages/powersync-db-collection/src/index.ts b/packages/powersync-db-collection/src/index.ts index f8d092805..f96a7a0ee 100644 --- a/packages/powersync-db-collection/src/index.ts +++ b/packages/powersync-db-collection/src/index.ts @@ -1,3 +1,4 @@ +export * from './attachments' export * from './definitions' export * from './powersync' export * from './PowerSyncTransactor' diff --git a/packages/powersync-db-collection/tests/attachments.test.ts b/packages/powersync-db-collection/tests/attachments.test.ts new file mode 100644 index 000000000..411c7cd75 --- /dev/null +++ b/packages/powersync-db-collection/tests/attachments.test.ts @@ -0,0 +1,401 @@ +import { randomUUID } from 'node:crypto' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + AttachmentState, + AttachmentTable, + Schema, + Table, + column, +} from '@powersync/common' +import { NodeFileSystemAdapter, PowerSyncDatabase } from '@powersync/node' +import { + createCollection, + isNull, + liveQueryCollectionOptions, + not, +} from '@tanstack/db' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { powerSyncCollectionOptions } from '../src' +import { TanStackDBAttachmentQueue } from '../src/attachments' +import { TEST_DATABASE_IMPLEMENTATION } from './test-db-implementation' +import type { + AttachmentErrorHandler, + RemoteStorageAdapter, + WatchedAttachmentItem, +} from '@powersync/common' + +// A minimal valid 1x1 pixel JPEG used as the remote payload for downloads. +const MOCK_JPEG_U8A = [ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xd9, +] +const createMockJpegBuffer = (): ArrayBuffer => + new Uint8Array(MOCK_JPEG_U8A).buffer + +const SYNC_INTERVAL_MS = 300 +const WAIT_TIMEOUT = 8000 + +const APP_SCHEMA = new Schema({ + users: new Table({ + name: column.text, + email: column.text, + photo_id: column.text, + }), + attachments: new AttachmentTable(), +}) + +type WatchAttachments = ( + onUpdate: (attachments: Array) => Promise, + signal: AbortSignal, +) => void + +const describePowerSync = TEST_DATABASE_IMPLEMENTATION + ? describe + : describe.skip + +describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { + async function setup() { + const db = new PowerSyncDatabase({ + database: { + dbFilename: `attachments-test-${randomUUID()}.sqlite`, + dbLocation: tmpdir(), + implementation: TEST_DATABASE_IMPLEMENTATION, + }, + schema: APP_SCHEMA, + }) + await db.disconnectAndClear() + + const localStorage = new NodeFileSystemAdapter( + join(tmpdir(), `ps-attachments-${randomUUID()}`), + ) + await localStorage.initialize() + + const uploadFile = vi.fn(() => + Promise.resolve(), + ) + const downloadFile = vi.fn(() => + Promise.resolve(createMockJpegBuffer()), + ) + const deleteFile = vi.fn(() => + Promise.resolve(), + ) + const remoteStorage: RemoteStorageAdapter = { + uploadFile, + downloadFile, + deleteFile, + } + + const attachmentsCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.attachments, + }), + ) + const usersCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.users, + }), + ) + await Promise.all([ + attachmentsCollection.stateWhenReady(), + usersCollection.stateWhenReady(), + ]) + + onTestFinished(async () => { + attachmentsCollection.cleanup() + usersCollection.cleanup() + await db.disconnectAndClear() + await db.close() + await localStorage.clear().catch(() => {}) + }) + + function createQueue( + overrides: { + watchAttachments?: WatchAttachments + archivedCacheLimit?: number + errorHandler?: AttachmentErrorHandler + remoteStorage?: RemoteStorageAdapter + } = {}, + ) { + const queue = new TanStackDBAttachmentQueue({ + db, + attachmentsCollection, + remoteStorage: overrides.remoteStorage ?? remoteStorage, + localStorage, + watchAttachments: overrides.watchAttachments ?? watchPhotoIds, + syncIntervalMs: SYNC_INTERVAL_MS, + archivedCacheLimit: overrides.archivedCacheLimit ?? 0, + errorHandler: overrides.errorHandler, + }) + onTestFinished(() => queue.stopSync()) + return queue + } + + // Reports every photo_id referenced by the users collection as a watched + // attachment. This mirrors how an application links its domain model to the + // attachment queue using a TanStack DB live query rather than a raw SQL + // watch: the `photo_id IS NOT NULL` filter lives in the query, and each + // change re-emits the full set of referenced ids. + const watchPhotoIdsWith = ( + toItem: (photoId: string) => WatchedAttachmentItem, + ): WatchAttachments => { + return async (onUpdate, signal) => { + const livePhotoIds = createCollection( + liveQueryCollectionOptions({ + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => not(isNull(user.photo_id))) + .select(({ user }) => ({ photo_id: user.photo_id })), + }), + ) + + const emit = () => + void onUpdate( + livePhotoIds.toArray + .map((row) => row.photo_id) + .filter((photoId): photoId is string => photoId != null) + .map(toItem), + ) + + // Emit the current snapshot once ready, then on every change. + await livePhotoIds.stateWhenReady() + emit() + const subscription = livePhotoIds.subscribeChanges(() => emit()) + + signal.addEventListener(`abort`, () => { + subscription.unsubscribe() + livePhotoIds.cleanup() + }) + } + } + + const watchPhotoIds = watchPhotoIdsWith((id) => ({ + id, + fileExtension: `jpg`, + })) + + return { + db, + localStorage, + remoteStorage, + uploadFile, + downloadFile, + deleteFile, + attachmentsCollection, + usersCollection, + createQueue, + watchPhotoIds, + watchPhotoIdsWith, + } + } + + /** Waits until the attachment with `id` reaches the expected state. */ + function waitForState( + collection: { get: (id: string) => TRow | undefined }, + id: string, + state: AttachmentState, + ): Promise { + return vi.waitFor( + () => { + const attachment = collection.get(id) + expect( + (attachment as { state?: AttachmentState } | undefined)?.state, + ).toBe(state) + return attachment! + }, + { timeout: WAIT_TIMEOUT, interval: 50 }, + ) + } + + describe(`saveFileTanStack`, () => { + it(`writes the local file and inserts a QUEUED_UPLOAD row into the collection`, async () => { + const { createQueue, attachmentsCollection, localStorage } = await setup() + const queue = createQueue() + + const data = new Uint8Array(123).fill(42).buffer + const record = await queue.saveFileTanStack({ + data, + fileExtension: `jpg`, + mediaType: `image/jpeg`, + }) + + expect(record.size).toBe(123) + expect(record.state).toBe(AttachmentState.QUEUED_UPLOAD) + expect(record.media_type).toBe(`image/jpeg`) + expect(record.filename).toBe(`${record.id}.jpg`) + expect(record.has_synced).toBe(0) + + // The file should exist on disk at the returned local_uri. + expect(await localStorage.fileExists(record.local_uri)).toBe(true) + + // The row should be reflected in the collection once it syncs back. + await waitForState( + attachmentsCollection, + record.id, + AttachmentState.QUEUED_UPLOAD, + ) + }) + + it(`commits the updateHook mutation atomically with the attachment row`, async () => { + const { createQueue, attachmentsCollection, usersCollection } = + await setup() + const queue = createQueue() + + const userId = randomUUID() + const record = await queue.saveFileTanStack({ + data: createMockJpegBuffer(), + fileExtension: `jpg`, + updateHook: async (attachment) => { + usersCollection.insert({ + id: userId, + name: `steven`, + email: `steven@journeyapps.com`, + photo_id: attachment.id, + }) + }, + }) + + // Both the attachment and the linked user row should appear together. + await waitForState( + attachmentsCollection, + record.id, + AttachmentState.QUEUED_UPLOAD, + ) + await vi.waitFor( + () => { + const user = usersCollection.get(userId) + expect(user?.photo_id).toBe(record.id) + }, + { timeout: WAIT_TIMEOUT, interval: 50 }, + ) + }) + + it(`uploads the saved file and transitions it to SYNCED`, async () => { + const { + createQueue, + attachmentsCollection, + usersCollection, + uploadFile, + } = await setup() + const queue = createQueue() + await queue.startSync() + + const userId = randomUUID() + const record = await queue.saveFileTanStack({ + data: createMockJpegBuffer(), + fileExtension: `jpg`, + updateHook: async (attachment) => { + usersCollection.insert({ + id: userId, + name: `steven`, + email: `steven@journeyapps.com`, + photo_id: attachment.id, + }) + }, + }) + + await waitForState( + attachmentsCollection, + record.id, + AttachmentState.SYNCED, + ) + + expect(uploadFile).toHaveBeenCalled() + const [, uploadedAttachment] = uploadFile.mock.calls[0]! + expect(uploadedAttachment.id).toBe(record.id) + }) + + it(`honours a caller-supplied id`, async () => { + const { createQueue } = await setup() + const queue = createQueue() + + const id = `my-custom-id` + const record = await queue.saveFileTanStack({ + id, + data: createMockJpegBuffer(), + fileExtension: `png`, + }) + + expect(record.id).toBe(id) + expect(record.filename).toBe(`${id}.png`) + }) + }) + + describe(`deleteFileTanStack`, () => { + it(`queues an existing attachment for deletion and removes the local file`, async () => { + const { + createQueue, + attachmentsCollection, + usersCollection, + localStorage, + } = await setup() + const queue = createQueue() + await queue.startSync() + + const userId = randomUUID() + const record = await queue.saveFileTanStack({ + data: createMockJpegBuffer(), + fileExtension: `jpg`, + updateHook: async (attachment) => { + usersCollection.insert({ + id: userId, + name: `steven`, + email: `steven@journeyapps.com`, + photo_id: attachment.id, + }) + }, + }) + + await waitForState( + attachmentsCollection, + record.id, + AttachmentState.SYNCED, + ) + + await queue.deleteFileTanStack({ + id: record.id, + updateHook: async (attachment) => { + usersCollection.update(userId, (draft) => { + if (draft.photo_id === attachment.id) { + draft.photo_id = null + } + }) + }, + }) + + // It should immediately be marked for deletion (and no longer synced). + const queued = attachmentsCollection.get(record.id) + expect(queued?.state).toBe(AttachmentState.QUEUED_DELETE) + expect(queued?.has_synced).toBe(0) + + // The user reference should have been cleared in the same transaction. + expect(usersCollection.get(userId)?.photo_id).toBeNull() + + // Eventually the row and the local file are removed. + await vi.waitFor( + () => expect(attachmentsCollection.get(record.id)).toBeUndefined(), + { timeout: WAIT_TIMEOUT, interval: 50 }, + ) + expect(await localStorage.fileExists(record.local_uri)).toBe(false) + }) + + it(`throws for an unknown id and commits nothing`, async () => { + const { createQueue, attachmentsCollection, usersCollection } = + await setup() + const queue = createQueue() + + const hook = vi.fn() + await expect( + queue.deleteFileTanStack({ id: `does-not-exist`, updateHook: hook }), + ).rejects.toThrow(/not found/i) + + // The failing transaction must not have run the hook or touched state. + expect(hook).not.toHaveBeenCalled() + expect(attachmentsCollection.get(`does-not-exist`)).toBeUndefined() + expect(usersCollection.size).toBe(0) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4562916d..3852ece3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1338,11 +1338,11 @@ importers: version: 4.0.1 devDependencies: '@powersync/common': - specifier: 1.54.0 - version: 1.54.0 + specifier: 1.55.0 + version: 1.55.0 '@powersync/node': specifier: 0.18.1 - version: 0.18.1(@powersync/common@1.54.0)(better-sqlite3@12.8.0) + version: 0.18.1(@powersync/common@1.55.0)(better-sqlite3@12.8.0) '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -4831,8 +4831,8 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@powersync/common@1.54.0': - resolution: {integrity: sha512-/gzitw4iQL4UI7ILf7TUzCy/cfbDJGU3/aiN/ciaLtDd2Uts3wYARVKclSW0OJhPPisKCX0E8Ev/iZGQPbTgDA==} + '@powersync/common@1.55.0': + resolution: {integrity: sha512-c9K2Gac9wOB4ijVnQT388g7Yeuh6VrfJZFXaL6Rag9Fp7F8JzAYcrWaULLTdMnzm7VW6b6XT5M2ip5yLS8oSBg==} '@powersync/node@0.18.1': resolution: {integrity: sha512-fcTICgs61CAEb39xiC7pedYsPgbjUInJ/47dr7RIdnEHpAgjWH8bW95/b70qK1fQUANy9lKBBF3PcmfswVgfCw==} @@ -8173,9 +8173,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - event-iterator@2.0.0: - resolution: {integrity: sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ==} - event-reduce-js@5.2.7: resolution: {integrity: sha512-Vi6aIiAmakzx81JAwhw8L988aSX5a3ZqqVjHyZa9xFU6P4oT1IotoDreWtjNlS+fvEnASvyIQT565nmkOtns/Q==} engines: {node: '>=16'} @@ -16483,14 +16480,13 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@powersync/common@1.54.0': + '@powersync/common@1.55.0': dependencies: - event-iterator: 2.0.0 js-logger: 1.6.1 - '@powersync/node@0.18.1(@powersync/common@1.54.0)(better-sqlite3@12.8.0)': + '@powersync/node@0.18.1(@powersync/common@1.55.0)(better-sqlite3@12.8.0)': dependencies: - '@powersync/common': 1.54.0 + '@powersync/common': 1.55.0 async-mutex: 0.5.0 bson: 6.10.4 comlink: 4.4.2 @@ -20580,8 +20576,6 @@ snapshots: etag@1.8.1: {} - event-iterator@2.0.0: {} - event-reduce-js@5.2.7: dependencies: array-push-at-sort-position: 4.0.1 From 91c24c6df4579bcca320227a9da4e222ccc42215 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 23 Jun 2026 13:49:32 +0200 Subject: [PATCH 3/6] Docs. --- docs/collections/powersync-collection.md | 167 ++++++++++++++++++ .../src/attachments.ts | 4 + 2 files changed, 171 insertions(+) diff --git a/docs/collections/powersync-collection.md b/docs/collections/powersync-collection.md index c8ddbabbb..b484c5bb9 100644 --- a/docs/collections/powersync-collection.md +++ b/docs/collections/powersync-collection.md @@ -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 + +`saveFileTanStack` 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.saveFileTanStack({ + 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 + +`deleteFileTanStack` 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.deleteFileTanStack({ + 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, + })) +) ``` \ No newline at end of file diff --git a/packages/powersync-db-collection/src/attachments.ts b/packages/powersync-db-collection/src/attachments.ts index c3c9d8f67..cbda93722 100644 --- a/packages/powersync-db-collection/src/attachments.ts +++ b/packages/powersync-db-collection/src/attachments.ts @@ -40,6 +40,10 @@ export interface SaveFileTanStackOptions { export interface DeleteFileTanStackOptions { id: string + /** * + * Note that this is called inside a synchronous TanStackDB transaction, + * any mutations made to other collections will be in the same transaction. + */ updateHook?: (attachment: AttachmentQueueRow) => Promise } From 3770d3b3e87966ec55775cae46e0ea762a136a4c Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 23 Jun 2026 14:01:10 +0200 Subject: [PATCH 4/6] changeset. --- .changeset/curly-planets-lead.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/curly-planets-lead.md diff --git a/.changeset/curly-planets-lead.md b/.changeset/curly-planets-lead.md new file mode 100644 index 000000000..891ffbb52 --- /dev/null +++ b/.changeset/curly-planets-lead.md @@ -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. From 38af8c59b7766ebb1b9f53941c9916dd503508ea Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Wed, 24 Jun 2026 10:45:59 +0200 Subject: [PATCH 5/6] Rename saveFileTanStack => save, deleteFIleTanStack => delete. --- docs/collections/powersync-collection.md | 8 ++++---- .../powersync-db-collection/src/attachments.ts | 13 +++++-------- .../tests/attachments.test.ts | 18 +++++++++--------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/docs/collections/powersync-collection.md b/docs/collections/powersync-collection.md index b484c5bb9..8799f96aa 100644 --- a/docs/collections/powersync-collection.md +++ b/docs/collections/powersync-collection.md @@ -1212,10 +1212,10 @@ const watchAttachments = async (onUpdate, abortSignal) => { ### 4. Save an attachment atomically with related data -`saveFileTanStack` 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. +`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.saveFileTanStack({ +await attachmentQueue.save({ data, // file bytes (ArrayBuffer / base64, per your local adapter) fileExtension: "jpg", updateHook: async (attachmentRecord) => { @@ -1233,10 +1233,10 @@ await attachmentQueue.saveFileTanStack({ ### 5. Delete an attachment and detach it from the row -`deleteFileTanStack` 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. +`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.deleteFileTanStack({ +await attachmentQueue.delete({ id: photo_id, updateHook: async () => { listsCollection.update(listId, (draft) => { diff --git a/packages/powersync-db-collection/src/attachments.ts b/packages/powersync-db-collection/src/attachments.ts index cbda93722..f70f6494e 100644 --- a/packages/powersync-db-collection/src/attachments.ts +++ b/packages/powersync-db-collection/src/attachments.ts @@ -25,7 +25,7 @@ export type TanStackDBAttachmentQueueOptions = AttachmentQueueOptions & { attachmentsCollection: Collection } -export interface SaveFileTanStackOptions { +export interface SaveOptions { data: AttachmentData fileExtension: string mediaType?: string @@ -38,7 +38,7 @@ export interface SaveFileTanStackOptions { updateHook?: (attachment: AttachmentQueueRow) => Promise } -export interface DeleteFileTanStackOptions { +export interface DeleteOptions { id: string /** * * Note that this is called inside a synchronous TanStackDB transaction, @@ -70,14 +70,14 @@ export class TanStackDBAttachmentQueue extends AttachmentQueue { * 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 saveFileTanStack({ + async save({ data, fileExtension, mediaType, metaData, id, updateHook, - }: SaveFileTanStackOptions): Promise { + }: SaveOptions): Promise { const resolvedId = id ?? (await this.generateAttachmentId()) const filename = `${resolvedId}.${fileExtension}` const localUri = this.localStorage.getLocalUri(filename) @@ -128,10 +128,7 @@ export class TanStackDBAttachmentQueue extends AttachmentQueue { * 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 deleteFileTanStack({ - id, - updateHook, - }: DeleteFileTanStackOptions): Promise { + async delete({ id, updateHook }: DeleteOptions): Promise { await this.withAttachmentContext(async (ctx) => { const tanStackDBTransaction = createTransaction({ autoCommit: false, diff --git a/packages/powersync-db-collection/tests/attachments.test.ts b/packages/powersync-db-collection/tests/attachments.test.ts index 411c7cd75..e13ea3dbf 100644 --- a/packages/powersync-db-collection/tests/attachments.test.ts +++ b/packages/powersync-db-collection/tests/attachments.test.ts @@ -210,13 +210,13 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { ) } - describe(`saveFileTanStack`, () => { + describe(`save`, () => { it(`writes the local file and inserts a QUEUED_UPLOAD row into the collection`, async () => { const { createQueue, attachmentsCollection, localStorage } = await setup() const queue = createQueue() const data = new Uint8Array(123).fill(42).buffer - const record = await queue.saveFileTanStack({ + const record = await queue.save({ data, fileExtension: `jpg`, mediaType: `image/jpeg`, @@ -245,7 +245,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { const queue = createQueue() const userId = randomUUID() - const record = await queue.saveFileTanStack({ + const record = await queue.save({ data: createMockJpegBuffer(), fileExtension: `jpg`, updateHook: async (attachment) => { @@ -284,7 +284,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { await queue.startSync() const userId = randomUUID() - const record = await queue.saveFileTanStack({ + const record = await queue.save({ data: createMockJpegBuffer(), fileExtension: `jpg`, updateHook: async (attachment) => { @@ -313,7 +313,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { const queue = createQueue() const id = `my-custom-id` - const record = await queue.saveFileTanStack({ + const record = await queue.save({ id, data: createMockJpegBuffer(), fileExtension: `png`, @@ -324,7 +324,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { }) }) - describe(`deleteFileTanStack`, () => { + describe(`delete file`, () => { it(`queues an existing attachment for deletion and removes the local file`, async () => { const { createQueue, @@ -336,7 +336,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { await queue.startSync() const userId = randomUUID() - const record = await queue.saveFileTanStack({ + const record = await queue.save({ data: createMockJpegBuffer(), fileExtension: `jpg`, updateHook: async (attachment) => { @@ -355,7 +355,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { AttachmentState.SYNCED, ) - await queue.deleteFileTanStack({ + await queue.delete({ id: record.id, updateHook: async (attachment) => { usersCollection.update(userId, (draft) => { @@ -389,7 +389,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { const hook = vi.fn() await expect( - queue.deleteFileTanStack({ id: `does-not-exist`, updateHook: hook }), + queue.delete({ id: `does-not-exist`, updateHook: hook }), ).rejects.toThrow(/not found/i) // The failing transaction must not have run the hook or touched state. From d82ffc2b031e989143f0dde3ad630a0c7ab579a4 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Wed, 24 Jun 2026 16:04:29 +0200 Subject: [PATCH 6/6] Made `updateHook` synchronous and updated `AttachmentQueueRow` typing. --- packages/powersync-db-collection/package.json | 6 ++-- .../src/attachments.ts | 28 +++++++--------- .../tests/attachments.test.ts | 4 +-- pnpm-lock.yaml | 33 +++++++------------ 4 files changed, 29 insertions(+), 42 deletions(-) diff --git a/packages/powersync-db-collection/package.json b/packages/powersync-db-collection/package.json index 937437182..fde2ae382 100644 --- a/packages/powersync-db-collection/package.json +++ b/packages/powersync-db-collection/package.json @@ -59,11 +59,11 @@ "p-defer": "^4.0.1" }, "peerDependencies": { - "@powersync/common": "^1.55.0" + "@powersync/common": "^1.57.0" }, "devDependencies": { - "@powersync/common": "1.55.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" diff --git a/packages/powersync-db-collection/src/attachments.ts b/packages/powersync-db-collection/src/attachments.ts index f70f6494e..d3c48fb1d 100644 --- a/packages/powersync-db-collection/src/attachments.ts +++ b/packages/powersync-db-collection/src/attachments.ts @@ -1,8 +1,6 @@ import { AttachmentQueue, - AttachmentState, - AttachmentTable, - Schema, + AttachmentState } from '@powersync/common' import { createTransaction } from '@tanstack/db' import { PowerSyncTransactor } from './PowerSyncTransactor' @@ -11,10 +9,10 @@ import type { AbstractPowerSyncDatabase, AttachmentData, AttachmentQueueOptions, -} from '@powersync/common' -import type { Collection } from '@tanstack/db' -export type AttachmentQueueRow = (typeof _tmpSchema)['types']['attachments'] + AttachmentTable} from '@powersync/common' +import type { Collection } from '@tanstack/db' +import type { OptionalExtractedTable } from './helpers' export type TanStackDBAttachmentQueueOptions = AttachmentQueueOptions & { /** @@ -32,24 +30,22 @@ export interface SaveOptions { metaData?: string id?: string /** - * Note that this is called inside a synchronous TanStackDB transaction, - * any mutations made to other collections will be in the same transaction. + * 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) => Promise + updateHook?: (attachment: AttachmentQueueRow) => void } export interface DeleteOptions { id: string - /** * - * Note that this is called inside a synchronous TanStackDB transaction, - * any mutations made to other collections will be in the same transaction. + /** + * 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) => Promise + updateHook?: (attachment: AttachmentQueueRow) => void } -const _tmpSchema = new Schema({ - attachments: new AttachmentTable(), -}) +export type AttachmentQueueRow = OptionalExtractedTable /** * A custom extension of the PowerSyncAttachmentQueue for TanStackDB. diff --git a/packages/powersync-db-collection/tests/attachments.test.ts b/packages/powersync-db-collection/tests/attachments.test.ts index e13ea3dbf..c5925e234 100644 --- a/packages/powersync-db-collection/tests/attachments.test.ts +++ b/packages/powersync-db-collection/tests/attachments.test.ts @@ -229,7 +229,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { expect(record.has_synced).toBe(0) // The file should exist on disk at the returned local_uri. - expect(await localStorage.fileExists(record.local_uri)).toBe(true) + expect(await localStorage.fileExists(record.local_uri!)).toBe(true) // The row should be reflected in the collection once it syncs back. await waitForState( @@ -379,7 +379,7 @@ describePowerSync(`PowerSync AttachmentQueue (TanStackDB)`, () => { () => expect(attachmentsCollection.get(record.id)).toBeUndefined(), { timeout: WAIT_TIMEOUT, interval: 50 }, ) - expect(await localStorage.fileExists(record.local_uri)).toBe(false) + expect(await localStorage.fileExists(record.local_uri!)).toBe(false) }) it(`throws for an unknown id and commits nothing`, async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3852ece3e..6e61ea7bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1338,11 +1338,11 @@ importers: version: 4.0.1 devDependencies: '@powersync/common': - specifier: 1.55.0 - version: 1.55.0 + specifier: 1.57.0 + version: 1.57.0 '@powersync/node': - specifier: 0.18.1 - version: 0.18.1(@powersync/common@1.55.0)(better-sqlite3@12.8.0) + specifier: 0.19.2 + version: 0.19.2(@powersync/common@1.57.0)(better-sqlite3@12.8.0) '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -4831,13 +4831,13 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@powersync/common@1.55.0': - resolution: {integrity: sha512-c9K2Gac9wOB4ijVnQT388g7Yeuh6VrfJZFXaL6Rag9Fp7F8JzAYcrWaULLTdMnzm7VW6b6XT5M2ip5yLS8oSBg==} + '@powersync/common@1.57.0': + resolution: {integrity: sha512-uYccCxK5mwahELRouY3YY584TZgjFU8wPPKZQQ6sAOUoMikV8D/+v+UYsNI280MKMnhFqLkxk4TPZIG7ArIzTQ==} - '@powersync/node@0.18.1': - resolution: {integrity: sha512-fcTICgs61CAEb39xiC7pedYsPgbjUInJ/47dr7RIdnEHpAgjWH8bW95/b70qK1fQUANy9lKBBF3PcmfswVgfCw==} + '@powersync/node@0.19.2': + resolution: {integrity: sha512-lF7v/rkiLujAojn7Vjgvs1AibhL5zlEQVYO0iCUGoE1S1Hw7lxfUvAa1mTneKWCEmj0EC9yQBHkPUyBDZXVdLA==} peerDependencies: - '@powersync/common': ^1.49.0 + '@powersync/common': ^1.57.0 better-sqlite3: 12.x peerDependenciesMeta: better-sqlite3: @@ -6799,9 +6799,6 @@ packages: async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} - async-mutex@0.5.0: - resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -16480,15 +16477,13 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@powersync/common@1.55.0': + '@powersync/common@1.57.0': dependencies: js-logger: 1.6.1 - '@powersync/node@0.18.1(@powersync/common@1.55.0)(better-sqlite3@12.8.0)': + '@powersync/node@0.19.2(@powersync/common@1.57.0)(better-sqlite3@12.8.0)': dependencies: - '@powersync/common': 1.55.0 - async-mutex: 0.5.0 - bson: 6.10.4 + '@powersync/common': 1.57.0 comlink: 4.4.2 undici: 7.24.4 optionalDependencies: @@ -18956,10 +18951,6 @@ snapshots: async-limiter@1.0.1: {} - async-mutex@0.5.0: - dependencies: - tslib: 2.8.1 - asynckit@0.4.0: {} at-least-node@1.0.0: {}